From dc89995d96d2b1ec13bc4c9a7b8b7165bd10eeed Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 15 Oct 2026 17:33:15 +0200 Subject: [PATCH] Add mesh-mail-notifier, the default holder of mail-notifier (issue 800 part B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR 0378 §9 as amended by ADR 0379: a module of its own, built from mesh-mail's event shape, runs as its own account to hear mesh-mail's restricted MAIL stream (ADR 0376 §2), shows each new inbox mail through node-notifier.send at most once, folds more than three from one account into one notice, and emits its own shown event. --- modules/mesh-mail-notifier/README.md | 35 ++ .../cmd/mesh-mail-notifier/copies_test.go | 31 ++ .../cmd/mesh-mail-notifier/mailevent.go | 13 + .../cmd/mesh-mail-notifier/main.go | 167 ++++++++++ .../cmd/mesh-mail-notifier/notifier.go | 307 ++++++++++++++++++ .../cmd/mesh-mail-notifier/notifier_test.go | 169 ++++++++++ modules/mesh-mail-notifier/go.mod | 5 + modules/mesh-mail-notifier/go.sum | 2 + modules/mesh-mail-notifier/module.json | 79 +++++ 9 files changed, 808 insertions(+) create mode 100644 modules/mesh-mail-notifier/README.md create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go create mode 100644 modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go create mode 100644 modules/mesh-mail-notifier/go.mod create mode 100644 modules/mesh-mail-notifier/go.sum create mode 100644 modules/mesh-mail-notifier/module.json diff --git a/modules/mesh-mail-notifier/README.md b/modules/mesh-mail-notifier/README.md new file mode 100644 index 00000000..1f82055c --- /dev/null +++ b/modules/mesh-mail-notifier/README.md @@ -0,0 +1,35 @@ +# mesh-mail-notifier — the default holder of mail-notifier + +Built from mesh-mail's source (novox/hq ADR 0378 §9, as amended by ADR 0379, issue 800 part B): it carries a +byte-identical copy of mesh-mail's event shape (`mailevent.go`, pinned by `copies_test.go`, the way +`modules/networkmanager`'s `uplink-tools` carries `ifwatchd`'s own file, ADR 0241). Nothing here names +Betterbird (ADR 0379 §3), and nothing here reaches a mailbox: it only hears what mesh-mail already said. + +## What it does + +It claims `mail-notifier` (held once per machine, needing `node-notifier` there) and shows each new inbox mail +on this machine's notifier, through `node-notifier.send`, at most once: + +- quiet during a first sync or a resync (mesh-mail already says nothing then, `imap.go` ~387) +- quiet for a mail from the account's own address (its address, from `mail-store.accounts`; not known, the + notice is shown) +- quiet for a mail already marked read (`\Seen` in its flags) +- quiet for anything outside the inbox +- more than three new mails from one account arriving close together become one notice naming the count and + the latest sender, rather than one notice each + +Each notice shown emits this module's own event `shown` (account and message id, never sender or subject), and +is kept in its own state (`shown`) so a redelivered event is not shown twice. + +## Where it runs + +**Its own account** (`runs-as`), because it hears mesh-mail's restricted stream `MAIL` (novox/hq ADR 0376 §2): +the machine's runtime runs as the operator's account, which every agent shares and which never hears a +restricted stream. Held once per machine (wherever `mail-notifier` is assigned); the operator grants it the +accounts it announces, in mesh-mail's trusted setting `grants` (`mail-store`'s stream). + +## Tools + +| tool | does | +|---|---| +| `mail-notifier.status` | the accounts it announces on this machine, and when it last showed a notice; its rules. Read-only | diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go new file mode 100644 index 00000000..eb4ff641 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/copies_test.go @@ -0,0 +1,31 @@ +package main + +// mesh-mail-notifier carries a byte-identical copy of mesh-mail's event shape (novox/hq ADR 0378 §9, issue 800 +// part B), the way modules/networkmanager's uplink-tools carries ifwatchd's own file (ADR 0241, copies_test.go): +// each builds alone, so each holds its own copy, and this test holds them to one text. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +// source is where mesh-mail's own copy of the file lives. +var source = filepath.Join("..", "..", "..", "mesh-mail", "cmd", "mesh-mail") + +func TestCarriesTheSameMailEventAsMeshMail(t *testing.T) { + for _, f := range []string{"mailevent.go"} { + mine, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + theirs, err := os.ReadFile(filepath.Join(source, f)) + if err != nil { + t.Fatalf("mesh-mail's copy of %s could not be read: %v", f, err) + } + if !bytes.Equal(mine, theirs) { + t.Errorf("this copy of %s differs from mesh-mail's: change both together", f) + } + } +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go new file mode 100644 index 00000000..fb9596fb --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/mailevent.go @@ -0,0 +1,13 @@ +package main + +// The shape of a mail-store message event (novox/hq ADR 0378 §2, issue 800 part A), moved here on its own so +// mesh-mail-notifier (issue 800 part B) carries a byte-identical copy, the way modules/networkmanager's +// uplink-tools carries ifwatchd's own file (novox/hq ADR 0241, copies_test.go). Nothing here reaches a mailbox or +// a server; it only says where an account's change is told on the bus and what the three changes are called. + +// EventKey is where an account's change is said: mail..message. (research 049 +// document 02 §5), the account by its name in the setting, never its address. +func EventKey(account, change string) string { return "mail." + account + ".message." + change } + +// Changes are the three things said of a message. +var Changes = []string{"added", "changed", "removed"} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go new file mode 100644 index 00000000..bf74727d --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/main.go @@ -0,0 +1,167 @@ +// mesh-mail-notifier: the default claimant of mail-notifier, built from mesh-mail's own event shape (novox/hq +// ADR 0378 §9, issue 800 part B). A Go bundle the node's runtime launches, its own account (runs-as, ADR 0376 +// §2): the base invocation serves mail-notifier.status; a separate process (`notify`) hears mesh-mail's added +// events and shows them. What this module says, it says on stderr; stdout is the MCP channel. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, format+"\n", a...) } + +// AddedPattern is the mesh-mail stream's event this module consumes (its own `consumes` entry, novox/hq ADR +// 0363, ADR 0378 §9): a new, changed or removed message's `added` only. +const AddedPattern = "mesh-mail.mail.*.message.added" + +// StatusTool is the mail-notifier seat's one verb this module serves. +const StatusTool = "mail-notifier.status" + +// ShownEvent is this module's own event (its `emits`), one per notice shown. +const ShownEvent = "shown" + +func main() { + n := &Notifier{ + Send: func(args map[string]any) error { + _, err := stdio.Ask("seat:node-notifier.send", args) + return err + }, + Accounts: readAccounts, + Store: busStore{}, + EmitShown: func(account, messageID string) error { + return stdio.Emit(ShownEvent, map[string]any{"account": account, "message-id": messageID}) + }, + Logf: logf, + } + n.init() + + if len(os.Args) > 1 && os.Args[1] == "notify" { + go watch(n) + if err := stdio.Serve("", nil); err != nil { + logf("%v", err) + os.Exit(1) + } + return + } + + tools := []stdio.Tool{{Name: StatusTool, + Description: "The accounts mesh-mail-notifier announces on this machine, and when it last showed a " + + "notice of new mail (novox/hq ADR 0378 §7). Read-only.", + Run: func(map[string]any) (any, error) { return n.Status() }}} + if err := stdio.Serve("", tools); err != nil { + logf("%v", err) + os.Exit(1) + } +} + +// watch hears mesh-mail's added events and flushes what it buffered once each account has been quiet a +// moment (ADR 0378 §9: folding a burst into one notice). +func watch(n *Notifier) { + time.Sleep(500 * time.Millisecond) // Serve first: the bus is reached through it + err := stdio.Subscribe(AddedPattern, func(e stdio.Envelope) error { + var body EventBody + if err := json.Unmarshal(e.Body, &body); err != nil { + logf("[mesh-mail-notifier] an added event could not be read; taken and dropped: %v", err) + return nil + } + return n.Add(body) + }) + if err != nil { + logf("[mesh-mail-notifier] could not hear %s: %v", AddedPattern, err) + os.Exit(1) + } + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + for range ticker.C { + if err := n.Flush(); err != nil { + logf("[mesh-mail-notifier] a buffered notice could not be shown: %v", err) + } + } +} + +// readAccounts asks mail-store.accounts for the accounts this module is granted, by name to own address. +func readAccounts() (map[string]string, error) { + raw, err := stdio.Ask("seat:mail-store.accounts", map[string]any{}) + if err != nil { + return nil, err + } + var answer struct { + Accounts []struct { + Account string `json:"account"` + Address string `json:"address"` + } `json:"accounts"` + } + if err := json.Unmarshal(raw, &answer); err != nil { + return nil, err + } + out := map[string]string{} + for _, a := range answer.Accounts { + out[a.Account] = a.Address + } + return out, nil +} + +// busStore keeps the notifier's own state (the `shown` bucket the manifest declares): which messages were +// shown already, and each account's last notice. +type busStore struct{} + +func stateKey(parts ...string) string { + words := make([]string, len(parts)) + for i, p := range parts { + var b strings.Builder + for _, r := range p { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-': + b.WriteRune(r) + default: + b.WriteRune('_') + } + } + words[i] = b.String() + } + return strings.Join(words, "_") +} + +func (busStore) HandleSeen(handle string) (bool, error) { + e, err := stdio.State("shown").Get("handle_" + stateKey(strings.Split(handle, "\x1f")...)) + return e != nil, err +} + +func (busStore) MarkHandle(handle string) error { + _, err := stdio.State("shown").Put("handle_"+stateKey(strings.Split(handle, "\x1f")...), true) + return err +} + +func (busStore) PutStatus(account string, s AccountStatus) error { + _, err := stdio.State("shown").Put("status_"+stateKey(account), s) + return err +} + +func (busStore) Statuses() (map[string]AccountStatus, error) { + keys, err := stdio.State("shown").Keys() + if err != nil { + return nil, err + } + out := map[string]AccountStatus{} + for _, k := range keys { + account, ok := strings.CutPrefix(k, "status_") + if !ok { + continue + } + e, err := stdio.State("shown").Get(k) + if err != nil || e == nil { + continue + } + var s AccountStatus + if json.Unmarshal(e.Value, &s) == nil { + out[account] = s + } + } + return out, nil +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go new file mode 100644 index 00000000..5e2801b9 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier.go @@ -0,0 +1,307 @@ +package main + +// mesh-mail-notifier: the default holder of mail-notifier (novox/hq ADR 0378 §7, as amended by ADR 0379 §4, +// issue 800 part B). It hears mesh-mail's added events on its own account (runs-as, ADR 0376 §2) and shows each +// new inbox mail at most once, through node-notifier.send: quiet during a first sync (mesh-mail already says +// nothing then, imap.go ~387), for mail from the account's own address, for mail already marked read (\Seen), +// and for anything outside the inbox. More than three new mails from one account arriving close together are +// folded into one notice naming the count and the latest sender, rather than shown one by one. +// +// Nothing here names Betterbird (ADR 0379 §3). + +import ( + "strconv" + "strings" + "sync" + "time" +) + +// Inbox is the one folder a new mail is shown for. +const Inbox = "INBOX" + +// Seen is the flag that means a mail was already read: shown for nothing it marks. +const SeenFlag = "\\Seen" + +// FoldOver is how many new mails from one account, arriving close together, are still shown one by one; one +// more than that and they become a single notice (novox/hq ADR 0378 §9: "more than three"). +const FoldOver = 3 + +// BurstWindow is how long the notifier waits, after the last qualifying mail of an account, before it decides +// whether to show what it buffered one by one or as a single notice. Well under the minute the decision is +// judged over (ADR 0378 §9), so a burst within that minute is still buffered together. +const BurstWindow = 5 * time.Second + +// Person is a sender or a recipient, as mesh-mail's event says it. +type Person struct { + Name string `json:"name,omitempty"` + Address string `json:"address"` +} + +// EventBody is what mesh-mail's added event says of a message (metadata only; mailevent.go names where it is +// said). Unknown fields are ignored: the notifier reads only what it needs. +type EventBody struct { + Account string `json:"account"` + Folder string `json:"folder"` + UID uint32 `json:"uid"` + MessageID string `json:"message-id"` + Subject string `json:"subject"` + From []Person `json:"from"` + Flags []string `json:"flags"` +} + +// Handle names one message, stably, for the dedupe state (novox/hq ADR 0378 §9: "a redelivered event is not +// shown twice"). +func (e EventBody) Handle() string { + return e.Account + "\x1f" + e.Folder + "\x1f" + strconv.FormatUint(uint64(e.UID), 10) +} + +func (e EventBody) hasFlag(flag string) bool { + for _, f := range e.Flags { + if strings.EqualFold(f, flag) { + return true + } + } + return false +} + +// sender is how a message's first sender reads in a notice: its name and address, or its address alone. +func (e EventBody) sender() string { + if len(e.From) == 0 { + return "(unknown sender)" + } + p := e.From[0] + if p.Name == "" { + return p.Address + } + return p.Name + " <" + p.Address + ">" +} + +func (e EventBody) fromAddress() string { + if len(e.From) == 0 { + return "" + } + return e.From[0].Address +} + +// Notice is one notification the notifier shows: its args for node-notifier.send, and the message its `shown` +// event names (the latest one, for a folded notice). +type Notice struct { + Account string + Args map[string]any + Handles []string // every message this notice accounts for, marked seen once shown + Latest EventBody +} + +// Store is what the notifier keeps of its own (the `shown` state the manifest declares): whether a message +// was shown already, and each account's last notice, for its status tool. +type Store interface { + HandleSeen(handle string) (bool, error) + MarkHandle(handle string) error + PutStatus(account string, s AccountStatus) error + Statuses() (map[string]AccountStatus, error) +} + +// AccountStatus is what the notifier last showed of one account. +type AccountStatus struct { + At time.Time `json:"at"` + Summary string `json:"summary"` +} + +// Notifier buffers qualifying mail per account and decides, once no more of that account's mail arrives for a +// moment, how to show what it buffered. +type Notifier struct { + // Send calls node-notifier.send on this machine. + Send func(args map[string]any) error + // Accounts reads the accounts the notifier is granted, by mail-store.accounts: each one's own address, for + // the quiet rule (novox/hq ADR 0378 §9's LEAD note). An account not known there is shown anyway. + Accounts func() (map[string]string, error) + Store Store + // EmitShown says mesh-mail-notifier's own event `shown`: account and message id, never sender or subject. + EmitShown func(account, messageID string) error + Now func() time.Time + Logf func(string, ...any) + Debounce time.Duration + + mu sync.Mutex + pending map[string][]EventBody + last map[string]time.Time +} + +func (n *Notifier) init() { + n.mu.Lock() + defer n.mu.Unlock() + if n.pending == nil { + n.pending = map[string][]EventBody{} + n.last = map[string]time.Time{} + } + if n.Now == nil { + n.Now = time.Now + } + if n.Logf == nil { + n.Logf = func(string, ...any) {} + } + if n.Debounce == 0 { + n.Debounce = BurstWindow + } +} + +// Add takes one added event. It returns an error only to have the event offered again (the handler +// Subscribe wraps this in follows that rule). +func (n *Notifier) Add(e EventBody) error { + n.init() + seen, err := n.Store.HandleSeen(e.Handle()) + if err != nil { + return err + } + if seen { + return nil // a redelivery of a message already shown (or already judged not to be) + } + own, known, err := n.ownAddress(e.Account) + if err != nil { + return err + } + if !n.qualifies(e, own, known) { + return nil + } + n.mu.Lock() + n.pending[e.Account] = append(n.pending[e.Account], e) + n.last[e.Account] = n.Now() + n.mu.Unlock() + return nil +} + +// qualifies says whether a mail is shown at all: a new inbox mail, not the account's own, not already read. +func (n *Notifier) qualifies(e EventBody, ownAddress string, ownKnown bool) bool { + if !strings.EqualFold(e.Folder, Inbox) { + return false + } + if e.hasFlag(SeenFlag) { + return false + } + if ownKnown && ownAddress != "" && strings.EqualFold(e.fromAddress(), ownAddress) { + return false + } + return true +} + +func (n *Notifier) ownAddress(account string) (string, bool, error) { + accounts, err := n.Accounts() + if err != nil { + // Not known: ADR 0378 §9's LEAD note says the notice is shown when the address cannot be read. Logged, + // not retried: mail-store being unreachable is not this event's fault. + n.Logf("[mesh-mail-notifier] the account's own address could not be read (%v); the notice is shown", err) + return "", false, nil + } + addr, ok := accounts[account] + return addr, ok, nil +} + +// Flush decides every account whose buffer has been quiet for Debounce: one notice each mail it holds, or +// one notice folding them all when there are more than FoldOver. +func (n *Notifier) Flush() error { + n.init() + now := n.Now() + n.mu.Lock() + var ready []string + for account, at := range n.last { + if now.Sub(at) >= n.Debounce { + ready = append(ready, account) + } + } + batches := map[string][]EventBody{} + for _, account := range ready { + batches[account] = n.pending[account] + delete(n.pending, account) + delete(n.last, account) + } + n.mu.Unlock() + for account, events := range batches { + if err := n.decide(account, events); err != nil { + return err + } + } + return nil +} + +// decide shows what one account buffered, and emits `shown` for the notice. +func (n *Notifier) decide(account string, events []EventBody) error { + if len(events) == 0 { + return nil + } + latest := events[len(events)-1] + var notice Notice + if len(events) > FoldOver { + notice = Notice{ + Account: account, + Args: map[string]any{ + "summary": strconv.Itoa(len(events)) + " new mails", + "body": "latest from " + latest.sender() + " — " + account, + "app_name": "mesh-mail", + }, + Latest: latest, + } + for _, e := range events { + notice.Handles = append(notice.Handles, e.Handle()) + } + if err := n.show(notice); err != nil { + return err + } + return nil + } + for _, e := range events { + notice := Notice{ + Account: account, + Args: map[string]any{ + "summary": e.Subject, + "body": e.sender() + " — " + account, + "app_name": "mesh-mail", + }, + Handles: []string{e.Handle()}, + Latest: e, + } + if err := n.show(notice); err != nil { + return err + } + } + return nil +} + +// show sends one notice, marks every message it accounts for seen, keeps the account's status, and emits +// `shown`. +func (n *Notifier) show(notice Notice) error { + if err := n.Send(notice.Args); err != nil { + n.Logf("[mesh-mail-notifier] %s's notice could not be shown: %v", notice.Account, err) + return err + } + for _, h := range notice.Handles { + if err := n.Store.MarkHandle(h); err != nil { + n.Logf("[mesh-mail-notifier] shown, but not kept as shown (%v): a redelivery may show it again", err) + } + } + summary, _ := notice.Args["summary"].(string) + if err := n.Store.PutStatus(notice.Account, AccountStatus{At: n.Now(), Summary: summary}); err != nil { + n.Logf("[mesh-mail-notifier] shown, but the status could not be kept: %v", err) + } + return n.EmitShown(notice.Account, notice.Latest.MessageID) +} + +// Status is the notifier's account of itself, for mail-notifier.status (novox/hq ADR 0378 §7: "accounts it +// announces there, when it last showed a notice"). +func (n *Notifier) Status() (map[string]any, error) { + statuses, err := n.Store.Statuses() + if err != nil { + return nil, err + } + accounts := map[string]any{} + for account, s := range statuses { + accounts[account] = map[string]any{"at": s.At.UTC().Format(time.RFC3339), "shown": s.Summary} + } + return map[string]any{ + "accounts": accounts, + "rules": []string{ + "it shows each new inbox mail at most once, through node-notifier.send on this machine (novox/hq ADR 0378 §7)", + "quiet during a first sync, for mail from the account's own address, for mail already marked read, and for anything outside the inbox", + "more than three new mails from one account arriving close together become one notice naming the count and the latest sender", + }, + }, nil +} diff --git a/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go new file mode 100644 index 00000000..99582247 --- /dev/null +++ b/modules/mesh-mail-notifier/cmd/mesh-mail-notifier/notifier_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "strings" + "sync" + "testing" + "time" +) + +// fakeStore is Store kept in memory, for the test alone. +type fakeStore struct { + mu sync.Mutex + handles map[string]bool + statuses map[string]AccountStatus +} + +func newFakeStore() *fakeStore { + return &fakeStore{handles: map[string]bool{}, statuses: map[string]AccountStatus{}} +} + +func (s *fakeStore) HandleSeen(handle string) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + return s.handles[handle], nil +} + +func (s *fakeStore) MarkHandle(handle string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.handles[handle] = true + return nil +} + +func (s *fakeStore) PutStatus(account string, st AccountStatus) error { + s.mu.Lock() + defer s.mu.Unlock() + s.statuses[account] = st + return nil +} + +func (s *fakeStore) Statuses() (map[string]AccountStatus, error) { + s.mu.Lock() + defer s.mu.Unlock() + out := map[string]AccountStatus{} + for k, v := range s.statuses { + out[k] = v + } + return out, nil +} + +// fakeClock is a clock the test moves by hand, so Flush's debounce needs no real sleep. +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) Advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +// TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded is the issue's first test (novox/hq ADR 0378 §9, +// issue 800 part B): given a new inbox mail from another sender, one already read, one from the account's own +// address, one in another folder, and five within a minute from one account, it sends exactly one notice, +// nothing, nothing, nothing, and one notice folding the five and naming the latest sender; it fails today +// because the module does not exist. +func TestFirstMailOfAnAccountIsShownOnceEachAndABurstIsFolded(t *testing.T) { + clock := &fakeClock{now: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + store := newFakeStore() + var mu sync.Mutex + var sent []map[string]any + var shown []struct{ account, id string } + + n := &Notifier{ + Now: clock.Now, + Send: func(args map[string]any) error { + mu.Lock() + defer mu.Unlock() + sent = append(sent, args) + return nil + }, + Accounts: func() (map[string]string, error) { + return map[string]string{"work": "me@work.example", "home": "me@home.example"}, nil + }, + Store: store, + EmitShown: func(account, messageID string) error { + mu.Lock() + defer mu.Unlock() + shown = append(shown, struct{ account, id string }{account, messageID}) + return nil + }, + Logf: t.Logf, + } + + // 1. A new inbox mail from another sender: shown. + one := EventBody{Account: "work", Folder: "INBOX", UID: 1, MessageID: "m1", Subject: "Hi", + From: []Person{{Name: "Alice", Address: "alice@example.com"}}} + // 2. Already read. + two := EventBody{Account: "work", Folder: "INBOX", UID: 2, MessageID: "m2", Subject: "Old", + From: []Person{{Address: "bob@example.com"}}, Flags: []string{"\\Seen"}} + // 3. From the account's own address. + three := EventBody{Account: "work", Folder: "INBOX", UID: 3, MessageID: "m3", Subject: "Sent", + From: []Person{{Address: "me@work.example"}}} + // 4. In another folder. + four := EventBody{Account: "work", Folder: "Archive", UID: 4, MessageID: "m4", Subject: "Filed", + From: []Person{{Address: "carol@example.com"}}} + for _, e := range []EventBody{one, two, three, four} { + if err := n.Add(e); err != nil { + t.Fatalf("Add(%+v): %v", e, err) + } + } + + // 5. Five new inbox mails from another account within a minute. + var burst []EventBody + for i := 1; i <= 5; i++ { + e := EventBody{Account: "home", Folder: "INBOX", UID: uint32(i), MessageID: "burst" + string(rune('0'+i)), + Subject: "Mail", From: []Person{{Name: "Sender " + string(rune('0'+i)), Address: "s@example.com"}}} + burst = append(burst, e) + clock.Advance(time.Second) + if err := n.Add(e); err != nil { + t.Fatalf("Add(%+v): %v", e, err) + } + } + latest := burst[len(burst)-1] + + clock.Advance(BurstWindow + time.Second) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + + if len(sent) != 2 { + t.Fatalf("sent %d notice(s): %+v", len(sent), sent) + } + if sent[0]["summary"] != "Hi" || !strings.Contains(sent[0]["body"].(string), "Alice") { + t.Errorf("the first notice is %+v, want Alice's mail Hi", sent[0]) + } + if sent[1]["summary"] != "5 new mails" || !strings.Contains(sent[1]["body"].(string), latest.sender()) { + t.Errorf("the folded notice is %+v, want 5 new mails naming %s", sent[1], latest.sender()) + } + + if len(shown) != 2 { + t.Fatalf("emitted shown %d time(s): %+v", len(shown), shown) + } + if shown[0].account != "work" || shown[0].id != "m1" { + t.Errorf("the first shown is %+v, want work/m1", shown[0]) + } + if shown[1].account != "home" || shown[1].id != latest.MessageID { + t.Errorf("the folded shown is %+v, want home/%s (the latest)", shown[1], latest.MessageID) + } + + // Redelivering the first event again shows nothing more: it was marked seen. + if err := n.Add(one); err != nil { + t.Fatalf("Add(one) again: %v", err) + } + clock.Advance(BurstWindow + time.Second) + if err := n.Flush(); err != nil { + t.Fatalf("Flush: %v", err) + } + if len(sent) != 2 { + t.Fatalf("a redelivered event was shown again: sent now %+v", sent) + } +} diff --git a/modules/mesh-mail-notifier/go.mod b/modules/mesh-mail-notifier/go.mod new file mode 100644 index 00000000..7d050d08 --- /dev/null +++ b/modules/mesh-mail-notifier/go.mod @@ -0,0 +1,5 @@ +module mesh-mail-notifier + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 diff --git a/modules/mesh-mail-notifier/go.sum b/modules/mesh-mail-notifier/go.sum new file mode 100644 index 00000000..3b200c4c --- /dev/null +++ b/modules/mesh-mail-notifier/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= diff --git a/modules/mesh-mail-notifier/module.json b/modules/mesh-mail-notifier/module.json new file mode 100644 index 00000000..1d1cb773 --- /dev/null +++ b/modules/mesh-mail-notifier/module.json @@ -0,0 +1,79 @@ +{ + "module": "mesh-mail-notifier", + "version": "1", + "runs-as": "mesh-mail-notifier", + "claims": [ + { + "name": "mail-notifier", + "scope": "node", + "serves": ["status"] + } + ], + "consumes": [ + "mesh-mail.mail.*.message.added" + ], + "invokes": [ + "seat:node-notifier.send", + "seat:mail-store.accounts" + ], + "emits": [ + "shown" + ], + "state": [ + { + "name": "shown", + "ttl-seconds": 2592000 + } + ], + "tools": [ + "mail-notifier.status" + ], + "own-secrets": { + "broker": "${dir:state}/broker" + }, + "secrets-owner": "mesh-mail-notifier", + "resources": [ + { + "id": "account", + "type": "user", + "name": "mesh-mail-notifier", + "shell": "/usr/bin/nologin", + "home": "/var/lib/mesh-mail-notifier" + }, + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "mesh-mail-notifier", + "place": "." + }, + { + "id": "notify", + "type": "process", + "name": "mesh-mail-notifier-notify", + "artifact": "tools", + "run": [ + "./mesh-mail-notifier", + "notify" + ], + "health": { + "kind": "unit" + } + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/mesh-mail-notifier", + "binary": "mesh-mail-notifier", + "loads": [ + "mesh-mail-notifier" + ] + } + ] + } +}