diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go index 53109853..84a1dfbe 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/kit_test.go @@ -95,13 +95,13 @@ func (s *memStore) AddEntry(_ context.Context, e Entry) (int64, error) { if held.Account == e.Account && entryKey(held) == entryKey(e) { // As PgStore's on conflict: the judgement replaced, the id, headers and told kept (1849). e.ID, e.Folder, e.UIDValidity, e.UID, e.Told = held.ID, held.Folder, held.UIDValidity, held.UID, held.Told - s.entries[i] = e + s.entries[i] = cloneEntry(e) return e.ID, nil } } s.nextEntry++ e.ID = s.nextEntry - s.entries = append(s.entries, e) + s.entries = append(s.entries, cloneEntry(e)) return e.ID, nil } @@ -112,7 +112,7 @@ func (s *memStore) UpdateEntry(_ context.Context, e Entry) error { defer s.mu.Unlock() for i, existing := range s.entries { if existing.ID == e.ID { - s.entries[i] = e + s.entries[i] = cloneEntry(e) return nil } } @@ -124,7 +124,7 @@ func (s *memStore) Entry(_ context.Context, id int64) (Entry, bool, error) { defer s.mu.Unlock() for _, e := range s.entries { if e.ID == id { - return e, true, nil + return cloneEntry(e), true, nil } } return Entry{}, false, nil @@ -134,7 +134,9 @@ func (s *memStore) Entries(context.Context) ([]Entry, error) { s.mu.Lock() defer s.mu.Unlock() out := make([]Entry, len(s.entries)) - copy(out, s.entries) + for i, e := range s.entries { + out[i] = cloneEntry(e) + } return out, nil } diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0004-tasks-promises-and-read-later.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0004-tasks-promises-and-read-later.sql new file mode 100644 index 00000000..fbeec69d --- /dev/null +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/migrations/0004-tasks-promises-and-read-later.sql @@ -0,0 +1,18 @@ +-- Issue 1857 (novox/hq ADR 0516 §1, §2): a mail entry also holds at most 3 tasks and 3 promises, each line +-- the model's own words of at most 120 characters, never text taken from the mail, and whether the mail is +-- read later. Older entries keep none. +alter table mail_entry add column read_later boolean not null default false; +create table mail_task ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + n smallint not null check (n between 1 and 3), + line text not null check (char_length(line) between 1 and 120), + due_date date, + primary key (mail_entry, n)); +create table mail_promise ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + n smallint not null check (n between 1 and 3), + kind text not null check (kind in ('you promised', 'waiting on others', 'on its way')), + line text not null check (char_length(line) between 1 and 120), + who text not null default '' check (char_length(who) <= 120), + date date, + primary key (mail_entry, n)); diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go index a9fb2e40..9a35066b 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore.go @@ -362,23 +362,125 @@ func (s *PgStore) AddEntry(ctx context.Context, e Entry) (int64, error) { m := string(e.Mark) mark = &m } + tx, err := s.pool.Begin(ctx) + if err != nil { + return 0, err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() var id int64 // One entry per account and message key (1849): a message judged again (its folder's second copy in a // race, or the re-reading) replaces its judgement and keeps its id, headers and told. - err := s.pool.QueryRow(ctx, `insert into mail_entry + err = tx.QueryRow(ctx, `insert into mail_entry (account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged, - uidvalidity, uid, message_key) - values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + uidvalidity, uid, message_key, read_later) + values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) on conflict (account, message_key) do update set mail_group = excluded.mail_group, summary = excluded.summary, - mark = excluded.mark, why = excluded.why, due_date = excluded.due_date, judged = excluded.judged + mark = excluded.mark, why = excluded.why, due_date = excluded.due_date, judged = excluded.judged, + read_later = excluded.read_later returning id`, e.Account, e.MessageID, e.Folder, e.Date, e.SenderName, e.SenderAddress, e.Subject, group, e.Summary, mark, e.Why, nullDate(e.DueDate), e.Told, e.Judged, int64(e.UIDValidity), int64(e.UID), - MessageKey(e.MessageID, e.Folder, e.UIDValidity, e.UID)).Scan(&id) - return id, err + MessageKey(e.MessageID, e.Folder, e.UIDValidity, e.UID), e.ReadLater).Scan(&id) + if err != nil { + return 0, err + } + if err := writeItems(ctx, tx, id, e); err != nil { + return 0, err + } + return id, tx.Commit(ctx) +} + +// writeItems replaces an entry's tasks and promises with e's (1857), in the caller's transaction. +func writeItems(ctx context.Context, tx pgx.Tx, id int64, e Entry) error { + if _, err := tx.Exec(ctx, `delete from mail_task where mail_entry = $1`, id); err != nil { + return err + } + if _, err := tx.Exec(ctx, `delete from mail_promise where mail_entry = $1`, id); err != nil { + return err + } + for _, t := range e.Tasks { + if _, err := tx.Exec(ctx, `insert into mail_task (mail_entry, n, line, due_date) values ($1, $2, $3, $4)`, + id, t.N, t.Line, nullDate(t.Due)); err != nil { + return err + } + } + for _, p := range e.Promises { + if _, err := tx.Exec(ctx, `insert into mail_promise (mail_entry, n, kind, line, who, date) values ($1, $2, $3, $4, $5, $6)`, + id, p.N, string(p.Kind), p.Line, p.Who, nullDate(p.Date)); err != nil { + return err + } + } + return nil +} + +// readItems fills the tasks and promises of the entries given (by id), in their numbers' order. +func (s *PgStore) readItems(ctx context.Context, entries []Entry, where string, args ...any) error { + at := map[int64]int{} + for i, e := range entries { + at[e.ID] = i + } + rows, err := s.pool.Query(ctx, `select mail_entry, n, line, due_date from mail_task `+where+` order by mail_entry, n`, args...) + if err != nil { + return err + } + for rows.Next() { + var id int64 + var t Task + var due *time.Time + if err := rows.Scan(&id, &t.N, &t.Line, &due); err != nil { + rows.Close() + return err + } + if due != nil { + t.Due = *due + } + if i, ok := at[id]; ok { + entries[i].Tasks = append(entries[i].Tasks, t) + } + } + rows.Close() + if err := rows.Err(); err != nil { + return err + } + rows, err = s.pool.Query(ctx, `select mail_entry, n, kind, line, who, date from mail_promise `+where+` order by mail_entry, n`, args...) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id int64 + var p Promise + var kind string + var date *time.Time + if err := rows.Scan(&id, &p.N, &kind, &p.Line, &p.Who, &date); err != nil { + return err + } + p.Kind = PromiseKind(kind) + if date != nil { + p.Date = *date + } + if i, ok := at[id]; ok { + entries[i].Promises = append(entries[i].Promises, p) + } + } + return rows.Err() } func (s *PgStore) UpdateEntry(ctx context.Context, e Entry) error { + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer func() { _ = tx.Rollback(context.WithoutCancel(ctx)) }() + if err := updateEntryIn(ctx, tx, e); err != nil { + return err + } + return tx.Commit(ctx) +} + +// updateEntryIn is UpdateEntry within the caller's transaction (correct writes its lesson and its action +// line in the same one, ADR 0517 §5). +func updateEntryIn(ctx context.Context, tx pgx.Tx, e Entry) error { var group *int64 if e.Group != 0 { group = &e.Group @@ -388,18 +490,18 @@ func (s *PgStore) UpdateEntry(ctx context.Context, e Entry) error { m := string(e.Mark) mark = &m } - tag, err := s.pool.Exec(ctx, `update mail_entry set account = $2, message_id = $3, folder = $4, date = $5, + tag, err := tx.Exec(ctx, `update mail_entry set account = $2, message_id = $3, folder = $4, date = $5, sender_name = $6, sender_address = $7, subject = $8, mail_group = $9, summary = $10, mark = $11, why = $12, - due_date = $13, told = $14, judged = $15 where id = $1`, + due_date = $13, told = $14, judged = $15, read_later = $16 where id = $1`, e.ID, e.Account, e.MessageID, e.Folder, e.Date, e.SenderName, e.SenderAddress, e.Subject, group, e.Summary, - mark, e.Why, nullDate(e.DueDate), e.Told, e.Judged) + mark, e.Why, nullDate(e.DueDate), e.Told, e.Judged, e.ReadLater) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } - return nil + return writeItems(ctx, tx, e.ID, e) } func scanEntry(row pgx.Row) (Entry, error) { @@ -409,7 +511,7 @@ func scanEntry(row pgx.Row) (Entry, error) { var dueDate *time.Time var uidvalidity, uid int64 err := row.Scan(&e.ID, &e.Account, &e.MessageID, &e.Folder, &e.Date, &e.SenderName, &e.SenderAddress, &e.Subject, - &group, &e.Summary, &mark, &e.Why, &dueDate, &e.Told, &e.Judged, &uidvalidity, &uid) + &group, &e.Summary, &mark, &e.Why, &dueDate, &e.Told, &e.Judged, &uidvalidity, &uid, &e.ReadLater) if err != nil { return e, err } @@ -427,14 +529,21 @@ func scanEntry(row pgx.Row) (Entry, error) { return e, nil } -const entryColumns = `id, account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged, uidvalidity, uid` +const entryColumns = `id, account, message_id, folder, date, sender_name, sender_address, subject, mail_group, summary, mark, why, due_date, told, judged, uidvalidity, uid, read_later` func (s *PgStore) Entry(ctx context.Context, id int64) (Entry, bool, error) { e, err := scanEntry(s.pool.QueryRow(ctx, `select `+entryColumns+` from mail_entry where id = $1`, id)) if errors.Is(err, pgx.ErrNoRows) { return Entry{}, false, nil } - return e, err == nil, err + if err != nil { + return Entry{}, false, err + } + one := []Entry{e} + if err := s.readItems(ctx, one, `where mail_entry = $1`, id); err != nil { + return Entry{}, false, err + } + return one[0], true, nil } func (s *PgStore) Entries(ctx context.Context) ([]Entry, error) { @@ -451,7 +560,11 @@ func (s *PgStore) Entries(ctx context.Context) ([]Entry, error) { } out = append(out, e) } - return out, rows.Err() + if err := rows.Err(); err != nil { + return nil, err + } + rows.Close() + return out, s.readItems(ctx, out, ``) } func (s *PgStore) FindOrCreateGroup(ctx context.Context, label string) (Group, error) { diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore_integration_test.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore_integration_test.go index df51c74d..3d5eab4e 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore_integration_test.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/pgstore_integration_test.go @@ -234,3 +234,38 @@ func TestPgStoreKeepsOneMessageInTwoFoldersOnce(t *testing.T) { t.Fatalf("entries = %+v (%v), want one, its judgement replaced and its folder kept", entries, err) } } + +// 1857/AC8 through the real store: an entry's tasks, promises and read-later flag are kept and read back, a +// re-judged message replaces them, and a correction's removal of one keeps the others' numbers. +func TestPgStoreKeepsTasksPromisesAndReadLater(t *testing.T) { + ctx := context.Background() + s := preparedStore(t) + date := time.Date(2026, 10, 22, 9, 0, 0, 0, time.UTC) + due := time.Date(2026, 10, 24, 0, 0, 0, 0, time.UTC) + e := Entry{Account: "work", MessageID: "", Folder: "INBOX", Date: date, Mark: MarkMatters, Judged: date, + ReadLater: true, + Tasks: []Task{{N: 1, Line: "Send Sara the signed offer", Due: due}, {N: 2, Line: "Book the Harbor demo room"}}, + Promises: []Promise{{N: 1, Kind: WaitingOnOthers, Line: "Tim sends the revised quote", Who: "Tim Jacobs", Date: due}}, + } + id, err := s.AddEntry(ctx, e) + if err != nil { + t.Fatal(err) + } + got, ok, err := s.Entry(ctx, id) + if err != nil || !ok { + t.Fatalf("Entry(%d): %v %v", id, ok, err) + } + e.ID = id + if !reflect.DeepEqual(got.Tasks, e.Tasks) || !reflect.DeepEqual(got.Promises, e.Promises) || !got.ReadLater { + t.Fatalf("read back %+v, want the tasks %+v, the promises %+v and read later", got, e.Tasks, e.Promises) + } + got.Tasks = got.Tasks[1:] + got.ReadLater = false + if err := s.UpdateEntry(ctx, got); err != nil { + t.Fatal(err) + } + all, err := s.Entries(ctx) + if err != nil || len(all) != 1 || len(all[0].Tasks) != 1 || all[0].Tasks[0].N != 2 || all[0].ReadLater { + t.Fatalf("after removing task 1: %+v (%v), want task 2 alone, keeping its number, and no read later", all, err) + } +} diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql index 918c0e58..e4955b4e 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/schema.sql @@ -1,4 +1,4 @@ --- The consolidated schema, through migration 0003: what a fresh database is given at once (the mesh's rule: a +-- The consolidated schema, through migration 0004: what a fresh database is given at once (the mesh's rule: a -- numbered migration for the running store, and this for a fresh install). create table mail_group ( id bigserial primary key, @@ -24,10 +24,29 @@ create table mail_entry ( due_date date, told boolean not null default false, judged timestamptz not null default now(), + read_later boolean not null default false, constraint mail_entry_one_per_message unique (account, message_key)); create index mail_entry_account_date on mail_entry (account, date desc); create index mail_entry_judged on mail_entry (judged); +-- What a mail asks and promises (novox/hq ADR 0516 §1, §2, issue 1857): at most 3 tasks and 3 promises per +-- entry, each line the model's own words of at most 120 characters, never text taken from the mail. An +-- item keeps its number n while its entry lives, and goes with it. +create table mail_task ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + n smallint not null check (n between 1 and 3), + line text not null check (char_length(line) between 1 and 120), + due_date date, + primary key (mail_entry, n)); +create table mail_promise ( + mail_entry bigint not null references mail_entry (id) on delete cascade, + n smallint not null check (n between 1 and 3), + kind text not null check (kind in ('you promised', 'waiting on others', 'on its way')), + line text not null check (char_length(line) between 1 and 120), + who text not null default '' check (char_length(who) <= 120), + date date, + primary key (mail_entry, n)); + -- A reading lesson (novox/hq ADR 0459 §8, §9, issue 1428): the corrected mail's own headers (never a body), -- the change made, when, and whether it was undone. sender_address and mail_group are its own matching keys -- for the next batch's question (1427): a lesson matches a mail of the same sender, or already known to be diff --git a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go index 5615c5e2..da097aa1 100644 --- a/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go +++ b/modules/mesh-mail-reader/cmd/mesh-mail-reader/store.go @@ -48,6 +48,65 @@ type Entry struct { // Judged is when a batch judged this mail (issue 1520) — the week the weekly measure (1521/AC3) counts it // in, which is never the mail's own Date: a mail that arrived long before it was judged is read this week. Judged time.Time + // Tasks, Promises and ReadLater are what the mail asks of the operator, what is promised in it and whether + // it is worth reading later (novox/hq ADR 0516 §1, issue 1857): at most MaxTasks and MaxPromises, each line + // the model's own words (§2). An item keeps its number for the entry's life: a task removed by correct + // leaves a gap, never renumbers the rest (handling names items by number, ADR 0517 §4). + Tasks []Task + Promises []Promise + ReadLater bool +} + +// The bounds of what an entry's judgement holds (ADR 0516 §1, §2). +const ( + MaxTasks = 3 + MaxPromises = 3 + MaxLine = 120 // characters, for a task's or a promise's line and a promise's who +) + +// Task is one thing the mail asks of the operator: the model's own line and a due date, zero for none. +type Task struct { + N int + Line string + Due time.Time +} + +// PromiseKind is one of the three kinds of promise (ADR 0516 §1). +type PromiseKind string + +const ( + YouPromised PromiseKind = "you promised" + WaitingOnOthers PromiseKind = "waiting on others" + OnItsWay PromiseKind = "on its way" +) + +// PromiseKinds are the three, in the order the list shows them. +var PromiseKinds = []PromiseKind{YouPromised, WaitingOnOthers, OnItsWay} + +func validPromiseKind(k string) bool { + for _, known := range PromiseKinds { + if string(known) == k { + return true + } + } + return false +} + +// Promise is one promise in the mail: its kind, the model's own line, who (where the mail names one) and a +// date, zero where the mail gives none. +type Promise struct { + N int + Kind PromiseKind + Line string + Who string + Date time.Time +} + +// cloneEntry copies an entry with its own tasks and promises, so a store double never shares them. +func cloneEntry(e Entry) Entry { + e.Tasks = append([]Task(nil), e.Tasks...) + e.Promises = append([]Promise(nil), e.Promises...) + return e } // Group is one mail group (ADR 0459 §4). Count, how many entries hold it, is answered by Store.Groups @@ -154,7 +213,8 @@ type Store interface { // AddEntry records a mail entry (1520/AC3) and answers its own id: one per account and MessageKey (1849) — // for a message that already has one, its judgement is replaced and its id kept. AddEntry(ctx context.Context, e Entry) (int64, error) - // UpdateEntry replaces an entry's fields by its id (1428/AC1, correct). ErrNotFound when unknown. + // UpdateEntry replaces an entry's fields by its id (1428/AC1, correct), its tasks, promises and read-later + // too (1857). ErrNotFound when unknown. UpdateEntry(ctx context.Context, e Entry) error // Entry answers one entry by id, and whether it was found (1428/AC1, AC5: an unknown id is refused). Entry(ctx context.Context, id int64) (Entry, bool, error)