Files
mesh-catalog/modules/mesh-mail/cmd/mesh-mail/send.go
T

1494 lines
66 KiB
Go

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"`
// Attempt is which handover to the server this is, 1 for the first: the sender's own claim from queued
// or retrying to sending counts it (novox/hq ADR 0436 clauses 9, 10, issue 1196).
Attempt int `json:"attempt,omitempty"`
// NextAttempt is when a "retrying" send is next claimed, the bus's own redelivery time (novox/hq ADR
// 0436 clause 10, issue 1198): read by mail_health to say a send overdue (issue 1199).
NextAttempt time.Time `json:"next-attempt,omitempty"`
// Tell is the sender's own tell to the operator for a failed, refused or not-proven send, kept to once:
// "" (not owed, or not yet attempted), "telling" (claimed, the channel not yet confirmed it took the
// message) or "told" (novox/hq ADR 0436 clause 11, issue 1199).
Tell string `json:"tell,omitempty"`
// WaitsTell is the tools' own tell that an overdue send's sender has not taken it, kept to once the
// same way as Tell, on its own field: the two are never the same tell (novox/hq ADR 0436 clause 11,
// issue 1199).
WaitsTell string `json:"waits-tell,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 <name>"; 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 <name>" 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 <addr>") 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 ---------------------------------------------------------------------------------------------------
// MailSendApproved is the event mesh-mail's tools publish on the work queue mail-sends once the operator's
// Approve is verified and the send recorded approved (novox/hq ADR 0436 clause 8).
const MailSendApproved = "mail.send.approved"
// QueuedSend is the body mail.send.approved carries: never the recipients, the subject or the body, which stay
// in the send's own record in `sends` (novox/hq ADR 0436 clause 8).
type QueuedSend struct {
Send string `json:"send"`
Account string `json:"account"`
Ask string `json:"ask"`
}
// QueuePublish publishes one message on the module's own work-queue stream, the message id chosen (here, the
// send id) so a repeated publish inside the stream's duplicate window is kept to one (novox/hq ADR 0436
// clauses 6, 8); nothing here reads or holds a password. Real is sdkQueuePublish; a test gives its own.
type QueuePublish func(id string, body any) error
func sdkQueuePublish(id string, body any) error {
return stdio.Emit(MailSendApproved, body, stdio.EmitOptions{MessageID: id})
}
// updateDecision writes a decision onto a kept send by compare-and-set, without touching the undecided index:
// the index is settleSend's to drop, once the decision is one the sweep no longer needs to look at again
// (novox/hq issue 811). On a race lost to another writer, it reads back whatever that writer decided.
func updateDecision(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
}
return d, newRev, nil
}
// 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 the sweep no longer needs to act on (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) {
final, newRev, err := updateDecision(store, req, rev, d)
if err != nil {
return final, 0, err
}
if newRev != 0 {
_ = removeUndecided(store, req.ID) // best-effort (see removeUndecided)
}
return final, 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 — records the send approved and queues it for the sender (novox/hq ADR 0436 clause 8);
// it never sends. 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 queues nothing twice.
func Decide(store SendStore, publish QueuePublish, 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, publish, req, rev, w, now)
default:
return settle(SendDecision{At: now, Outcome: "refused: the option " + option.ID + " is not one mail_send offers"})
}
}
// decideSend records the request queued by a compare-and-set write before it publishes, so a redelivered
// warrant finds it already claimed (or decided) and queues nothing twice: a person's Approve is queued once
// (novox/hq ADR 0436 clause 8, as decided to replace the two-state approved/queued design — review round 1,
// replacing finding 2's work-around with a decision changing the clause itself). There is no "approved"
// state in a send's life at all: the record moves straight from undecided to queued; the publish that
// follows is best-effort, and a publish that fails (or the tools stopping before it ever runs) leaves the
// record "queued" with its own time, which the sweep finds and republishes once a short grace has passed
// (Sweep, below) — the send id as the message id, and the stream's duplicate window, keep it one message
// either way, and the sender's own claim (clause 9) keeps it one send. It never sends: the sender (clause 7)
// takes the queued message, verifies the warrant again itself (clause 9) and performs the one handover to
// the server (clause 10).
func decideSend(store SendStore, publish QueuePublish, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) {
// Indexed before the record carries "queued" (novox/hq review round 1 finding 3): a crash between this
// and the write below must still leave it findable by mail_health's overdue check and the sweep's own
// stale-queued retry once "queued" does land.
indexErr := addSenderPending(store, req.ID)
queued := SendDecision{At: now, Outcome: "queued", Warrant: w.Says()}
claimed, newRev, err := settleSend(store, req, rev, queued)
if err != nil {
return SendDecision{}, errOr(indexErr, err)
}
if newRev == 0 {
// Lost the claim race: another writer already decided this send.
return claimed, indexErr
}
// Best-effort: its own failure is never returned to the caller as this call's own error — the record is
// already durably "queued" either way, and the sweep retries a publish that did not land.
_ = publish(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID})
return claimed, indexErr
}
// ---- the sender's own claim (novox/hq ADR 0436 clauses 9, 10, issue 1196) --------------------------------------
//
// The sender never trusts the queue's message: it reads the router's record of mesh-mail's own ask under its
// own account, and mirrors the tools' own check (Decide, above) exactly — w.For, then option.Performs on the
// request's own Act — before it claims the send by compare-and-set and, only then, connects to anything.
// RouterRecord is how the sender reads the router's record of one ask: a function so a test gives a fake, and
// the real one (sender.go) wraps a process.Conn's Ask(bucket, id) over the operator channel's records.
type RouterRecord func(id string) (*stdio.StateEntry, error)
// claimOutcome is what became of one claim, so the caller (the message handler) knows the message's fate.
type claimOutcome int
const (
// claimToSend: claimed "sending" by this call; the caller may now connect and send.
claimToSend claimOutcome = iota
// claimRefused: recorded refused for a reason this function names; the caller terminates the message.
claimRefused
// claimNotProven: the send was already "sending" (a previous delivery may have handed it to the server);
// recorded not proven; the caller terminates the message, never retrying it automatically.
claimNotProven
// claimLostRace: the compare-and-set to "sending" lost to another writer (another delivery of the same
// message, racing this one); nothing is recorded here and nothing sent; the caller leaves the message to
// the bus entirely — no Ack, no Nak, no Term.
claimLostRace
// claimAlreadyFinished: a redelivery of a message whose send already finished (sent, failed, refused
// or not proven) — the sender's own ack was lost, or two deliveries raced and the other one already
// finished it. Nothing is recorded, emitted or told again; the caller only terminates the message
// (novox/hq ADR 0436 clause 10, operator-approved change).
claimAlreadyFinished
)
// isFinishedOutcome says whether a decided send's outcome is already over, in the sense clause 10 means it:
// sent, failed (any reason), refused (any reason, including the sender's own clause-9 refusal), or not
// proven. A redelivery that finds one of these is a repeat, never a new refusal (novox/hq review, the
// operator-approved change to clause 10).
func isFinishedOutcome(outcome string) bool {
return outcome == "sent" || outcome == "not proven" ||
strings.HasPrefix(outcome, "failed: ") || strings.HasPrefix(outcome, "refused: ")
}
// claimQueuedSend verifies the operator's warrant for id (mesh-mail's own ask) and claims the send by
// compare-and-set from queued or retrying to sending, with the attempt and time, before any connection
// (novox/hq ADR 0436 clauses 9, 10). It is never trusted from the queue's message alone: the ask is read
// fresh from the router under the sender's own account, closed by the operator's Approve at level approve
// within its expiry (Warrant.For), and its act's digest (the account, recipients, subject and body's digest)
// is the option's binds (Option.Performs) — exactly the tools' own check (Decide, above) mirrored here,
// because the tool runner's account, which every agent session on this machine also reaches the bus as, may
// publish on the queue and write `sends` (this is the boundary clause 9 describes).
func claimQueuedSend(store SendStore, record RouterRecord, emit Emit, id string, now time.Time) (SendRequest, SendDecision, uint64, claimOutcome, error) {
req, rev, ok, err := getSend(store, id)
if err != nil {
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
}
if !ok {
return SendRequest{}, SendDecision{}, 0, claimLostRace, fmt.Errorf("no send %q is kept", id)
}
if req.Decided == nil {
// Nothing was ever decided at all (never queued): no tell owed (clause 11 ties the tell to a
// *failed approved* send), and nothing emitted either — clause 11's two events are for a send the
// operator did approve.
d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: the send is not yet approved"})
return req, d, 0, claimRefused, err
}
// Every other refusal below follows a real Approve (the send reached at least queued): clause 9's own
// refusal owes the operator a tell and the same mail.send.failed event as clause 10's refusal does
// (novox/hq ADR 0436 clause 11, issue 1199; review round 1 finding 4).
refuse := func(reason string) (SendRequest, SendDecision, uint64, claimOutcome, error) {
// Indexed for a tell BEFORE the record carries "refused" (novox/hq review round 1 finding 3): a
// crash between this and the write below must still leave it findable once refused does land;
// tellSend itself tolerates finding it still sending, queued or retrying in the meantime.
indexErr := addTellPending(store, req.ID)
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "refused: " + reason})
if newRev == 0 {
// The write did not land (another writer's decision already stands, or it failed outright):
// nothing of this call's own making to emit (novox/hq review round 2, issue 1198).
return req, d, 0, claimRefused, errOr(indexErr, err)
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
// Only once refused is durably recorded is the sender-pending index's entry dropped.
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
}
return req, d, 0, claimRefused, indexErr
}
switch {
case req.Decided.Outcome == "sending":
// A previous delivery may have handed it to the server already; never sent twice, and never
// retried automatically either way (ADR 0436 clause 10). Same ordering as refuse() above.
indexErr := addTellPending(store, req.ID)
d, newRev, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "not proven", Attempt: req.Decided.Attempt})
if newRev == 0 {
// The write did not land: nothing of this call's own making to emit (novox/hq review round 2,
// issue 1198).
return req, d, 0, claimNotProven, errOr(indexErr, err)
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
}
return req, d, 0, claimNotProven, indexErr
case isFinishedOutcome(req.Decided.Outcome):
// A redelivery of a message whose send already finished — sent, failed, refused by the sender's
// own claim, or not proven: the sender's own ack was lost (a bus reconnect) and the work-queue gave
// it up after AckWait, or two deliveries raced and the other one already finished it. The operator
// was already told (or sent) once; telling, emitting or recording anything again here would be a
// second word about a send that is already over. Just a repeat: terminated, nothing else touched
// (novox/hq ADR 0436 clause 10, operator-approved change).
return req, *req.Decided, 0, claimAlreadyFinished, nil
case req.Decided.Outcome != "queued" && req.Decided.Outcome != "retrying":
return refuse(fmt.Sprintf("the send is %q, neither queued nor retrying", req.Decided.Outcome))
}
entry, err := record(id)
if err != nil {
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
}
if entry == nil {
return refuse("the router keeps no record of this ask")
}
var rec routerRecord
if json.Unmarshal(entry.Value, &rec) != nil {
return refuse("the router's record of it cannot be read")
}
if rec.State == "" || rec.State == "open" || rec.Warrant == nil {
return refuse("the ask is not closed by the operator's Approve")
}
w := *rec.Warrant
if w.Asker != Module {
return refuse(fmt.Sprintf("the warrant is for %s's ask, not %s's", w.Asker, Module))
}
ask := req.Ask()
option, err := w.For(Module, ask)
if err == nil {
err = option.Performs(req.Act(option.ID))
}
if err != nil {
return refuse(err.Error())
}
if option.ID != OptionSend {
return refuse("the option " + option.ID + " does not authorise a send")
}
claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says(), Attempt: req.Decided.Attempt + 1}
claimed, newRev, err := updateDecision(store, req, rev, claim)
if err != nil {
return SendRequest{}, SendDecision{}, 0, claimLostRace, err
}
if newRev == 0 {
// Lost the claim race: another delivery of the same message is already handling it.
return req, claimed, 0, claimLostRace, nil
}
return req, claimed, newRev, claimToSend, nil
}
// ---- the send's outcome, retry and emit (novox/hq ADR 0436 clauses 10, 11, issue 1198) --------------------------
// MailSendSent and MailSendFailed are the two events the sender emits, never anything else (novox/hq ADR
// 0436 clause 11): the send id, the account and the outcome in words — never the recipients, the subject or
// the body.
const (
MailSendSent = "mail.send.sent"
MailSendFailed = "mail.send.failed"
)
// OutcomeEvent is the body mail.send.sent and mail.send.failed carry.
type OutcomeEvent struct {
Send string `json:"send"`
Account string `json:"account"`
Outcome string `json:"outcome"`
}
// Emit publishes one of this module's own events with a chosen message id (novox/hq ADR 0436 clauses 4,
// 11): the real one (sender.go) wraps a process.Conn's own Emit; a test gives its own.
type Emit func(eventType, id string, body any) error
// RetryDelays is how long after each not-taken attempt (1-indexed) the bus is asked to redeliver the
// message, before the next attempt: 1, 5, 15 and 30 minutes for attempts 1 to 4 (novox/hq ADR 0436 clause
// 10).
var RetryDelays = []time.Duration{time.Minute, 5 * time.Minute, 15 * time.Minute, 30 * time.Minute}
// MostAttempts is how many times the sender hands a claimed send to the server before giving up: the fifth
// failure records failed, and the bus's own max-deliveries (module.json's mail-sends) dead-letters the
// message once it is terminated rather than acknowledged (novox/hq ADR 0436 clause 10).
const MostAttempts = 5
// msgAction is what the caller (the message handler, sender.go) does with the queue message once
// recordOutcome has written the decision: Ack (sent), NakWithDelay (retrying, not the last attempt yet) or
// Term (failed, or not proven — the bus is never asked to redeliver those; a dead letter past the fifth
// attempt is still reached by Term, since clause 10 records failed itself rather than waiting for the bus's
// own max-deliveries to notice). actionLeave is none of these: the record's own write could not be kept
// (even after a retry on a changed revision), so nothing is emitted and the message is left entirely to
// the bus — a later redelivery finds the record still at its previous outcome and reconciles from there,
// never a send silently announced sent, failed or retrying when it was not actually kept that way (novox/hq
// review round 1 finding 5).
type msgAction int
const (
actionAck msgAction = iota
actionRetry
actionTerm
actionLeave
)
// recordOutcome writes the final decision for one claimed send once SendMail has run (or could not even be
// attempted — the account vanished, or its password cannot be read, both passed in as a NotTaken
// *SendError, exactly as a real SendMail failure would be: safe to retry), and says what the caller does
// with the message next. Attempt (req's own, from the claim) decides retrying from failed on a not-taken
// failure: the fifth attempt always fails rather than retries again (novox/hq ADR 0436 clauses 10, 11).
func recordOutcome(store SendStore, emit Emit, req SendRequest, claimed SendDecision, rev uint64, sendErr error, now time.Time) (SendDecision, msgAction, time.Duration, error) {
// write keeps d, retrying once on a changed revision (re-reading the send and trying again with its
// fresh one) before giving up: kept is false only when the record's own write could not be kept even
// after that retry, in which case the caller emits nothing and leaves the message to the bus entirely —
// never announcing an outcome (sent, failed, retrying) that was not actually recorded (novox/hq review
// round 1 finding 5). "Kept" is decided from settleSend's own returned revision, never from its error
// alone: on a changed revision whose record already carries another writer's decision, settleSend
// (through updateDecision) returns that other decision with revision 0 and a nil error — a nil error
// is not success here, and without checking the revision the retry never ran at all, so the caller
// could emit an outcome (and ack) while the record actually held someone else's state (novox/hq review
// round 2, issue 1198).
write := func(d SendDecision, final, tellWorthy bool) (result SendDecision, kept bool, outErr error) {
var indexErr error
if tellWorthy {
// Indexed for a tell BEFORE the record carries the outcome that owes it (novox/hq review round
// 1 finding 3): a crash between this and the write below must still leave it findable once the
// outcome does land; tellSend itself tolerates finding it still sending, queued or retrying.
indexErr = addTellPending(store, req.ID)
}
curReq, curRev := req, rev
var newRev uint64
var writeErr error
for attempt := 0; attempt < 2; attempt++ {
result, newRev, writeErr = settleSend(store, curReq, curRev, d)
if newRev != 0 {
break // this write landed
}
if attempt == 1 {
break // retried once already; give up
}
if writeErr != nil && !errors.Is(writeErr, stdio.ErrChanged) {
break // a hard failure, not a revision race: retrying blindly would not help
}
fresh, freshRev, ok, rerr := getSend(store, req.ID)
if rerr != nil || !ok {
break
}
curReq, curRev = fresh, freshRev
}
if newRev == 0 {
reportErr := writeErr
if reportErr == nil {
reportErr = fmt.Errorf("the send %s's outcome could not be kept: another writer's decision already stands, even retried on its fresh revision", req.ID)
}
return d, false, errOr(indexErr, reportErr)
}
if final {
// Only once the outcome is durably recorded is the sender-pending index's entry dropped: from
// here the sender is done with it, one way or another (novox/hq issue 1199).
if rmErr := removeSenderPending(store, req.ID); rmErr != nil {
indexErr = errOr(indexErr, fmt.Errorf("dropping %s from the sender-pending index: %w", req.ID, rmErr))
}
}
return result, true, indexErr
}
if sendErr == nil {
d, kept, err := write(SendDecision{At: now, Outcome: "sent", Warrant: claimed.Warrant, Attempt: claimed.Attempt}, true, false)
if !kept {
return d, actionLeave, 0, err
}
_ = emit(MailSendSent, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
return d, actionAck, 0, err
}
var se *SendError
if !errors.As(sendErr, &se) {
// SendMail itself never returns an error that is not a *SendError; a caller passing one of its own
// (the account vanished, or its password cannot be read) is read as not taken, never as a refusal or
// a proven send, which would be worse guesses than "safe to retry" (novox/hq ADR 0436 clause 10).
se = &SendError{Outcome: NotTaken, Err: sendErr}
}
switch se.Outcome {
case Refused:
d, kept, err := write(SendDecision{At: now, Outcome: "failed: " + se.Err.Error(), Warrant: claimed.Warrant,
Attempt: claimed.Attempt, Error: se.Err.Error()}, true, true)
if !kept {
return d, actionLeave, 0, err
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
return d, actionTerm, 0, err
case NotProven:
d, kept, err := write(SendDecision{At: now, Outcome: "not proven", Warrant: claimed.Warrant, Attempt: claimed.Attempt,
Error: se.Err.Error()}, true, true)
if !kept {
return d, actionLeave, 0, err
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
return d, actionTerm, 0, err
default: // NotTaken
if claimed.Attempt >= MostAttempts {
d, kept, err := write(SendDecision{At: now, Outcome: "failed: " + se.Err.Error(), Warrant: claimed.Warrant,
Attempt: claimed.Attempt, Error: se.Err.Error()}, true, true)
if !kept {
return d, actionLeave, 0, err
}
_ = emit(MailSendFailed, req.ID, OutcomeEvent{Send: req.ID, Account: req.Account, Outcome: d.Outcome})
return d, actionTerm, 0, err
}
delay := RetryDelays[claimed.Attempt-1]
d, kept, err := write(SendDecision{At: now, Outcome: "retrying", Warrant: claimed.Warrant, Attempt: claimed.Attempt,
Error: se.Err.Error(), NextAttempt: now.Add(delay)}, false, false)
if !kept {
return d, actionLeave, 0, err
}
return d, actionRetry, delay, err // not a final outcome: no emit (clause 11 names only sent and failed)
}
}
// ---- two more indices (novox/hq issue 1199), the same shape as the undecided index (issue 811) ------------------
//
// Neither the tools' nor the sender's own `sends` state lists every key it holds (mesh-sdk go/process's
// State offers no such call, and listing it all on every health check or every look would grow with every
// send ever sent, not with the few still live): a reserved key stands in for a list, exactly as the
// undecided index already does.
// errOr is a, or b when a is nil: the first of two errors a caller that can only report one keeps (novox/hq
// review round 1 finding 3), so an index-bookkeeping failure is never silently dropped once there is also a
// record-write failure to report, and vice versa.
func errOr(a, b error) error {
if a != nil {
return a
}
return b
}
// idIndex is the shape a send-id index keeps: generic across all three (undecided, sender-pending,
// tells-pending).
type idIndex struct {
IDs []string `json:"ids"`
}
// addToIndex adds id to the index at key, 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 addToIndex(store SendStore, key, id string) error {
for {
raw, rev, ok, err := store.Get(key)
if err != nil {
return err
}
var idx idIndex
if ok {
if err := json.Unmarshal(raw, &idx); err != nil {
return fmt.Errorf("the index %q cannot be parsed: %w", key, err)
}
for _, existing := range idx.IDs {
if existing == id {
return nil
}
}
}
idx.IDs = append(idx.IDs, id)
if !ok {
if _, err := store.Create(key, idx); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return err
}
return nil
}
if _, err := store.Update(key, idx, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return err
}
return nil
}
}
// removeFromIndex drops id from the index at key, retrying its compare-and-set write on a race. An id
// never in the index (already absent) is left as it is.
func removeFromIndex(store SendStore, key, id string) error {
for {
raw, rev, ok, err := store.Get(key)
if err != nil || !ok {
return err
}
var idx idIndex
if err := json.Unmarshal(raw, &idx); err != nil {
return fmt.Errorf("the index %q cannot be parsed: %w", key, 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(key, idx, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return err
}
return nil
}
}
// indexIDs is every id the index at key currently names.
func indexIDs(store SendStore, key string) ([]string, error) {
raw, _, ok, err := store.Get(key)
if err != nil || !ok {
return nil, err
}
var idx idIndex
if err := json.Unmarshal(raw, &idx); err != nil {
return nil, fmt.Errorf("the index %q cannot be parsed: %w", key, err)
}
return idx.IDs, nil
}
// senderPendingKey indexes every send the sender currently owns — queued, retrying or sending — so
// mail_health's overdue check (Overdue, below) finds them without listing `sends` whole.
const senderPendingKey = "sender-pending"
func addSenderPending(store SendStore, id string) error {
return addToIndex(store, senderPendingKey, id)
}
func removeSenderPending(store SendStore, id string) error {
return removeFromIndex(store, senderPendingKey, id)
}
func senderPendingIDs(store SendStore) ([]string, error) { return indexIDs(store, senderPendingKey) }
// tellsPendingKey indexes every send still owing the sender's own tell (needsTell, below), so its own
// look (sender.go) finds them without listing `sends` whole.
const tellsPendingKey = "tells-pending"
func addTellPending(store SendStore, id string) error { return addToIndex(store, tellsPendingKey, id) }
func removeTellPending(store SendStore, id string) error {
return removeFromIndex(store, tellsPendingKey, id)
}
func tellPendingIDs(store SendStore) ([]string, error) { return indexIDs(store, tellsPendingKey) }
// ---- overdue (novox/hq ADR 0436 clause 11, issue 1199) ------------------------------------------------------
// OverdueAfter is how long a send may sit queued, or wait past its own next attempt, before mail_health
// calls it unhealthy (novox/hq ADR 0436 clause 11).
const OverdueAfter = 10 * time.Minute
// Overdue is every send the sender currently owns (senderPendingIDs) that is overdue: queued more than
// OverdueAfter ago, or retrying more than OverdueAfter past its own next attempt. A send waiting for its
// next attempt within that window, or one actually being sent, is never overdue (novox/hq ADR 0436 clause
// 11).
func Overdue(store SendStore, now time.Time) ([]SendRequest, error) {
ids, err := senderPendingIDs(store)
if err != nil {
return nil, err
}
var out []SendRequest
for _, id := range ids {
req, _, ok, err := getSend(store, id)
if err != nil || !ok || req.Decided == nil {
continue
}
switch {
case req.Decided.Outcome == "queued" && now.Sub(req.Decided.At) > OverdueAfter:
out = append(out, req)
case req.Decided.Outcome == "retrying" && now.Sub(req.Decided.NextAttempt) > OverdueAfter:
out = append(out, req)
}
}
return out, nil
}
// ---- telling the operator (novox/hq ADR 0436 clause 11, issue 1199) ----------------------------------------
//
// Two separate tells, each kept to once on its own field: the sender's own, for a failed, refused or
// not-proven send (Tell); the tools' own, for an overdue one — still queued or retrying, its sender not
// having taken it yet (WaitsTell). Neither ever names an address, held to the operator channel's content
// rule by using only the account's own name.
// TellOperator calls seat:operator-channel.tell once with the one line to say (novox/hq ADR 0380 §8). The
// real ones wrap a process.Conn's own Call (sender.go) or stdio.Ask (main.go); a test gives its own.
type TellOperator func(headline string) error
// needsTell says whether a decided send's outcome owes the operator the sender's own tell (novox/hq ADR
// 0436 clause 11): every outcome after the Approve but sent — failed, refused or not proven.
func needsTell(outcome string) bool {
return outcome == "not proven" || strings.HasPrefix(outcome, "failed: ") || strings.HasPrefix(outcome, "refused: ")
}
// tellReason is the sender's own one line for a failed, refused or not-proven send (novox/hq ADR 0436
// clause 11).
func tellReason(account, outcome string) string {
if outcome == "not proven" {
return fmt.Sprintf("The mail you approved from %s may not have been sent; its sent folder says", account)
}
reason := outcome
for _, prefix := range []string{"failed: ", "refused: "} {
if r, ok := strings.CutPrefix(outcome, prefix); ok {
reason = r
break
}
}
return fmt.Sprintf("The mail you approved from %s was not sent: %s", account, reason)
}
// tellSend attempts the sender's own tell for one failed, refused or not-proven send, kept to once by
// Tell: telling then told, by compare-and-set, on the record itself (novox/hq ADR 0436 clause 11). A tell
// the channel refuses, or that nothing answers, leaves telling, so the next look (the sender's own ticker,
// at least every 10 minutes) tries it again; a record already told, or whose outcome no longer needs one,
// is dropped from the index without being told again.
func tellSend(store SendStore, tell TellOperator, id string, now time.Time) error {
req, rev, ok, err := getSend(store, id)
if err != nil {
return err
}
if !ok || req.Decided == nil {
_ = removeTellPending(store, id)
return nil
}
switch req.Decided.Outcome {
case "sending", "queued", "retrying":
// Indexed for a tell before the outcome that owes it had actually landed (addTellPending now runs
// before the write, novox/hq review round 1 finding 3): not decided yet whether a tell is owed.
// Left in the index; the next look finds it once the write lands.
return nil
}
if !needsTell(req.Decided.Outcome) || req.Decided.Tell == "told" {
_ = removeTellPending(store, id)
return nil
}
d := *req.Decided
if d.Tell != "telling" {
d.Tell = "telling"
next := req
next.Decided = &d
newRev, err := store.Update(id, next, rev)
if err != nil {
if errors.Is(err, stdio.ErrChanged) {
return nil // raced with another writer; the next look sorts it out
}
return err
}
rev = newRev
}
if err := tell(tellReason(req.Account, d.Outcome)); err != nil {
return nil // left telling; tried again on the next look
}
d.Tell = "told"
next := req
next.Decided = &d
if _, err := store.Update(id, next, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return nil
}
return err
}
_ = removeTellPending(store, id)
return nil
}
// waitsReason is the tools' own one line for an overdue send (novox/hq ADR 0436 clause 11).
func waitsReason(account string) string {
return fmt.Sprintf("The mail you approved from %s waits: its sender has not taken it", account)
}
// tellWaits attempts the tools' own "waits" tell for one overdue send, kept to once by WaitsTell — the
// same telling/told sequence as tellSend, on the record's own separate field, since the two tells are
// never the same one (novox/hq ADR 0436 clause 11).
func tellWaits(store SendStore, tell TellOperator, id string, now time.Time) error {
req, rev, ok, err := getSend(store, id)
if err != nil {
return err
}
if !ok || req.Decided == nil || (req.Decided.Outcome != "queued" && req.Decided.Outcome != "retrying") || req.Decided.WaitsTell == "told" {
return nil
}
d := *req.Decided
if d.WaitsTell != "telling" {
d.WaitsTell = "telling"
next := req
next.Decided = &d
newRev, err := store.Update(id, next, rev)
if err != nil {
if errors.Is(err, stdio.ErrChanged) {
return nil
}
return err
}
rev = newRev
}
if err := tell(waitsReason(req.Account)); err != nil {
return nil
}
d.WaitsTell = "told"
next := req
next.Decided = &d
if _, err := store.Update(id, next, rev); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return nil
}
return err
}
return nil
}
// ---- reading the status -----------------------------------------------------------------------------------------
// ReadSendStatus is where a send asked with mail_send stands, answered only to the caller that asked for it
// (novox/hq ADR 0440 §7, issue 1261): an id no send is kept under, or a send whose own caller differs from the
// one asking, answers status "unknown" and nothing else of it — never an error, and never the account,
// recipients or subject of a send that is not the caller's own. The sweep, which settles a send regardless of
// who asked for it, calls readSendStatus directly instead, never through this caller check.
func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id, caller 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 || req.Caller != caller {
return map[string]any{"status": "unknown"}, nil
}
return readSendStatus(store, bus, publish, req, rev, now)
}
// readSendStatus is ReadSendStatus's own reading of one send already fetched (req, rev): 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). It never checks who is asking: ReadSendStatus
// does that before calling it, and the sweep (which settles a send no matter who asked for it) calls it
// directly.
func readSendStatus(store SendStore, bus AskBus, publish QueuePublish, req SendRequest, rev uint64, now time.Time) (map[string]any, error) {
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", req.ID, 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, publish, *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).
// reportDecision's status word is one of: waiting (for the operator, or for the sender — approved, queued,
// being sent or retrying, each said apart in words), sent, failed, refused, not proven, declined or expired
// (novox/hq ADR 0436 clause 12, issue 1199) — the whole vocabulary mail_send_status answers.
func reportDecision(out map[string]any, d SendDecision, now time.Time) {
out["at"] = d.At.Format(time.RFC3339)
switch {
case d.Outcome == "queued":
// Recorded queued by the one compare-and-set the Approve makes (novox/hq ADR 0436 clause 8): there
// is no separate "approved" state in a send's life. Published to mail-sends already, or the sweep
// will publish it again once a short grace has passed.
out["status"], out["words"] = "waiting", "approved and waiting for the sender; ask again shortly"
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", "being sent; ask again shortly"
case d.Outcome == "retrying":
out["status"] = "waiting"
out["words"] = "waiting for its next attempt, at " + d.NextAttempt.Format(time.RFC3339) + ": " + d.Error
case d.Outcome == "not proven":
out["status"] = "not proven"
out["words"] = "not proven: the send may or may not have gone out: " + d.Error
case d.Outcome == "ended: never heard":
out["status"], out["words"] = "expired", "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"] = "expired", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ")
case strings.HasPrefix(d.Outcome, "refused: "):
out["status"], out["words"] = "refused", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ")
default:
out["status"], out["words"] = "expired", 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.queuePublish(), 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)
}
}
}
// QueuedGrace is how long a newly queued send is given to be published before the sweep considers it
// possibly stuck and publishes it again: long enough that the tools' own publish, right after the same
// compare-and-set write, is never raced by the sweep under ordinary load (novox/hq ADR 0436 clause 8, as
// decided to replace the two-state approved/queued design, review round 1).
const QueuedGrace = time.Minute
// Sweep does two things, neither reading every send ever kept (its work grows only with the few still
// live, never with history): a send the undecided index still names and that is not yet decided at all is
// asked about the same way mail_send_status does (ReadSendStatus) — an Approve recorded while this module
// did not hear it directly (down, or not yet subscribed) is still acted on, once (novox/hq ADR 0361 §2,
// review round 1 of issue 790; the index, issue 811); and a send the sender-pending index names that is
// still "queued" past QueuedGrace — its publish to mail-sends may have failed, or the tools stopped before
// it ever ran — is published again, the send id still its message id, so the duplicate window and the
// sender's own claim keep it one send either way (novox/hq ADR 0436 clause 8, review round 1).
func (b *Bundle) Sweep(now time.Time) {
store := b.sends()
undecided, err := undecidedIDs(store)
if err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] the undecided index cannot be read: %v", err)
}
} else {
for _, id := range undecided {
req, rev, ok, err := getSend(store, id)
if err != nil || !ok || req.Decided != nil {
continue
}
// 1261/AC6: the sweep settles a send regardless of who asked for it — readSendStatus directly,
// never ReadSendStatus's caller check.
if _, err := readSendStatus(store, b.askBus(), b.queuePublish(), req, rev, now); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] sweeping the send %s: %v", id, err)
}
}
}
}
pending, err := senderPendingIDs(store)
if err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] the sender-pending index cannot be read: %v", err)
}
return
}
for _, id := range pending {
req, _, ok, err := getSend(store, id)
if err != nil || !ok || req.Decided == nil || req.Decided.Outcome != "queued" || now.Sub(req.Decided.At) < QueuedGrace {
continue
}
if err := b.queuePublish()(req.ID, QueuedSend{Send: req.ID, Account: req.Account, Ask: req.ID}); err != nil {
if b.Logf != nil {
b.Logf("[mesh-mail] republishing the queued send %s: %v", id, err)
}
}
}
}