Files
mesh-catalog/modules/mesh-mail-reader/cmd/mesh-mail-reader/tell.go
T

187 lines
6.4 KiB
Go

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
}