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 // " " 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 }