package main // Sending the mail signal (novox/hq ADR 0461 §1, §2, §4 to §6; issue 1522): the words (signal.go, issue 1429) // checked with messenger.messenger_check, then one seat:operator-channel.tell with subject "mail" — or the // safe form when the words are refused. mesh-mail-reader's manifest invokes both (1522/AC4). import ( "context" "encoding/json" "fmt" "strings" "time" ) // MessengerCheck is one seat:messenger.messenger_check ask (ADR 0461 §4): whether text is safe to say on an // operator channel. type MessengerCheck func(text string) (allowed bool, err error) // NewMessengerCheck is MessengerCheck over the module's own Ask (batch.go). func NewMessengerCheck(ask Ask) MessengerCheck { return func(text string) (bool, error) { raw, err := ask("messenger.messenger_check", map[string]any{"text": text}) if err != nil { return false, err } var out struct { Allowed bool `json:"allowed"` } if err := json.Unmarshal(raw, &out); err != nil { return false, fmt.Errorf("messenger_check's answer could not be read: %.200s", raw) } return out.Allowed, nil } } // Tell is one seat:operator-channel.tell ask (ADR 0461 §1): the headline, at most two lines, and the // subject "mail" (1522/AC1). SentOn answers non-empty once a channel actually said it (1522/AC2); empty // means every channel dropped it (the cap of 10 an hour, messenger's outbox.go) or refused it outright. type Tell func(headline string, lines []string, subject string) (sentOn string, err error) // NewTell is Tell over the module's own Ask (batch.go). func NewTell(ask Ask) Tell { return func(headline string, lines []string, subject string) (string, error) { raw, err := ask("seat:operator-channel.tell", map[string]any{"headline": headline, "lines": lines, "subject": subject}) if err != nil { return "", err } var out struct { SentOn json.RawMessage `json:"sent_on"` } if err := json.Unmarshal(raw, &out); err != nil { return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw) } sentOn, err := readSentOn(out.SentOn) if err != nil { return "", fmt.Errorf("operator-channel.tell's answer could not be read: %.200s", raw) } return sentOn, nil } } // readSentOn reads tell's sent_on: messenger answers the list of channels that said it (null or empty when // every channel dropped it), never one string (issue 1926: reading it as a string failed every real answer, so // no mail was ever marked told and the same signal went out after every batch). A plain string is still // accepted. The channels are joined with a comma; empty means nothing said it. func readSentOn(raw json.RawMessage) (string, error) { if len(raw) == 0 || string(raw) == "null" { return "", nil } var list []string if err := json.Unmarshal(raw, &list); err == nil { return strings.Join(list, ","), nil } var one string if err := json.Unmarshal(raw, &one); err != nil { return "", err } return one, nil } // signalEntries is what the signal counts out of the entries marked matters and not yet told (issue 1926): // a mail whose due date lies before today is left out (AC2: it no longer matters, the mail page still holds // it); the same mail seen twice (one message id in two folders, issue 1849) and the same notice in two // languages (the same sender address, mail day and due date) count once (AC3). The entries left out stay // as they were; the ones that count are returned in the order given. func signalEntries(entries []Entry, now time.Time) []Entry { today := time.Date(now.UTC().Year(), now.UTC().Month(), now.UTC().Day(), 0, 0, 0, 0, time.UTC) seen := map[string]bool{} var out []Entry for _, e := range entries { if !e.DueDate.IsZero() && e.DueDate.UTC().Before(today) { continue } var keys []string if e.MessageID != "" { keys = append(keys, "id\x00"+e.Account+"\x00"+e.MessageID) } if !e.DueDate.IsZero() && e.SenderAddress != "" { keys = append(keys, "notice\x00"+e.Account+"\x00"+strings.ToLower(e.SenderAddress)+"\x00"+ e.Date.UTC().Format("2006-01-02")+"\x00"+e.DueDate.UTC().Format("2006-01-02")) } dup := false for _, k := range keys { if seen[k] { dup = true } } for _, k := range keys { seen[k] = true } if !dup { out = append(out, e) } } return out } // SendSignal is 1522: every entry marked matters and not yet told — including one carried over from an // earlier batch that went untold (1429/AC4) — is gathered; its words are built (signal.go) and checked word // by word with check; any one refused falls back to the safe form (1522/AC1). Nothing is sent, and nothing // asked at all, when there is nothing to say (a batch whose only mark is scam warning, 1522/AC3) or while // the reader is unhealthy (1522/AC3, ADR 0461 §6) — read fresh from the store, not assumed from the caller's // own last outcome. The tell sent, every one of those entries is marked told only once it answers sent_on // (1522/AC2); a tell that answers sent_on empty leaves them exactly as they were, for the next signal to // count (1522/AC2, AC3). func SendSignal(ctx context.Context, store Store, check MessengerCheck, tell Tell, now func() time.Time) error { health, err := store.ReaderHealth(ctx) if err != nil { return err } if !health.Healthy { return nil } entries, err := store.Entries(ctx) if err != nil { return err } var untold []Entry for _, e := range entries { if e.Mark == MarkMatters && !e.Told { untold = append(untold, e) } } // What counts (signalEntries) is what is said; every untold entry is marked told once it is said, so an // unchanged set is never said twice (issue 1926/AC1) and one that only holds past-due or duplicate mails // says nothing. toTell := signalEntries(untold, now()) if len(toTell) == 0 { return nil } var mails []SignalMail for _, e := range toTell { mails = append(mails, SignalMail{SenderName: e.SenderName, Summary: e.Summary, Date: e.Date, DueDate: e.DueDate}) } words := BuildSignal(mails) allowed := true for _, line := range append([]string{words.Headline}, words.Lines...) { ok, err := check(line) if err != nil { return err } if !ok { allowed = false break } } if !allowed { words = SafeSignal(len(toTell)) } sentOn, err := tell(words.Headline, words.Lines, "mail") if err != nil { return err } if sentOn == "" { return nil } for _, e := range untold { e.Told = true if err := store.UpdateEntry(ctx, e); err != nil { return err } } return nil }