issue 1859: answer the mail page's greeting and reading pane from mail reading

ADR 0515 §1, §5: list also answers the mails the mail signal told of today, and each line its mail's folder
and UID; entries takes an id and answers that one entry whole, with its sender's address, tasks, promises
and read-later, for the reading pane's corrections.
This commit is contained in:
jochen
2026-10-22 20:00:44 +02:00
parent f24a4d858a
commit 4f3ebe370b
5 changed files with 95 additions and 4 deletions
@@ -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,
}
@@ -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 {
@@ -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 {
@@ -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)
}
}
@@ -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]