diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/days.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/days.go new file mode 100644 index 00000000..2efe162a --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/days.go @@ -0,0 +1,32 @@ +package main + +// The operator's days (design 63 §2, novox/hq ADR 0517 §1, §2): today, this week and the day an undo ends are +// days in the operator's own time zone, Europe/Brussels, whatever the machine's own zone is. The zone's rules +// are built into the binary (time/tzdata), so a machine without a zone database answers the same. + +import ( + "time" + _ "time/tzdata" +) + +// Brussels is the operator's time zone. +var Brussels = mustZone("Europe/Brussels") + +func mustZone(name string) *time.Location { + loc, err := time.LoadLocation(name) + if err != nil { + panic(err) // time/tzdata carries it: never reached + } + return loc +} + +// dayOf is the operator's day of t, as midnight UTC of that date: the same form a date column answers. +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) +} + +// endOfWeek is the last day of the week of day (a dayOf): its Sunday. +func endOfWeek(day time.Time) time.Time { + return day.AddDate(0, 0, (7-int(day.Weekday()))%7) +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/handle_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/handle_test.go new file mode 100644 index 00000000..24db6f3a --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/handle_test.go @@ -0,0 +1,145 @@ +package main + +// Issue 1858 (novox/hq ADR 0517 §1, §2, §4, §5): handle, unhandle and remind, item by item, each writing its +// one action line in the same transaction as its act; an act that fails writes none. + +import ( + "context" + "strings" + "testing" + "time" +) + +// callTool calls a tool by its name through Tools(), so a test names a verb before it exists and fails on +// that, not on a compile error. +func callTool(t *testing.T, b *Bundle, name string, args map[string]any) (any, error) { + t.Helper() + for _, tool := range b.Tools() { + if tool.Name == name { + return tool.Run(args) + } + } + t.Fatalf("the bundle serves no %s", name) + return nil, nil +} + +// brusselsAt is a time on the operator's own clock (design 63 §2). +func brusselsAt(year int, month time.Month, day, hour, minute int) time.Time { + return time.Date(year, month, day, hour, minute, 0, 0, Brussels) +} + +// listBundle is a bundle over store whose clock reads *now. +func listBundle(store Store, now *time.Time) *Bundle { + b := testBundle(store, "mesh-mail-web") + b.Now = func() time.Time { return *now } + return b +} + +func lines(t *testing.T, store Store) []ActionLine { + t.Helper() + out, err := store.Actions(context.Background(), 1000) + if err != nil { + t.Fatal(err) + } + return out +} + +// 1858/AC4: handle, unhandle and remind each write exactly one line — what, mail id, item, mode, when — and +// change the item's handling. +func TestHandleUnhandleAndRemindEachWriteOneLine(t *testing.T) { + ctx := context.Background() + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 9, 15) + b := listBundle(store, &now) + + if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id), "item": "task 2", "mode": "work"}); err != nil { + t.Fatal(err) + } + hs, _ := store.Handlings(ctx) + if len(hs) != 1 || hs[0].Entry != id || hs[0].Item != "task 2" || !hs[0].Handled.Equal(now) { + t.Fatalf("handlings = %+v, want task 2 handled at %s", hs, now) + } + got := lines(t, store) + if len(got) != 1 || got[0].What != ActHandled || got[0].Entry != id || got[0].Item != "task 2" || got[0].Mode != "work" || !got[0].At.Equal(now) { + t.Fatalf("lines = %+v, want one: handled, %d, task 2, work, %s", got, id, now) + } + + now = now.Add(time.Minute) + if _, err := callTool(t, b, UnhandleTool, map[string]any{"id": float64(id), "item": "task 2", "mode": "work"}); err != nil { + t.Fatal(err) + } + if hs, _ := store.Handlings(ctx); len(hs) != 0 { + t.Fatalf("handlings after unhandle = %+v, want none", hs) + } + if got := lines(t, store); len(got) != 2 || got[0].What != ActUndone || got[0].Item != "task 2" { + t.Fatalf("lines = %+v, want undone newest", got) + } + + if _, err := callTool(t, b, RemindTool, map[string]any{"id": float64(id), "mode": "personal"}); err != nil { + t.Fatal(err) + } + hs, _ = store.Handlings(ctx) + tomorrow := time.Date(2026, 10, 23, 0, 0, 0, 0, time.UTC) + if len(hs) != 1 || hs[0].Item != "mail" || !hs[0].ShowsAgain.Equal(tomorrow) || !hs[0].Handled.IsZero() { + t.Fatalf("handlings after remind = %+v, want the mail shown again on 2026-10-23", hs) + } + if got := lines(t, store); len(got) != 3 || got[0].What != ActReminded || got[0].Item != "mail" || got[0].Mode != "personal" { + t.Fatalf("lines = %+v, want reminded newest", got) + } +} + +// 1858/AC4: an act that fails — refused, or failing in the store — writes no line. +func TestAFailedActWritesNoLine(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 9, 15) + b := listBundle(store, &now) + for _, tc := range []struct { + name, verb string + args map[string]any + why string + }{ + {"no such item", HandleTool, map[string]any{"id": float64(id), "item": "task 3"}, "task 3"}, + {"no such entry", HandleTool, map[string]any{"id": float64(id + 99)}, "no mail entry"}, + {"not an item", RemindTool, map[string]any{"id": float64(id), "item": "the subject"}, "item"}, + {"not a mode", HandleTool, map[string]any{"id": float64(id), "mode": "holiday"}, "mode"}, + {"undo what is not handled", UnhandleTool, map[string]any{"id": float64(id), "item": "promise 1"}, "not handled"}, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := callTool(t, b, tc.verb, tc.args) + if err == nil || !strings.Contains(err.Error(), tc.why) { + t.Fatalf("%s %v: %v, want a refusal naming %q", tc.verb, tc.args, err, tc.why) + } + if got := lines(t, store); len(got) != 0 { + t.Fatalf("lines = %+v, want none", got) + } + }) + } + store.failAct = errStoreFake + if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id)}); err == nil { + t.Fatal("handle with a failing store: no error") + } + if hs, _ := store.Handlings(context.Background()); len(hs) != 0 || len(lines(t, store)) != 0 { + t.Fatalf("a failed act kept %+v and wrote %+v", hs, lines(t, store)) + } +} + +// 1858/AC2 (undo lasts the day): an item handled yesterday is not undone today. +func TestUndoLastsUntilTheDayEnds(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + now := brusselsAt(2026, 10, 22, 23, 50) + b := listBundle(store, &now) + if _, err := callTool(t, b, HandleTool, map[string]any{"id": float64(id), "item": "task 1"}); err != nil { + t.Fatal(err) + } + now = brusselsAt(2026, 10, 23, 0, 5) + if _, err := callTool(t, b, UnhandleTool, map[string]any{"id": float64(id), "item": "task 1"}); err == nil || + !strings.Contains(err.Error(), "until the day ends") { + t.Fatalf("undo the next day: %v, want a refusal saying undo lasts until the day ends", err) + } + if got := lines(t, store); len(got) != 1 { + t.Fatalf("lines = %+v, want the handled line alone", got) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go new file mode 100644 index 00000000..37f945a1 --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go @@ -0,0 +1,185 @@ +package main + +// The operator's list and what the operator does with it (novox/hq ADR 0517 §1 to §5, issue 1858): handle, +// unhandle and remind an item — a task, a promise, or the mail itself — each writing its one action line in +// the same transaction as its act (Store.Act); list, the operator's list for a mode (list.go); record, a line +// for an act that changes no entry; and actions, the action log. Each answers only a caller the setting +// callers names, like every mail-reading verb. A refused act writes nothing. + +import ( + "context" + "fmt" + "time" +) + +// ItemMail is the item that is the mail itself; "task n" and "promise n" are its tasks and promises. +const ItemMail = "mail" + +// Modes are the page's modes (ADR 0515 §4): which accounts each shows is mesh-mail-web's setting `modes`. +var Modes = []string{"work", "personal", "everything"} + +// itemArg reads the item an act concerns: the mail itself when left out. +func itemArg(args map[string]any) (string, error) { + v, has := args["item"] + if !has || v == nil { + return ItemMail, nil + } + s, _ := v.(string) + for _, known := range []string{ItemMail, "task 1", "task 2", "task 3", "promise 1", "promise 2", "promise 3"} { + if s == known { + return s, nil + } + } + return "", fmt.Errorf("the item %q is none of mail, task 1 to 3, promise 1 to 3", s) +} + +// modeArg reads the mode the page is in: everything when left out. +func modeArg(args map[string]any) (string, error) { + v, has := args["mode"] + if !has || v == nil { + return "everything", nil + } + s, _ := v.(string) + for _, known := range Modes { + if s == known { + return s, nil + } + } + return "", fmt.Errorf("the mode %q is none of work, personal, everything", s) +} + +// holdsItem says whether the entry holds the item: the mail always, a task or a promise by its number. +func holdsItem(e Entry, item string) bool { + if item == ItemMail { + return true + } + var n int + if _, err := fmt.Sscanf(item, "task %d", &n); err == nil { + return hasTask(e, n) + } + if _, err := fmt.Sscanf(item, "promise %d", &n); err == nil { + return hasPromise(e, n) + } + return false +} + +// itemAct is what handle, unhandle and remind share: the caller checked, the arguments read, the entry and +// the item found, and the item's handling as it stands. +type itemAct struct { + store Store + entry Entry + item string + mode string + current Handling +} + +func (b *Bundle) startItemAct(verb string, args map[string]any) (itemAct, error) { + if _, err := b.checkCaller(args); err != nil { + return itemAct{}, err + } + if err := only(args, "id", "item", "mode"); err != nil { + return itemAct{}, err + } + id, err := number(args, "id", 0) + if err != nil { + return itemAct{}, err + } + if id <= 0 { + return itemAct{}, fmt.Errorf("%s needs id", verb) + } + item, err := itemArg(args) + if err != nil { + return itemAct{}, err + } + mode, err := modeArg(args) + if err != nil { + return itemAct{}, err + } + ctx := context.Background() + store, err := b.Store(ctx) + if err != nil { + return itemAct{}, err + } + entry, ok, err := store.Entry(ctx, int64(id)) + if err != nil { + return itemAct{}, err + } + if !ok { + return itemAct{}, fmt.Errorf("%s: no mail entry %d is held; list answers the ids held", verb, id) + } + if !holdsItem(entry, item) { + return itemAct{}, fmt.Errorf("%s: the mail entry %d holds no %s", verb, id, item) + } + a := itemAct{store: store, entry: entry, item: item, mode: mode, current: Handling{Entry: entry.ID, Item: item}} + handlings, err := store.Handlings(ctx) + if err != nil { + return itemAct{}, err + } + for _, h := range handlings { + if h.Entry == entry.ID && h.Item == item { + a.current = h + } + } + return a, nil +} + +// act keeps the item's new handling and the act's line, in one transaction (Store.Act). +func (a itemAct) act(verb, what string, h Handling, at time.Time) (any, error) { + err := a.store.Act(context.Background(), h, ActionLine{What: what, Entry: a.entry.ID, Item: a.item, Mode: a.mode, At: at}) + if err == ErrNotFound { + return nil, fmt.Errorf("%s: no mail entry %d is held; list answers the ids held", verb, a.entry.ID) + } + if err != nil { + return nil, err + } + return map[string]any{what: map[string]any{"id": a.entry.ID, "item": a.item}}, nil +} + +// handleTool marks an item handled (ADR 0517 §1): the mail itself handles all its items with it. +func (b *Bundle) handleTool(args map[string]any) (any, error) { + a, err := b.startItemAct(HandleTool, args) + if err != nil { + return nil, err + } + if !a.current.Handled.IsZero() { + return nil, fmt.Errorf("%s: %s of mail entry %d is handled already", HandleTool, a.item, a.entry.ID) + } + now := b.now() + h := a.current + h.Handled = now + return a.act(HandleTool, ActHandled, h, now) +} + +// unhandleTool undoes a handling of today (ADR 0517 §1: the undo lasts until the day ends). +func (b *Bundle) unhandleTool(args map[string]any) (any, error) { + a, err := b.startItemAct(UnhandleTool, args) + if err != nil { + return nil, err + } + now := b.now() + if a.current.Handled.IsZero() { + return nil, fmt.Errorf("%s: %s of mail entry %d is not handled", UnhandleTool, a.item, a.entry.ID) + } + if !dayOf(a.current.Handled).Equal(dayOf(now)) { + return nil, fmt.Errorf("%s: %s of mail entry %d was handled before today, and an undo lasts until the day ends", + UnhandleTool, a.item, a.entry.ID) + } + h := a.current + h.Handled = time.Time{} + return a.act(UnhandleTool, ActUndone, h, now) +} + +// remindTool hides an item until the next day, when the list shows it under today (ADR 0517 §2). +func (b *Bundle) remindTool(args map[string]any) (any, error) { + a, err := b.startItemAct(RemindTool, args) + if err != nil { + return nil, err + } + if !a.current.Handled.IsZero() { + return nil, fmt.Errorf("%s: %s of mail entry %d is handled; undo it first", RemindTool, a.item, a.entry.ID) + } + now := b.now() + h := a.current + h.ShowsAgain = dayOf(now).AddDate(0, 0, 1) + return a.act(RemindTool, ActReminded, h, now) +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go index 5f1723e8..9565112b 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/manifest_test.go @@ -59,7 +59,7 @@ func TestTheManifestClaimsMailReadingAndInvokesReadOnly(t *testing.T) { t.Fatalf("runs-as %q, want %q", m.RunsAs, Module) } wantClaims := []seat{ - {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}}, + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"}}, } if !reflect.DeepEqual(m.Claims, wantClaims) { t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go index d4503f4d..802c4bd2 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -29,6 +29,14 @@ const ( LessonsTool = "mail-reading.lessons" UndoTool = "mail-reading.undo" StatusTool = "mail-reading.status" + + // The verbs of the operator's list (novox/hq ADR 0517 §4, issue 1858). + ListTool = "mail-reading.list" + HandleTool = "mail-reading.handle" + UnhandleTool = "mail-reading.unhandle" + RemindTool = "mail-reading.remind" + RecordTool = "mail-reading.record" + ActionsTool = "mail-reading.actions" ) // Bundle is what the tools reach: the trusted setting `callers`, the runtime's own caller, and the index @@ -38,6 +46,7 @@ type Bundle struct { Env Env Caller func(args map[string]any) string Store func(ctx context.Context) (Store, error) + Now func() time.Time // nil outside a test: time.Now // callersOverride, when set, is used instead of reading Env.Callers — the tests' own seam. callersOverride func() (Callers, error) @@ -48,6 +57,13 @@ type Env struct { Callers string // the path MESH_MAIL_READER_CALLERS names. } +func (b *Bundle) now() time.Time { + if b.Now != nil { + return b.Now() + } + return time.Now() +} + func (b *Bundle) caller(args map[string]any) string { if b.Caller != nil { return b.Caller(args) @@ -180,6 +196,25 @@ func (b *Bundle) Tools() []stdio.Tool { "no mail read says so). Answered only to a caller the setting callers names. Read-only.", Input: schema(nil, map[string]any{}), Run: b.statusTool}, + {Name: HandleTool, + Description: "Mark one item of a mail entry handled for the operator (novox/hq ADR 0517 §1): a task or a " + + "promise by its number, or the mail itself, which handles all its items with it. It changes nothing " + + "in the mailbox, and writes one handled line in the action log. Answered only to a caller the " + + "setting callers names.", + Input: itemActSchema(), + Run: b.handleTool}, + {Name: UnhandleTool, + Description: "Undo a handling of today (novox/hq ADR 0517 §1): the item is back in its section; the undo " + + "lasts until the day ends (Europe/Brussels). Writes one undone line. Answered only to a caller the " + + "setting callers names.", + Input: itemActSchema(), + Run: b.unhandleTool}, + {Name: RemindTool, + Description: "Remind me tomorrow (novox/hq ADR 0517 §2): the item is hidden until the next day, when the " + + "list shows it under today. Writes one reminded line. Answered only to a caller the setting callers " + + "names.", + Input: itemActSchema(), + Run: b.remindTool}, {Name: HealthTool, Description: "How mesh-mail-reader is (novox/hq ADR 0459 §12): unhealthy once a batch judgement is " + "refused, times out or answers out of shape, naming which and since when; healthy once one " + @@ -189,6 +224,15 @@ func (b *Bundle) Tools() []stdio.Tool { } } +// itemActSchema is handle's, unhandle's and remind's own arguments. +func itemActSchema() map[string]any { + return schema([]string{"id"}, map[string]any{ + "id": integer("the mail entry's id, as list answered it"), + "item": text("task 1 to 3 or promise 1 to 3 by its number, or mail (the mail itself, also when left out)"), + "mode": text("the mode the page is in: work, personal or everything (when left out)"), + }) +} + // entryOut is one mail entry as entriesTool answers it (1521/AC1): account, message id, date, sender name, // subject, group (by its label), summary, mark, why and due date — never a mail body. func entryOut(e Entry, labels map[int64]string) map[string]any { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go index fc794f23..231e01ef 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools_test.go @@ -22,7 +22,8 @@ func TestToolsListsTheSixVerbsAndHealth(t *testing.T) { } want := []string{ "mail-reading.entries", "mail-reading.groups", "mail-reading.correct", "mail-reading.lessons", - "mail-reading.undo", "mail-reading.status", "mail_reader_health", + "mail-reading.undo", "mail-reading.status", "mail-reading.handle", "mail-reading.unhandle", "mail-reading.remind", + "mail_reader_health", } if len(names) != len(want) { t.Fatalf("Tools() = %v, want %v", names, want) diff --git a/modules/mesh-mail-reader/module.json b/modules/mesh-mail-reader/module.json index 80ecdeea..3930172d 100644 --- a/modules/mesh-mail-reader/module.json +++ b/modules/mesh-mail-reader/module.json @@ -6,7 +6,7 @@ { "name": "mail-reading", "scope": "mesh", - "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"] } ], "consumes": [ @@ -48,6 +48,9 @@ "mail-reading.lessons", "mail-reading.undo", "mail-reading.status", + "mail-reading.handle", + "mail-reading.unhandle", + "mail-reading.remind", "mail_reader_health" ], "runtime-health": { diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index 9e8b53cd..17a026bb 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}}, {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}}, - {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status"}}, + {Name: "mail-reading", Scope: "mesh", Serves: []string{"entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"}}, } if !reflect.DeepEqual(m.Seats, wantSeats) { t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index 735fbc54..008acefd 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -22,7 +22,7 @@ { "name": "mail-reading", "scope": "mesh", - "serves": ["entries", "groups", "correct", "lessons", "undo", "status"] + "serves": ["entries", "groups", "correct", "lessons", "undo", "status", "handle", "unhandle", "remind"] } ], "claims": [