diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go index 28a3335b..3a35f278 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/list.go @@ -41,6 +41,9 @@ type OperatorList struct { YouPromised, WaitingOnOthers, OnItsWay []ListItem GoodToKnow, ReadLater, ScamWarnings []ListItem // mail items HandledToday []ListItem + // ToldToday is the mails the mail signal told of today, for the page's greeting (ADR 0515 §1, ADR 0461): + // an entry told and judged today (the signal is sent once a batch, right after it judged, tell.go). + ToldToday []ListItem } // BuildList builds the list of the entries of accounts (every account when accounts is empty) for the day of @@ -67,6 +70,9 @@ func BuildList(entries []Entry, handlings []Handling, accounts []string, now tim hs := byItem[e.ID] mail := hs[ItemMail] mailItem := ListItem{Entry: e, Item: ItemMail, Kind: "mail", Line: e.Summary} + if e.Told && dayOf(e.Judged).Equal(today) { + l.ToldToday = append(l.ToldToday, mailItem) + } if !mail.Handled.IsZero() { if dayOf(mail.Handled).Equal(today) { mailItem.Handled = mail.Handled @@ -129,7 +135,7 @@ func BuildList(entries []Entry, handlings []Handling, accounts []string, now tim for _, section := range [][]ListItem{l.Today, l.ThisWeek, l.Later, l.YouPromised, l.WaitingOnOthers, l.OnItsWay} { sortByDue(section) } - for _, section := range [][]ListItem{l.GoodToKnow, l.ReadLater, l.ScamWarnings} { + for _, section := range [][]ListItem{l.GoodToKnow, l.ReadLater, l.ScamWarnings, l.ToldToday} { sort.SliceStable(section, func(i, j int) bool { return section[i].Entry.Date.After(section[j].Entry.Date) }) } sort.SliceStable(l.HandledToday, func(i, j int) bool { return l.HandledToday[i].Handled.After(l.HandledToday[j].Handled) }) @@ -168,7 +174,7 @@ func sortByDue(section []ListItem) { func listItemOut(it ListItem) map[string]any { e := it.Entry out := map[string]any{ - "id": e.ID, "item": it.Item, "kind": it.Kind, "line": it.Line, "account": e.Account, + "id": e.ID, "item": it.Item, "kind": it.Kind, "line": it.Line, "account": e.Account, "folder": e.Folder, "uid": e.UID, "sender-name": e.SenderName, "sender-address": e.SenderAddress, "subject": e.Subject, "date": e.Date.UTC().Format(time.RFC3339), "summary": e.Summary, "mark": string(e.Mark), "why": e.Why, } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/list_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/list_test.go index 4131fae2..9cce18b1 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/list_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/list_test.go @@ -26,6 +26,7 @@ type listed struct { ReadLater []map[string]any `json:"read-later"` HandledToday []map[string]any `json:"handled-today"` ScamWarnings []map[string]any `json:"scam-warnings"` + ToldToday []map[string]any `json:"told-today"` } func listOf(t *testing.T, b *Bundle, args map[string]any) listed { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go index df71dfec..e365d52a 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/listverbs.go @@ -240,7 +240,7 @@ func (b *Bundle) listTool(args map[string]any) (any, error) { "you-promised": sectionOut(l.YouPromised, limit), "waiting-on-others": sectionOut(l.WaitingOnOthers, limit), "on-its-way": sectionOut(l.OnItsWay, limit), "good-to-know": sectionOut(l.GoodToKnow, limit), "read-later": sectionOut(l.ReadLater, limit), "scam-warnings": sectionOut(l.ScamWarnings, limit), - "handled-today": sectionOut(l.HandledToday, limit), + "handled-today": sectionOut(l.HandledToday, limit), "told-today": sectionOut(l.ToldToday, limit), } looked, ok, err := store.LastAction(ctx, ActPageOpened) if err != nil { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pageanswers_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pageanswers_test.go new file mode 100644 index 00000000..3293121d --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pageanswers_test.go @@ -0,0 +1,51 @@ +package main + +// Issue 1859 (novox/hq ADR 0515 §1, §5): what the mail page needs of mail reading — the mail signals told +// today for its greeting, each line's folder and UID for the reading pane, and one entry by its id, whole, +// for the pane's corrections. + +import ( + "testing" + "time" +) + +// 1859/AC1 (the reader's half): list answers the mails the mail signal told of today, and each line carries +// its mail's folder and UID. +func TestListAnswersWhatWasToldTodayAndWhereEachMailIs(t *testing.T) { + store := newMemStore() + now := brusselsAt(2026, 10, 22, 9, 0) + told := addEntry(t, store, Entry{Account: "work", Folder: "INBOX", UID: 41, Mark: MarkMatters, Told: true, + Judged: now.Add(-time.Hour), Summary: "Sara needs the signed offer", Tasks: []Task{{N: 1, Line: "Sign the offer"}}}) + addEntry(t, store, Entry{Account: "work", UID: 42, Mark: MarkMatters, Told: true, Judged: now.AddDate(0, 0, -1)}) + l := listOf(t, listBundle(store, &now), map[string]any{}) + if len(l.ToldToday) != 1 || l.ToldToday[0]["id"] != float64(told) || l.ToldToday[0]["summary"] != "Sara needs the signed offer" { + t.Fatalf("told today = %v, want the one mail told today", l.ToldToday) + } + if it := l.Later[0]; it["folder"] != "INBOX" || it["uid"] != float64(41) { + t.Fatalf("a line = %v, want its folder INBOX and uid 41", it) + } +} + +// 1859/AC7 (the reader's half): entries with an id answers that one entry whole: its folder and UID, sender +// address, tasks, promises and read-later. +func TestEntriesAnswersOneEntryWhole(t *testing.T) { + store := newMemStore() + id := entryWithItems(t, store) + addEntry(t, store, Entry{Account: "work", UID: 77}) + out, err := testBundle(store, "mesh-mail-web").entriesTool(map[string]any{"id": float64(id)}) + if err != nil { + t.Fatal(err) + } + got := out.(map[string]any)["entries"].([]map[string]any) + if len(got) != 1 { + t.Fatalf("entries = %v, want the one asked", got) + } + e := got[0] + tasks, _ := e["tasks"].([]map[string]any) + promises, _ := e["promises"].([]map[string]any) + if e["folder"] != "INBOX" || e["uid"] != uint32(5) || e["sender-address"] != "lena@harbor.example" || + len(tasks) != 2 || tasks[1]["n"] != 2 || tasks[1]["line"] != "Forward the invoice to finance" || + len(promises) != 1 || promises[0]["kind"] != "on its way" || e["read-later"] != false { + t.Fatalf("entry = %v", e) + } +} 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 73fa7392..fc402731 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/tools.go @@ -149,6 +149,7 @@ func (b *Bundle) Tools() []stdio.Tool { "limit": integer("how many to answer; a default bounds it when left out"), "group": text("only mails of this mail group, by its label"), "mark": text("only mails of this mark: matters, scam warning or neither"), + "id": integer("only this one mail entry, by its id"), }), Run: b.entriesTool}, {Name: GroupsTool, @@ -276,18 +277,40 @@ func entryOut(e Entry, labels map[int64]string) map[string]any { if !e.DueDate.IsZero() { due = e.DueDate.Format("2006-01-02") } + tasks := make([]map[string]any, len(e.Tasks)) + for i, t := range e.Tasks { + tasks[i] = map[string]any{"n": t.N, "line": t.Line, "due-date": dateOrEmpty(t.Due)} + } + promises := make([]map[string]any, len(e.Promises)) + for i, p := range e.Promises { + promises[i] = map[string]any{"n": p.N, "kind": string(p.Kind), "line": p.Line, "who": p.Who, "date": dateOrEmpty(p.Date)} + } return map[string]any{ "id": e.ID, "account": e.Account, "message-id": e.MessageID, "date": e.Date.UTC().Format(time.RFC3339), "sender-name": e.SenderName, "subject": e.Subject, "group": labels[e.Group], "summary": e.Summary, "mark": string(e.Mark), "why": e.Why, "due-date": due, + // For the mail page's reading pane (issue 1859): where the mail is, who sent it, and its items. + "folder": e.Folder, "uid": e.UID, "sender-address": e.SenderAddress, + "tasks": tasks, "promises": promises, "read-later": e.ReadLater, } } +func dateOrEmpty(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format("2006-01-02") +} + func (b *Bundle) entriesTool(args map[string]any) (any, error) { if _, err := b.checkCaller(args); err != nil { return nil, err } - if err := only(args, "limit", "group", "mark"); err != nil { + if err := only(args, "limit", "group", "mark", "id"); err != nil { + return nil, err + } + onlyID, err := number(args, "id", 0) + if err != nil { return nil, err } limit, err := number(args, "limit", 0) @@ -326,6 +349,16 @@ func (b *Bundle) entriesTool(args map[string]any) (any, error) { } else { entries = FilterEntries(entries, 0, mark) } + if onlyID > 0 { + // One entry, whole, by its id (issue 1859: the mail page's reading pane); none when it is not held. + var one []Entry + for _, e := range entries { + if e.ID == int64(onlyID) { + one = append(one, e) + } + } + entries = one + } entries = SortEntries(entries) if limit > 0 && len(entries) > limit { entries = entries[:limit]