Files
mesh-catalog/modules/mesh-mail-web/cmd/mail-page-dev/reading.go
T
jochen d8144b704d issue 1859: run the mail page locally with invented mail (1859.5)
So the operator can click through the page on their own machine before it is delivered: cmd/mail-page-dev
serves internal/web's real handlers on 127.0.0.1 alone, over invented mail in two accounts and an in-memory
mail reading that fills every section, with an always-signed-in stand-in; nothing reaches the bus, a
database, Keycloak or a mail server. -now fixes today. The README says how to run it and what is a stand-in.
2026-10-22 20:14:24 +02:00

518 lines
18 KiB
Go

package main
// The local run's mail reading (issue 1859.5): mail-reading's verbs — entries, groups, status, correct,
// lessons, undo, list, handle, unhandle, remind, record — over invented entries held in memory, answered in
// mesh-mail-reader's own shapes (tools.go entryOut, listverbs.go, list.go listItemOut) and by its rules
// (list.go BuildList: today, this week and later by due date in Europe/Brussels; promises by kind; good to
// know; read later; handled today with its undo until the day ends; a reminder hides an item until the next
// day). A stand-in: no judgement, no database, nothing kept past the run.
import (
"context"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
)
type devTask struct {
N int
Line string
Due time.Time // a day, midnight UTC; zero for none
}
type devPromise struct {
N int
Kind, Line, Who string
Date time.Time
}
type devEntry struct {
ID int64
Mail *devMail
Summary, Mark, Why, Group string
Tasks []devTask
Promises []devPromise
ReadLater, Told bool
Judged time.Time
}
type handling struct{ Handled, ShowsAgain time.Time }
type devLesson struct {
ID int64
Sender, Subject, Change string
Made time.Time
Undone bool
}
type actionLine struct {
What, Item, Mode string
Entry int64
At time.Time
}
// reading is the in-memory mail reading.
type reading struct {
mu sync.Mutex
now func() time.Time
entries []*devEntry
handlings map[string]handling // "<id> <item>"
lessons []devLesson
actions []actionLine
}
var brussels = func() *time.Location {
loc, err := time.LoadLocation("Europe/Brussels")
if err != nil {
panic(err)
}
return loc
}()
func dayOf(t time.Time) time.Time {
y, m, d := t.In(brussels).Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}
func endOfWeek(day time.Time) time.Time { return day.AddDate(0, 0, (7-int(day.Weekday()))%7) }
func newReading(start time.Time, now func() time.Time, m map[string]*devMail) *reading {
today := dayOf(start)
sunday := endOfWeek(today)
thisWeek := today.AddDate(0, 0, 2)
if thisWeek.After(sunday) {
thisWeek = sunday
}
r := &reading{now: now, handlings: map[string]handling{}}
id := int64(0)
add := func(mail *devMail, mark, summary, why, group string, tasks []devTask, promises []devPromise, readLater, told bool) *devEntry {
id++
e := &devEntry{ID: id, Mail: mail, Mark: mark, Summary: summary, Why: why, Group: group, Tasks: tasks,
Promises: promises, ReadLater: readLater, Told: told, Judged: mail.Date.Add(3 * time.Minute)}
r.entries = append(r.entries, e)
return e
}
add(m["offer"], "matters", "Sara needs the signed Harbor offer back today", "a contract to sign, due today", "the Harbor demo",
[]devTask{{1, "Sign the Harbor offer and send it back to Sara", today}}, nil, false, true)
add(m["quote"], "matters", "Tim is preparing the revised quote", "a quote the demo waits on", "the Harbor demo",
nil, []devPromise{{1, "waiting on others", "Tim sends the revised quote before Friday", "Tim Jacobs", thisWeek}}, false, false)
add(m["hostco"], "matters", "Hostco's renewal needs approving; the contract is on its way", "a renewal to approve", "suppliers",
[]devTask{{1, "Approve the Hostco renewal", thisWeek}, {2, "Forward the Hostco invoice to finance", time.Time{}}},
[]devPromise{{1, "on its way", "The new Hostco contract comes by post", "Hostco", time.Time{}}}, false, false)
add(m["room"], "matters", "Sara asks for a room for the demo", "a booking to make", "the Harbor demo",
[]devTask{{1, "Book a room for twelve for the Harbor demo", today.AddDate(0, 0, 14)}}, nil, false, false)
add(m["slides"], "matters", "Tim waits for the demo slides you promised", "a promise of yours", "the Harbor demo",
nil, []devPromise{{1, "you promised", "Send Tim the demo slides", "Tim Jacobs", today.AddDate(0, 0, 1)}}, false, false)
add(m["newsletter"], "neither", "The Harbor team's autumn newsletter", "news, nothing asked", "newsletters", nil, nil, true, false)
add(m["router"], "neither", "The new Telco router arrives tomorrow", "a delivery", "deliveries",
nil, []devPromise{{1, "on its way", "The Telco router arrives tomorrow", "Telco", today.AddDate(0, 0, 1)}}, false, false)
add(m["digest"], "neither", "This week's digest of the list", "a digest", "lists", nil, nil, false, false)
add(m["parcel"], "scam warning", "A courier fee asked by an unknown sender", "a fee asked by a sender you never had mail from, with a link elsewhere", "",
nil, nil, false, true)
add(m["thanks"], "neither", "Lena thanks you for Tuesday", "thanks, nothing asked", "suppliers", nil, nil, false, false)
agenda := add(m["agenda"], "matters", "Sara asks which kick-off slot suits you", "a question to answer", "the Harbor demo",
[]devTask{{1, "Tell Sara which kick-off slot suits you", today}, {2, "Check the kick-off time slots", today}}, nil, false, false)
add(m["bill"], "matters", "The Telco bill of 34.50 EUR is due on the 28th", "a bill to pay", "bills",
[]devTask{{1, "Pay the Telco bill of 34.50 EUR", today.AddDate(0, 0, 6)}}, nil, false, false)
add(m["photos"], "neither", "Lena's photos from the weekend", "photos, no hurry", "", nil, nil, true, false)
add(m["club"], "neither", "The book club's next book", "news of the club", "clubs", nil, nil, false, false)
add(m["dentist"], "matters", "The dentist asks you to confirm Tuesday's appointment", "an appointment to confirm", "",
[]devTask{{1, "Confirm the dentist appointment of next Tuesday", today.AddDate(0, 0, 1)}}, nil, false, false)
// Handled earlier today, so handled today shows something with its undo.
r.handlings[key(agenda.ID, "task 2")] = handling{Handled: start.Add(-30 * time.Minute)}
r.lessons = append(r.lessons, devLesson{ID: 1, Sender: "news@harbor.example", Subject: "Harbor news", Change: "mark: matters -> neither",
Made: start.Add(-72 * time.Hour)})
r.actions = append(r.actions, actionLine{What: "page opened", Mode: "everything", At: start.Add(-3 * time.Hour)})
return r
}
func key(id int64, item string) string { return strconv.FormatInt(id, 10) + " " + item }
func date(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format("2006-01-02")
}
func (r *reading) entry(id int64) *devEntry {
for _, e := range r.entries {
if e.ID == id {
return e
}
}
return nil
}
func (r *reading) holds(e *devEntry, item string) bool {
if item == "mail" {
return true
}
var n int
if _, err := fmt.Sscanf(item, "task %d", &n); err == nil {
for _, t := range e.Tasks {
if t.N == n {
return true
}
}
}
if _, err := fmt.Sscanf(item, "promise %d", &n); err == nil {
for _, p := range e.Promises {
if p.N == n {
return true
}
}
}
return false
}
// verb is a mail-reading verb over this store, answered as JSON.
func (r *reading) verb(f func(args map[string]any) (any, error)) func(context.Context, map[string]any) (json.RawMessage, error) {
return func(_ context.Context, args map[string]any) (json.RawMessage, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := f(args)
if err != nil {
return nil, err
}
return json.Marshal(out)
}
}
func entryOut(e *devEntry) map[string]any {
tasks := []map[string]any{}
for _, t := range e.Tasks {
tasks = append(tasks, map[string]any{"n": t.N, "line": t.Line, "due-date": date(t.Due)})
}
promises := []map[string]any{}
for _, p := range e.Promises {
promises = append(promises, map[string]any{"n": p.N, "kind": p.Kind, "line": p.Line, "who": p.Who, "date": date(p.Date)})
}
m := e.Mail
return map[string]any{
"id": e.ID, "account": m.Account, "message-id": m.MessageID, "date": m.Date.UTC().Format(time.RFC3339),
"sender-name": m.From.Name, "subject": m.Subject, "group": e.Group, "summary": e.Summary, "mark": e.Mark,
"why": e.Why, "due-date": "", "folder": m.Folder, "uid": m.UID, "sender-address": m.From.Address,
"tasks": tasks, "promises": promises, "read-later": e.ReadLater,
}
}
func (r *reading) entriesVerb(args map[string]any) (any, error) {
id := int64(number(args, "id"))
var out []map[string]any
for _, e := range r.entries {
if id != 0 && e.ID != id {
continue
}
if mark := text(args, "mark"); mark != "" && e.Mark != mark {
continue
}
out = append(out, entryOut(e))
}
return map[string]any{"entries": out}, nil
}
func (r *reading) groupsVerb(map[string]any) (any, error) {
counts := map[string]int{}
for _, e := range r.entries {
if e.Group != "" {
counts[e.Group]++
}
}
var out []map[string]any
for label, n := range counts {
out = append(out, map[string]any{"label": label, "count": n})
}
sort.Slice(out, func(i, j int) bool { return out[i]["count"].(int) > out[j]["count"].(int) })
return map[string]any{"groups": out}, nil
}
func (r *reading) statusVerb(map[string]any) (any, error) {
monday := dayOf(r.now()).AddDate(0, 0, -((int(dayOf(r.now()).Weekday()) + 6) % 7))
return map[string]any{"healthy": true, "last-success": r.now().Add(-2 * time.Minute).UTC().Format(time.RFC3339),
"weeks": []map[string]any{{"week-start": date(monday), "mails-read": len(r.entries), "lessons": len(r.lessons),
"lessons-per-100": float64(len(r.lessons)) * 100 / float64(len(r.entries))}}}, nil
}
func (r *reading) lessonsVerb(map[string]any) (any, error) {
var out []map[string]any
for i := len(r.lessons) - 1; i >= 0; i-- {
l := r.lessons[i]
if l.Undone {
continue
}
out = append(out, map[string]any{"id": l.ID, "sender-name": "", "sender-address": l.Sender, "subject": l.Subject,
"change": l.Change, "made": l.Made.UTC().Format(time.RFC3339), "undone": false})
}
return map[string]any{"lessons": out}, nil
}
func (r *reading) undoVerb(args map[string]any) (any, error) {
id := int64(number(args, "id"))
for i := range r.lessons {
if r.lessons[i].ID == id {
r.lessons[i].Undone = true
return map[string]any{"undone": id}, nil
}
}
return nil, fmt.Errorf("mail-reading.undo: no reading lesson %d is held", id)
}
func (r *reading) line(what string, id int64, item, mode string) {
if mode == "" {
mode = "everything"
}
r.actions = append(r.actions, actionLine{What: what, Entry: id, Item: item, Mode: mode, At: r.now()})
}
func (r *reading) correctVerb(args map[string]any) (any, error) {
e := r.entry(int64(number(args, "id")))
if e == nil {
return nil, fmt.Errorf("mail-reading.correct: no mail entry %d is held", number(args, "id"))
}
var changes []string
item := "mail"
if mark := text(args, "mark"); mark != "" && mark != e.Mark {
changes = append(changes, "mark: "+e.Mark+" -> "+mark)
e.Mark = mark
}
if group := text(args, "group"); group != "" && group != e.Group {
changes = append(changes, "mail group: "+e.Group+" -> "+group)
e.Group = group
}
if n := number(args, "not-a-task"); n != 0 {
var kept []devTask
for _, t := range e.Tasks {
if t.N == n {
changes = append(changes, "not a task: "+t.Line)
item = "task " + strconv.Itoa(n)
continue
}
kept = append(kept, t)
}
e.Tasks = kept
}
if n := number(args, "remove-promise"); n != 0 {
var kept []devPromise
for _, p := range e.Promises {
if p.N == n {
changes = append(changes, "not a promise ("+p.Kind+"): "+p.Line)
item = "promise " + strconv.Itoa(n)
continue
}
kept = append(kept, p)
}
e.Promises = kept
}
if v, ok := args["read-later"].(bool); ok && v != e.ReadLater {
changes = append(changes, fmt.Sprintf("read later: %v -> %v", e.ReadLater, v))
e.ReadLater = v
}
if len(changes) == 0 {
return nil, fmt.Errorf("mail-reading.correct needs a change that actually changes the entry")
}
r.lessons = append(r.lessons, devLesson{ID: int64(len(r.lessons) + 1), Sender: e.Mail.From.Address, Subject: e.Mail.Subject,
Change: strings.Join(changes, "; "), Made: r.now()})
r.line("corrected", e.ID, item, text(args, "mode"))
return entryOut(e), nil
}
// act is handle, unhandle and remind (ADR 0517 §1, §2), each refusing as mesh-mail-reader's listverbs.go does.
func (r *reading) act(what string) func(args map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
id := int64(number(args, "id"))
item := text(args, "item")
if item == "" {
item = "mail"
}
e := r.entry(id)
if e == nil {
return nil, fmt.Errorf("mail-reading.%s: no mail entry %d is held", what, id)
}
if !r.holds(e, item) {
return nil, fmt.Errorf("mail-reading.%s: the mail entry %d holds no %s", what, id, item)
}
h := r.handlings[key(id, item)]
now := r.now()
switch what {
case "handle":
if !h.Handled.IsZero() {
return nil, fmt.Errorf("mail-reading.handle: %s of mail entry %d is handled already", item, id)
}
h.Handled = now
r.line("handled", id, item, text(args, "mode"))
case "unhandle":
if h.Handled.IsZero() {
return nil, fmt.Errorf("mail-reading.unhandle: %s of mail entry %d is not handled", item, id)
}
if !dayOf(h.Handled).Equal(dayOf(now)) {
return nil, fmt.Errorf("mail-reading.unhandle: %s of mail entry %d was handled before today, and an undo lasts until the day ends", item, id)
}
h.Handled = time.Time{}
r.line("undone", id, item, text(args, "mode"))
case "remind":
if !h.Handled.IsZero() {
return nil, fmt.Errorf("mail-reading.remind: %s of mail entry %d is handled; undo it first", item, id)
}
h.ShowsAgain = dayOf(now).AddDate(0, 0, 1)
r.line("reminded", id, item, text(args, "mode"))
}
r.handlings[key(id, item)] = h
return map[string]any{what: map[string]any{"id": id, "item": item}}, nil
}
}
func (r *reading) recordVerb(args map[string]any) (any, error) {
what := text(args, "what")
if what != "opened" && what != "mode" && what != "page opened" {
return nil, fmt.Errorf("mail-reading.record records opened, mode or page opened; %q is none of them", what)
}
item := ""
if what == "opened" {
item = "mail"
}
r.line(what, int64(number(args, "id")), item, text(args, "mode"))
return map[string]any{"recorded": what}, nil
}
// itemOut is one line of the list (mesh-mail-reader list.go listItemOut).
func itemOut(e *devEntry, item, kind, line string, due time.Time, promiseKind, who string, reminded bool, handled time.Time) map[string]any {
m := e.Mail
out := map[string]any{"id": e.ID, "item": item, "kind": kind, "line": line, "account": m.Account, "folder": m.Folder,
"uid": m.UID, "sender-name": m.From.Name, "sender-address": m.From.Address, "subject": m.Subject,
"date": m.Date.UTC().Format(time.RFC3339), "summary": e.Summary, "mark": e.Mark, "why": e.Why}
if !due.IsZero() {
if kind == "promise" {
out["promise-date"] = date(due)
} else {
out["due-date"] = date(due)
}
}
if kind == "promise" {
out["promise-kind"], out["who"] = promiseKind, who
}
if reminded {
out["reminded"] = true
}
if !handled.IsZero() {
out["handled"], out["undo"] = handled.UTC().Format(time.RFC3339), true
}
return out
}
// listVerb is mesh-mail-reader's list (list.go BuildList), over this store.
func (r *reading) listVerb(args map[string]any) (any, error) {
now := r.now()
today := dayOf(now)
sunday := endOfWeek(today)
wanted := map[string]bool{}
if names, ok := args["accounts"].([]any); ok {
for _, n := range names {
wanted[fmt.Sprint(n)] = true
}
}
sections := map[string][]map[string]any{}
put := func(name string, v map[string]any) { sections[name] = append(sections[name], v) }
for _, e := range r.entries {
if len(wanted) > 0 && !wanted[e.Mail.Account] {
continue
}
if e.Told && dayOf(e.Judged).Equal(today) {
put("told-today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
}
mail := r.handlings[key(e.ID, "mail")]
if !mail.Handled.IsZero() {
if dayOf(mail.Handled).Equal(today) {
put("handled-today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, mail.Handled))
}
continue
}
if mail.ShowsAgain.After(today) {
continue
}
mailToday := mail.ShowsAgain.Equal(today)
if e.Mark == "scam warning" {
put("scam-warnings", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
continue
}
type it struct {
item, kind, line, pkind, who string
due time.Time
}
var items []it
for _, t := range e.Tasks {
items = append(items, it{"task " + strconv.Itoa(t.N), "task", t.Line, "", "", t.Due})
}
for _, p := range e.Promises {
items = append(items, it{"promise " + strconv.Itoa(p.N), "promise", p.Line, p.Kind, p.Who, p.Date})
}
for _, x := range items {
h := r.handlings[key(e.ID, x.item)]
switch {
case !h.Handled.IsZero():
if dayOf(h.Handled).Equal(today) {
put("handled-today", itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, false, h.Handled))
}
case h.ShowsAgain.After(today):
case h.ShowsAgain.Equal(today) || mailToday:
put("today", itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, true, time.Time{}))
case x.kind == "task":
section := "later"
if !x.due.IsZero() && !x.due.After(today) {
section = "today"
} else if !x.due.IsZero() && !x.due.After(sunday) {
section = "this-week"
}
put(section, itemOut(e, x.item, x.kind, x.line, x.due, "", "", false, time.Time{}))
default:
put(strings.ReplaceAll(x.pkind, " ", "-"), itemOut(e, x.item, x.kind, x.line, x.due, x.pkind, x.who, false, time.Time{}))
}
}
switch {
case len(items) == 0 && mailToday:
put("today", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", true, time.Time{}))
case e.ReadLater:
put("read-later", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
case len(items) == 0:
put("good-to-know", itemOut(e, "mail", "mail", e.Summary, time.Time{}, "", "", false, time.Time{}))
}
}
out := map[string]any{"mode": text(args, "mode"), "today-is": date(today)}
for _, name := range []string{"today", "this-week", "later", "you-promised", "waiting-on-others", "on-its-way",
"good-to-know", "read-later", "handled-today", "scam-warnings", "told-today"} {
s := sections[name]
sort.SliceStable(s, func(i, j int) bool {
a, _ := s[i]["due-date"].(string)
b, _ := s[j]["due-date"].(string)
if a == "" {
a, _ = s[i]["promise-date"].(string)
}
if b == "" {
b, _ = s[j]["promise-date"].(string)
}
if (a == "") != (b == "") {
return b == ""
}
return a < b
})
if s == nil {
s = []map[string]any{}
}
out[name] = s
}
var looked time.Time
for _, a := range r.actions {
if a.What == "page opened" && a.At.After(looked) {
looked = a.At
}
}
if !looked.IsZero() {
out["last-looked"] = looked.UTC().Format(time.RFC3339)
}
return out, nil
}