package main // mail_send (novox/hq ADR 0361 §2): the tool that replaces the no-send rule of ADR 0359 §5, for sending alone. It // sends nothing itself. A caller granted the account (the same check as mail_read, ADR 0359 §4) asks for one send; // this raises an ask on the operator channel (novox/hq ADR 0259) showing the account, the recipients, the subject // and the first lines of the body, and the tool returns at once with the ask's id. The mail reaches the account's // outgoing server only on the operator's Approve, on a channel that proves who answered, and once: the id is kept // with the decision it was given, so a warrant delivered twice is acted on once (asks.Warrant.For and // asks.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it // expires, sends nothing, which mail_send_status says. The password is read only to sign in, and the body only to // send; neither is ever logged. // // The pattern — publish an ask under this module's own name, compose it again from what was kept to hold the // router's warrant, act once on a compare-and-set claim — follows mesh-knowledge's own approval of a record // (cmd/mesh-knowledge/operator.go) and claude-code's proposals and questions (cmd/claude-code/proposals.go, // questions.go). import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "net/mail" "regexp" "strings" "time" stdio "git.novox.be/novox/mesh-sdk/go" "git.novox.be/novox/mesh-sdk/go/asks" ) // SendsState is the module's state the sends are kept in. const SendsState = "sends" // The two answers, both at the level approve, so only a channel that proves who answered can carry either. const ( OptionSend = "send" OptionDecline = "decline" ) // SendAskLasts is how long the operator has to answer: a day less a margin, under the router's bound for an approval. const SendAskLasts = asks.ApproveLasts - 10*time.Minute // StaleSendAfter is how long a send may read "sending" before mail_send_status stops calling it "waiting": past // this, the claim before the SMTP call is old enough that the send may or may not have gone out, and is never // retried automatically (novox/hq issue 790, review round 1). const StaleSendAfter = 10 * time.Minute // SweepEvery is how often the sweep reads every send still undecided and asks the router's own record for its // answer, so an Approve answered while this module did not hear it directly (down, or not yet subscribed) is // still acted on (novox/hq issue 790, review round 1). const SweepEvery = time.Minute // undecidedIndexKey is a reserved key in the `sends` state (never a send id, which always starts "send-") that // keeps the ids of the sends still undecided, so the sweep can list only those instead of every send ever kept // (novox/hq issue 811). It stays in the same declared state rather than a state of its own, which a manifest // change would need. const undecidedIndexKey = "undecided-index" // undecidedIndex is the undecidedIndexKey document: the ids of the sends still undecided. type undecidedIndex struct { IDs []string `json:"ids"` } // addUndecided adds id to the undecided index, retrying its compare-and-set write on a race. A stored index that // cannot be parsed is never overwritten (which would silently drop every id it already named): it is reported as // an error naming the key instead (novox/hq issue 811, review round 3). func addUndecided(store SendStore, id string) error { for { raw, rev, ok, err := store.Get(undecidedIndexKey) if err != nil { return err } var idx undecidedIndex if ok { if err := json.Unmarshal(raw, &idx); err != nil { return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) } for _, existing := range idx.IDs { if existing == id { return nil } } } idx.IDs = append(idx.IDs, id) if !ok { if _, err := store.Create(undecidedIndexKey, idx); err != nil { if errors.Is(err, stdio.ErrChanged) { continue } return err } return nil } if _, err := store.Update(undecidedIndexKey, idx, rev); err != nil { if errors.Is(err, stdio.ErrChanged) { continue } return err } return nil } } // removeUndecided drops id from the undecided index, retrying its compare-and-set write on a race. A send never // read by the index (already absent) is left as it is. A stored index that cannot be parsed is never overwritten: // it is reported as an error naming the key instead (novox/hq issue 811, review round 3). func removeUndecided(store SendStore, id string) error { for { raw, rev, ok, err := store.Get(undecidedIndexKey) if err != nil || !ok { return err } var idx undecidedIndex if err := json.Unmarshal(raw, &idx); err != nil { return fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) } at := -1 for i, existing := range idx.IDs { if existing == id { at = i break } } if at == -1 { return nil } idx.IDs = append(idx.IDs[:at], idx.IDs[at+1:]...) if _, err := store.Update(undecidedIndexKey, idx, rev); err != nil { if errors.Is(err, stdio.ErrChanged) { continue } return err } return nil } } // undecidedIDs is every id the undecided index currently names, for the sweep to read. An index that cannot be // parsed is reported as an error naming the key, not silently read as empty (novox/hq issue 811, review round 3). func undecidedIDs(store SendStore) ([]string, error) { raw, _, ok, err := store.Get(undecidedIndexKey) if err != nil || !ok { return nil, err } var idx undecidedIndex if err := json.Unmarshal(raw, &idx); err != nil { return nil, fmt.Errorf("the undecided index %q cannot be parsed: %w", undecidedIndexKey, err) } return idx.IDs, nil } // The bounds of what mail_send takes. const ( MostRecipients = 20 MostSubject = 200 MostSendBody = 1 << 20 // 1 MiB; only the first ShownBodyChars reach the operator's ask ShownBodyChars = 300 ) // SendRequest is one send asked of the operator, as the `sends` state keeps it. Every field the ask composes from // is here, so the decision is held to exactly what was asked. type SendRequest struct { ID string `json:"id"` Account string `json:"account"` To []string `json:"to"` Subject string `json:"subject"` Body string `json:"body"` Caller string `json:"caller"` At time.Time `json:"at"` // AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq // issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's // name, any other said without its address or host. Composed once, from the accounts configured when // the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide, // mail_send_status) as it did when the operator was first asked. AskTo string `json:"ask_to"` // Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2). Decided *SendDecision `json:"decided,omitempty"` } // SendDecision is what became of one send, kept once and read back by mail_send_status. type SendDecision struct { At time.Time `json:"at"` Outcome string `json:"outcome"` Warrant string `json:"warrant,omitempty"` Error string `json:"error,omitempty"` } // bodyDigest is the digest an act binds in place of the body itself: never the body, which the act's digest is // published and read back on the bus. func bodyDigest(body string) string { sum := sha256.Sum256([]byte(body)) return "sha256:" + hex.EncodeToString(sum[:]) } // Act is what an answer binds: this request, whole, and the option chosen. func (s SendRequest) Act(option string) asks.Act { return asks.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account, "to": strings.Join(s.To, ","), "subject": s.Subject, "body-digest": bodyDigest(s.Body), "caller": s.Caller, "at": s.At.UTC().Format(time.RFC3339Nano)} } // firstChars is at most n runes of s, marked when it was cut. func firstChars(s string, n int) string { r := []rune(s) if len(r) <= n { return s } return string(r[:n]) + " […cut]" } // Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first // lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets // through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and // body have every mail address and link or host name masked (maskText) before they reach the ask. Messenger // refuses an ask as a whole on its Headline, Explanation, OnExpiry and each option's Label and Does alone // (asks.go's TakeAsk) — never Details, which it filters line by line instead (holder.go's sayOn) — so once // those are composed, the real content rule (content_rule.go, mesh-mail's own run-time copy) judges them // exactly as messenger would. When the masking still leaves something the rule refuses (an IP, a hardware // address, a path-shaped reference, a hex hash, … novox/hq issue 868, review round 1), the subject and first // lines are left out of the Explanation and pointed at Details instead, so the ask always reaches the operator. // The mail itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body. func (s SendRequest) Ask() asks.Ask { to := s.AskTo subject := maskText(s.Subject) body := maskText(firstChars(s.Body, ShownBodyChars)) headline := fmt.Sprintf("Send mail from %s?", s.Account) if len([]rune(headline)) > asks.HeadlineLength { headline = "Send mail?" } onExpiry := "nothing is sent" options := []struct{ id, label, does string }{ {OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"}, {OptionDecline, "Decline", "nothing is sent"}, } // judged is exactly the words messenger refuses (or lets through) an ask as a whole on. judged := func(explanation string) string { words := []string{headline, explanation, onExpiry} for _, o := range options { words = append(words, o.label, o.does) } return strings.Join(words, "\n") } explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject) if len([]rune(explanation)) > asks.SummaryLength { explanation = fmt.Sprintf("From %s: %q.", s.Account, subject) } if _, ok := Check(judged(explanation)); !ok { explanation = fmt.Sprintf("From %s to %s. The subject and first lines are in Details.", s.Account, to) if len([]rune(explanation)) > asks.SummaryLength { explanation = "Send mail. The subject and first lines are in Details." } } details := fmt.Sprintf("Asked by %s through the mesh-mail module, at %s UTC.\nAccount: %s\nTo: %s\nSubject: %s\n\n%s", s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body) a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details, Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: onExpiry} for _, o := range options { binds, _ := asks.ActDigest(s.Act(o.id)) a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds}) } return a } func newSendID() string { var b [8]byte _, _ = rand.Read(b[:]) return "send-" + hex.EncodeToString(b[:]) } // ---- the bus, as a send reaches it ---------------------------------------------------------------------------- // AskBus is the operator channel as this module reaches it: publish an ask under its own name, and read the // router's own record of one of its asks. type AskBus interface { Publish(subject string, body any, id string) error Record(id string) (*stdio.StateEntry, error) } type sdkAskBus struct{} func (sdkAskBus) Publish(subject string, body any, id string) error { return stdio.SeatPublish(subject, body, id) } func (sdkAskBus) Record(id string) (*stdio.StateEntry, error) { return stdio.SeatRecord("", id) } // SendStore is the `sends` state: a value with its revision, created only when absent, updated only at the revision // read — so of two writers only the first writes (the compare-and-set a decision is claimed by). type SendStore interface { Get(key string) (json.RawMessage, uint64, bool, error) Create(key string, value any) (uint64, error) Update(key string, value any, revision uint64) (uint64, error) Keys() ([]string, error) } // keptSends is the `sends` state on the bus. type keptSends struct{ s stdio.KeptState } func (k keptSends) Get(key string) (json.RawMessage, uint64, bool, error) { e, err := k.s.Get(key) if err != nil || e == nil { return nil, 0, false, err } return e.Value, e.Revision, true, nil } func (k keptSends) Create(key string, value any) (uint64, error) { return k.s.Create(key, value) } func (k keptSends) Update(key string, value any, revision uint64) (uint64, error) { return k.s.Update(key, value, revision) } func (k keptSends) Keys() ([]string, error) { return k.s.Keys() } func getSend(store SendStore, id string) (SendRequest, uint64, bool, error) { raw, rev, ok, err := store.Get(id) if err != nil || !ok { return SendRequest{}, 0, false, err } var req SendRequest if json.Unmarshal(raw, &req) != nil || req.ID != id { return SendRequest{}, 0, false, nil } return req, rev, true, nil } // routerRecord is the router's record of one ask, as far as this module reads it. type routerRecord struct { State string `json:"state"` Warrant *asks.Warrant `json:"warrant"` } // ---- asking ---------------------------------------------------------------------------------------------------- // SendInput is one send a caller asks for: the account is already the caller's to use (the grants check, the same // as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has // configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know // which recipients are the operator's own (novox/hq issue 868). type SendInput struct { Account Account To []string Subject string Body string Caller string Accounts []Account } // cleanAddresses trims and drops empty entries. func cleanAddresses(in []string) []string { var out []string for _, a := range in { if a = strings.TrimSpace(a); a != "" { out = append(out, a) } } return out } // hasControlChar is true when s holds a byte a mail header must never carry: CR, LF, any other ASCII control // character below 0x20, or DEL (0x7f) — exactly what a header injection needs (novox/hq issue 790, review round 1). func hasControlChar(s string) bool { for i := 0; i < len(s); i++ { if s[i] < 0x20 || s[i] == 0x7f { return true } } return false } // AskMailSend raises an ask for one send and keeps it, under the module's own name. It never sends: the mail // reaches the server only once the operator's Approve is read back and held to this request (Decide). It answers // at once, with the ask's id; mail_send_status says where it stands. func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[string]any, error) { refuse := func(why string) (map[string]any, error) { return nil, errors.New(why + ". Nothing was sent") } to := cleanAddresses(in.To) switch { case len(to) == 0: return refuse("to names no recipient") case len(to) > MostRecipients: return refuse(fmt.Sprintf("to names %d recipients, more than %d", len(to), MostRecipients)) } for _, addr := range to { if hasControlChar(addr) { return refuse(fmt.Sprintf("%q is not a mail address: it holds a control character", addr)) } if _, err := mail.ParseAddress(addr); err != nil { return refuse(fmt.Sprintf("%q is not a mail address", addr)) } } subject := strings.TrimSpace(in.Subject) switch { case subject == "": return refuse("subject is empty") case hasControlChar(subject): return refuse("the subject holds a control character") case len([]rune(subject)) > MostSubject: return refuse(fmt.Sprintf("the subject is %d characters, more than %d", len([]rune(subject)), MostSubject)) } switch { case strings.TrimSpace(in.Body) == "": return refuse("body is empty") case len(in.Body) > MostSendBody: return refuse(fmt.Sprintf("the body is %d bytes, more than %d", len(in.Body), MostSendBody)) } if in.Account.Outgoing == nil { return refuse(fmt.Sprintf("the account %s has no outgoing server configured", in.Account.Name)) } req := SendRequest{ID: newSendID(), Account: in.Account.Name, To: to, Subject: subject, Body: in.Body, Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)} ask := req.Ask() if err := ask.Check(now); err != nil { return refuse(err.Error()) } if _, err := store.Create(req.ID, req); err != nil { return nil, fmt.Errorf("the send could not be kept, so the operator was not asked: %w. Nothing was sent", err) } if err := addUndecided(store, req.ID); err != nil { // Nothing was published yet, so refusing here costs nothing: an ask the sweep could never see is never // sent (novox/hq issue 811, review round 3). return nil, fmt.Errorf("the send could not be made visible to the sweep, so the operator was not asked: %w. Nothing was sent", err) } if err := bus.Publish(asks.AskSubject(Module), ask, ask.ID); err != nil { return nil, fmt.Errorf("the operator could not be asked (%v). Nothing was sent; mail_send_status %s says so", err, req.ID) } return map[string]any{ "ask": req.ID, "status": "waiting", "asked": "\"" + ask.Headline + "\", to " + to[0] + onwards(to), "from": in.Account.Name, "expires": ask.Expires.Format(time.RFC3339), "next": "mail_send_status with this ask id says where it stands; nothing is sent until the operator's Approve, " + "and nothing on Decline or at expiry", }, nil } func onwards(to []string) string { if len(to) <= 1 { return "" } return fmt.Sprintf(" and %d more", len(to)-1) } // ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------ // // messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because // Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the // subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead: // a recipient of the operator's own is named by its account; any other loses its address and host; the subject // and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ. // mostShownRecipients is how many recipients the ask names one by one before it counts the rest. const mostShownRecipients = 3 // composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your // account "; any other is said without its address or host (its first letter, an ellipsis, and its // domain's labels as words); past the first few, the rest are counted rather than named. func composeAskTo(to []string, accounts []Account) string { own := map[string]string{} for _, a := range accounts { if a.Address != "" { own[strings.ToLower(a.Address)] = a.Name } } shown, more := to, 0 if len(shown) > mostShownRecipients { shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients } parts := make([]string, 0, len(shown)) for _, addr := range shown { parts = append(parts, oneAskRecipient(addr, own)) } line := strings.Join(parts, ", ") if more > 0 { line += fmt.Sprintf(" and %d more", more) } return line } // oneAskRecipient is one recipient's words in the ask: "your account " for one of the accounts given, or // its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). A // recipient written with a display name ("Name ") is matched and masked by its address alone, parsed the // same way AskMailSend already validates it (novox/hq issue 868, review round 1). func oneAskRecipient(addr string, own map[string]string) string { bare := addr if parsed, err := mail.ParseAddress(addr); err == nil { bare = parsed.Address } if name, ok := own[strings.ToLower(bare)]; ok { return "your account " + name } local, domain, found := strings.Cut(bare, "@") if !found || local == "" { return "a recipient" } first := string([]rune(local)[:1]) return first + "… at " + strings.ReplaceAll(domain, ".", " ") } // maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL), // so what they catch here is masked before the rule ever judges it. var ( maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`) // maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators), // so a bare host name is found on the same tokens the rule itself would judge. maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`) ) // maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list // content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused. var maskTopLevel = map[string]bool{} func init() { for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz site online tech page link be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { maskTopLevel[t] = true } } // maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the // subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves: // the mail is sent with what was actually written (novox/hq issue 868). func maskText(s string) string { s = maskEmail.ReplaceAllString(s, "[address]") s = maskURL.ReplaceAllString(s, "[link]") return maskWord.ReplaceAllStringFunc(s, func(word string) string { trimmed := strings.TrimRight(word, ".,:;!?") if looksLikeHostName(trimmed) { return "[link]" + word[len(trimmed):] } return word }) } // looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName // judges a bare host name by. func looksLikeHostName(w string) bool { w = strings.ToLower(w) if w == "localhost" { return true } parts := strings.Split(w, ".") if len(parts) < 2 { return false } for _, p := range parts { if p == "" { return false } } return maskTopLevel[parts[len(parts)-1]] } // ---- deciding --------------------------------------------------------------------------------------------------- // AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked: // the account's server may have changed since, and its password is read fresh), or found false. type AccountLookup func(name string) (Account, bool) // SendActor performs the one send a chosen option authorises, resolving the account's own secret itself. It is // called at most once per request (Decide claims the request first). type SendActor func(a Account, to []string, subject, body string, now time.Time) error // settleSend writes a decision onto a kept send (claiming it, or ending it outright) and, once the write lands, // drops it from the undecided index: an index entry is never left behind once the record it points to carries a // decision (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided. func settleSend(store SendStore, req SendRequest, rev uint64, d SendDecision) (SendDecision, uint64, error) { next := req next.Decided = &d newRev, err := store.Update(req.ID, next, rev) if err != nil { if errors.Is(err, stdio.ErrChanged) { if again, _, ok, err2 := getSend(store, req.ID); err2 == nil && ok && again.Decided != nil { return *again.Decided, 0, nil } } return d, 0, err } _ = removeUndecided(store, req.ID) // best-effort (see removeUndecided) return d, newRev, nil } // Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and — // on Approve, exactly once — performs the send. A request already decided is never decided again: decide answers // its kept decision at once, and two warrants for the same request (a redelivery, or two readers racing) have only // the first claim the request by a compare-and-set write; the second finds it already claimed or decided and sends // nothing. func Decide(store SendStore, lookup AccountLookup, send SendActor, w asks.Warrant, now time.Time) (SendDecision, error) { req, rev, ok, err := getSend(store, w.Ask) if err != nil { return SendDecision{}, err } if !ok { return SendDecision{}, fmt.Errorf("no send %q is kept", w.Ask) } if req.Decided != nil { return *req.Decided, nil } if w.Asker != Module { return SendDecision{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's", w.Asker, w.Ask, Module) } settle := func(d SendDecision) (SendDecision, error) { final, _, err := settleSend(store, req, rev, d) return final, err } if w.Outcome != asks.OutcomeChosen { outcome := "ended: " + string(w.Outcome) if w.Words != "" { outcome += " (" + w.Words + ")" } return settle(SendDecision{At: now, Outcome: outcome}) } ask := req.Ask() option, err := w.For(Module, ask) if err == nil { err = option.Performs(req.Act(option.ID)) } if err != nil { return settle(SendDecision{At: now, Outcome: "refused: " + err.Error()}) } switch option.ID { case OptionDecline: return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()}) case OptionSend: return decideSend(store, lookup, send, req, rev, w, now) default: return settle(SendDecision{At: now, Outcome: "refused: the option " + option.ID + " is not one mail_send offers"}) } } // decideSend claims the request by a compare-and-set write before it sends, so a redelivered warrant finds it // already claimed (or decided) and acts on nothing: a person's Approve is performed once, never retried on a // transient failure (the failure is kept in the decision, not hidden). func decideSend(store SendStore, lookup AccountLookup, send SendActor, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) { claimed := req claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says()} claimedDecision, newRev, err := settleSend(store, req, rev, claim) if err != nil { return SendDecision{}, err } if newRev == 0 { // Lost the claim race: another writer already decided this send. return claimedDecision, nil } claimed.Decided = &claim acc, found := lookup(req.Account) var final SendDecision switch { case !found: final = SendDecision{At: now, Outcome: "refused: the account no longer exists", Warrant: w.Says()} default: if sendErr := send(acc, req.To, req.Subject, req.Body, now); sendErr != nil { final = SendDecision{At: now, Outcome: "failed: " + sendErr.Error(), Warrant: w.Says(), Error: sendErr.Error()} } else { final = SendDecision{At: now, Outcome: "sent", Warrant: w.Says()} } } claimed.Decided = &final if _, err := store.Update(req.ID, claimed, newRev); err != nil { // The claim stands either way: a person's Approve is acted on once. The caller logs this; it is not retried. return final, fmt.Errorf("the send %s was %s, and that could not be kept (%w)", req.ID, final.Outcome, err) } return final, nil } // ---- reading the status ----------------------------------------------------------------------------------------- // ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, // declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own // record; when the router's word is a close one that was not decided yet, this is also where a send is first // decided (and so sent), exactly once (Decide). func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send SendActor, id string, now time.Time) (map[string]any, error) { trimmed := strings.TrimSpace(id) req, rev, ok, err := getSend(store, trimmed) if err != nil { return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err) } if !ok { return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed) } out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject, "asked": req.At.Format(time.RFC3339)} if req.Decided != nil { reportDecision(out, *req.Decided, now) return out, nil } entry, err := bus.Record(req.ID) if err != nil { return nil, fmt.Errorf("the router's record of the ask cannot be read now (%v); ask again", err) } if entry == nil { // The router never recorded this ask at all (its Publish failed, or it was never heard): once its asked // time plus SendAskLasts has passed, it never will be, so it is settled as ended rather than left // "waiting" forever (novox/hq issue 811). if now.Sub(req.At) > SendAskLasts { d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"}) if err != nil { return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err) } reportDecision(out, d, now) return out, nil } out["status"], out["words"] = "waiting", "not yet recorded by the router; nothing is sent until the operator answers" return out, nil } var rec routerRecord if json.Unmarshal(entry.Value, &rec) != nil { out["status"], out["words"] = "not proven", "the router's record of it cannot be read" return out, nil } if rec.State == "" || rec.State == "open" || rec.Warrant == nil { out["status"], out["words"] = "waiting", "the operator has not answered yet" return out, nil } d, err := Decide(store, lookup, send, *rec.Warrant, now) if err != nil { out["status"], out["words"] = "not proven", err.Error() return out, nil } reportDecision(out, d, now) return out, nil } // reportDecision turns a kept decision into what mail_send_status answers: a status word a caller can branch on, // and the words that explain it. now is when it is asked, not when the send was decided: a claim still "sending" // longer than StaleSendAfter with no final write is "not proven" (the send may or may not have gone out), never // "waiting" forever — and it is never retried automatically either way (novox/hq issue 790, review round 1). func reportDecision(out map[string]any, d SendDecision, now time.Time) { out["at"] = d.At.Format(time.RFC3339) switch { case d.Outcome == "sent": out["status"], out["words"] = "sent", "sent on the operator's Approve ("+d.Warrant+")" case d.Outcome == "declined": out["status"], out["words"] = "declined", "nothing was sent: the operator declined ("+d.Warrant+")" case d.Outcome == "sending" && now.Sub(d.At) > StaleSendAfter: out["status"] = "not proven" out["words"] = "not proven: the send may or may not have gone out; the operator's Approve was being acted " + "on at " + d.At.Format(time.RFC3339) + ", with no final word since" case d.Outcome == "sending": out["status"], out["words"] = "waiting", "the operator's Approve is being acted on; ask again shortly" case d.Outcome == "ended: never heard": out["status"], out["words"] = "ended", "its ask to the operator was never heard, so it was not sent" case strings.HasPrefix(d.Outcome, "failed: "): out["status"], out["words"] = "failed", "nothing reached the server: "+strings.TrimPrefix(d.Outcome, "failed: ") case strings.HasPrefix(d.Outcome, "ended: "): out["status"], out["words"] = "ended", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ") case strings.HasPrefix(d.Outcome, "refused: "): out["status"], out["words"] = "not proven", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ") default: out["status"], out["words"] = "ended", d.Outcome } } // ---- heard directly, and swept ------------------------------------------------------------------------------- // Heard acts on one warrant the router's own subject delivered to this module directly (main.go's hear), through // the same Decide that mail_send_status uses: an approved send goes out as soon as the operator answers, never // only when something later happens to call mail_send_status (novox/hq ADR 0361 §2, review round 1 of issue 790). func (b *Bundle) Heard(w asks.Warrant) { if _, err := Decide(b.sends(), b.lookupAccount, b.sendActor(), w, b.now()); err != nil { if b.Logf != nil { b.Logf("[mesh-mail] the warrant for the send %s could not be acted on: %v", w.Ask, err) } } } // Sweep reads only the sends the undecided index still names, and asks the router's own record for each one's // answer, the same path mail_send_status takes (ReadSendStatus): an Approve recorded while this module did not // hear it directly (down, or not yet subscribed) is still acted on, once. Unlike reading every send ever kept, its // work grows only with the sends still undecided, never with every one a module has ever sent (novox/hq ADR 0361 // §2, review round 1 of issue 790; the index, issue 811). func (b *Bundle) Sweep(now time.Time) { store := b.sends() ids, err := undecidedIDs(store) if err != nil { if b.Logf != nil { b.Logf("[mesh-mail] the undecided index cannot be read: %v", err) } return } for _, id := range ids { req, _, ok, err := getSend(store, id) if err != nil || !ok || req.Decided != nil { continue } if _, err := ReadSendStatus(store, b.askBus(), b.lookupAccount, b.sendActor(), id, now); err != nil { if b.Logf != nil { b.Logf("[mesh-mail] sweeping the send %s: %v", id, err) } } } }