1494 lines
66 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|
|
}
|