diff --git a/modules/mesh-mail/README.md b/modules/mesh-mail/README.md index be92c413..c5104514 100644 --- a/modules/mesh-mail/README.md +++ b/modules/mesh-mail/README.md @@ -2,7 +2,9 @@ One module holds the operator's mail accounts, watches them, and says each change of a message on the bus for the modules the operator grants it to (novox/hq ADR 0359, research 049 document 02). One instance, on the control-node. -This is the first stage: accounts of the kind `imap`, read-only. Nothing here flags, moves, deletes, drafts or sends. +Reading is the first stage: accounts of the kind `imap`, read-only — nothing here flags, moves, deletes or drafts. +Sending is `mail_send` alone (novox/hq ADR 0361 §2): it sends nothing itself, and reaches an account's outgoing +server only once the operator approves it on a channel that proves who answered. ## What it does @@ -32,12 +34,13 @@ This is the first stage: accounts of the kind `imap`, read-only. Nothing here fl "folders": ["optional: the folders watched besides the inbox; every folder when left out"]}} ``` -`outgoing` is read and never used in this stage. A second `imap` account on another server is one more entry and one -more password; no code changes. `user` signs in under another name than the address. +`outgoing` is the server `mail_send` sends through: host, port, and `security` `tls` (465, implicit) or `starttls` +(587). A second `imap` account on another server is one more entry and one more password; no code changes. `user` +signs in under another name than the address. -`grants` — per account, who may read its mail: a module's name (on every machine), `.`, or -`person.`. Nobody by default. It restricts the stream `MAIL` on the bus (a person's entry grants nothing there) -and the tools `mail_search` and `mail_read`. +`grants` — per account, who may read its mail, and who may send from it: a module's name (on every machine), +`.`, or `person.`. Nobody by default. It restricts the stream `MAIL` on the bus (a person's +entry grants nothing there) and the tools `mail_search`, `mail_read` and `mail_send` — the same check for all three. The password of an account is the own secret `mail-credential-`, given at the desk; the mesh never makes one. @@ -49,8 +52,12 @@ The password of an account is the own secret `mail-credential-`, given | `mail_search` | messages of the accounts the caller is granted, by every word of a query, newest first, no body | | `mail_read` | one message's metadata and text, or one attachment's bytes, to a caller granted its account | | `mail_health` | healthy; unhealthy naming the account and why (connection down, or no answer for five minutes); waits naming `mail-credential-` while its password is not given | +| `mail_send` | raises an ask on the operator channel (the account, the recipients, the subject, the first lines) for a caller granted the account; answers at once with the ask's id. Sends nothing itself | +| `mail_send_status` | where a send stands, by its ask id: waiting, sent, declined, ended without a choice, or not proven | -The caller is the runtime's word, never an argument. +The caller is the runtime's word, never an argument. `mail_send` sends through the account's outgoing server only +once the operator's Approve comes back on a channel that proves who answered, and once: a warrant delivered twice +never sends twice (novox/hq ADR 0361 §2). ## Its processes diff --git a/modules/mesh-mail/cmd/mesh-mail/config_test.go b/modules/mesh-mail/cmd/mesh-mail/config_test.go index 1fb38430..21acbb3a 100644 --- a/modules/mesh-mail/cmd/mesh-mail/config_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/config_test.go @@ -28,8 +28,8 @@ func TestTheFirstStagesAccountIsReadAndItsSecretNamed(t *testing.T) { } // A second account of the kind imap on another server — Gmail with an app password, another member of the family — -// is configuration only: no code changes for it. Each account may also say its outgoing server, which this stage -// reads and never uses (it sends nothing). +// is configuration only: no code changes for it. Each account may also say its outgoing server, which mail_send +// uses to send through it (novox/hq ADR 0361 §1). func TestASecondIMAPAccountIsConfigurationOnly(t *testing.T) { got, err := ParseAccounts([]byte(`{"accounts": { "mesh": {"kind": "imap", "address": "jschoubben@novox.be", "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}}, diff --git a/modules/mesh-mail/cmd/mesh-mail/imap_test.go b/modules/mesh-mail/cmd/mesh-mail/imap_test.go index 6a62105e..a17052a3 100644 --- a/modules/mesh-mail/cmd/mesh-mail/imap_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/imap_test.go @@ -209,8 +209,11 @@ func TestTheKeeperWaitsForThePasswordAndSaysARefusal(t *testing.T) { }) } -// It only reads (ADR 0359 §5): no code of the module, but its tests, issues a command that flags, moves, copies, -// deletes, appends, creates, renames or sends, and every folder is opened read-only and every body peeked. +// It only reads a mailbox (ADR 0359 §5): no code of the module, but its tests, issues a command that flags, moves, +// copies, deletes, appends, creates, renames or otherwise writes to one over IMAP, and every folder is opened +// read-only and every body peeked. mail_send is the one exception the operator's own Approve carves out (ADR 0361 +// §2): it never touches a mailbox either — it speaks SMTP, in smtp.go alone, to the account's outgoing server, and +// only once the operator approved it (send.go). func TestNothingButTheTestsWritesToAMailbox(t *testing.T) { files, err := filepath.Glob("*.go") if err != nil { @@ -218,7 +221,9 @@ func TestNothingButTheTestsWritesToAMailbox(t *testing.T) { } writes := regexp.MustCompile(`([A-Za-z_][A-Za-z0-9_]*)\.(Store|Move|Copy|Expunge|UIDExpunge|Append|Create|Delete|Rename|Subscribe|Unsubscribe|SetMetadata|SetQuota)\(`) for _, f := range files { - if strings.HasSuffix(f, "_test.go") { + if strings.HasSuffix(f, "_test.go") || f == "send.go" || f == "smtp.go" { + // Neither touches a mailbox: send.go keeps the `sends` state on the bus, never IMAP; smtp.go speaks SMTP + // to the account's outgoing server, never to a mailbox, and only on the operator's Approve (ADR 0361 §2). continue } raw, err := os.ReadFile(f) @@ -231,7 +236,7 @@ func TestNothingButTheTestsWritesToAMailbox(t *testing.T) { } } if strings.Contains(string(raw), "net/smtp") { - t.Errorf("%s speaks SMTP: this stage sends nothing", f) + t.Errorf("%s speaks SMTP: only smtp.go does, and only on the operator's Approve (ADR 0361 §2)", f) } } src, _ := os.ReadFile("imap.go") diff --git a/modules/mesh-mail/cmd/mesh-mail/main.go b/modules/mesh-mail/cmd/mesh-mail/main.go index 34ae3094..373d0020 100644 --- a/modules/mesh-mail/cmd/mesh-mail/main.go +++ b/modules/mesh-mail/cmd/mesh-mail/main.go @@ -1,8 +1,10 @@ // mesh-mail: the operator's mail accounts, watched, and each mail said on the bus (novox/hq ADR 0359). One instance, -// on the control-node. The first stage holds the mesh's own server, read-only: no flag, move, delete, draft or send. +// on the control-node. Reading is read-only: no flag, move, delete or draft. mail_send is the one way out (novox/hq +// ADR 0361 §2): it sends nothing itself, and reaches an account's outgoing server only on the operator's Approve. // -// mesh-mail the bundle the node's runtime launches: the tools mail_accounts, mail_search, mail_read and -// mail_health, and the events the keeper wrote, said on the bus as the module +// mesh-mail the bundle the node's runtime launches: the tools mail_accounts, mail_search, mail_read, +// mail_health, mail_send and mail_send_status, and the events the keeper wrote, said on the +// bus as the module // mesh-mail prepare the preparation step: the index's schema brought to this version's // mesh-mail keep acts: watches every account (IDLE on the inbox, a look at the other folders every five // minutes), keeps the index and the attachment store, and writes each change's event beside it diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index 128300cb..d6563479 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -78,7 +78,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { for _, tool := range (&Bundle{}).Tools() { names = append(names, tool.Name) } - if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, []string{"mail_accounts", "mail_search", "mail_read", "mail_health"}) { + if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, []string{"mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status"}) { t.Fatalf("tools %v, the code serves %v", m.Tools, names) } // Its events go to its own stream MAIL, kept a week, each account's part heard only by the consumers the trusted diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go new file mode 100644 index 00000000..213691e0 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -0,0 +1,443 @@ +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" + "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 + +// 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"` + // Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2). + Decided *SendDecision `json:"decided,omitempty"` +} + +// SendDecision is what became of one send, kept once and read back by mail_send_status. +type SendDecision struct { + At time.Time `json:"at"` + Outcome string `json:"outcome"` + Warrant string `json:"warrant,omitempty"` + Error string `json:"error,omitempty"` +} + +// bodyDigest is the digest an act binds in place of the body itself: never the body, which the act's digest is +// published and read back on the bus. +func bodyDigest(body string) string { + sum := sha256.Sum256([]byte(body)) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +// Act is what an answer binds: this request, whole, and the option chosen. +func (s SendRequest) Act(option string) asks.Act { + return asks.Act{"verb": Module + ".send", "option": option, "send": s.ID, "account": s.Account, + "to": strings.Join(s.To, ","), "subject": s.Subject, "body-digest": bodyDigest(s.Body), + "caller": s.Caller, "at": s.At.UTC().Format(time.RFC3339Nano)} +} + +// firstChars is at most n runes of s, marked when it was cut. +func firstChars(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[:n]) + " […cut]" +} + +// Ask is the ask for this request, composed from it alone: the account, the recipients, the subject and the first +// lines of the body (novox/hq ADR 0361 §2). +func (s SendRequest) Ask() asks.Ask { + to := strings.Join(s.To, ", ") + headline := fmt.Sprintf("Send mail from %s?", s.Account) + if len([]rune(headline)) > asks.HeadlineLength { + headline = "Send mail?" + } + explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, s.Subject) + if len([]rune(explanation)) > asks.SummaryLength { + explanation = fmt.Sprintf("From %s: %q.", s.Account, s.Subject) + } + 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, s.Subject, firstChars(s.Body, ShownBodyChars)) + a := asks.Ask{ID: s.ID, Headline: headline, Explanation: explanation, Details: details, + Who: asks.Operator, Expires: s.At.Add(SendAskLasts), OnExpiry: "nothing is sent"} + for _, o := range []struct{ id, label, does string }{ + {OptionSend, "Send", "the mail is sent through " + s.Account + "'s outgoing server"}, + {OptionDecline, "Decline", "nothing is sent"}, + } { + 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) +} + +// 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 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). +type SendInput struct { + Account Account + To []string + Subject string + Body string + Caller string +} + +// 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 +} + +// 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 _, 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 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()} + 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 := 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) +} + +// ---- deciding --------------------------------------------------------------------------------------------------- + +// AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked: +// the account's server may have changed since, and its password is read fresh), or found false. +type AccountLookup func(name string) (Account, bool) + +// SendActor performs the one send a chosen option authorises, resolving the account's own secret itself. It is +// called at most once per request (Decide claims the request first). +type SendActor func(a Account, to []string, subject, body string, now time.Time) error + +// Decide holds one closed ask's warrant to the request it answers (asks.Warrant.For, asks.Option.Performs), and — +// on Approve, exactly once — performs the send. A request already decided is never decided again: decide answers +// its kept decision at once, and two warrants for the same request (a redelivery, or two readers racing) have only +// the first claim the request by a compare-and-set write; the second finds it already claimed or decided and sends +// nothing. +func Decide(store SendStore, lookup AccountLookup, send SendActor, w asks.Warrant, now time.Time) (SendDecision, error) { + req, rev, ok, err := getSend(store, w.Ask) + if err != nil { + return SendDecision{}, err + } + if !ok { + return SendDecision{}, fmt.Errorf("no send %q is kept", w.Ask) + } + if req.Decided != nil { + return *req.Decided, nil + } + if w.Asker != Module { + return SendDecision{}, fmt.Errorf("the warrant is for %s's ask %s, not %s's", w.Asker, w.Ask, Module) + } + settle := func(d SendDecision) (SendDecision, error) { + next := req + next.Decided = &d + if _, err := store.Update(req.ID, next, rev); err != nil { + if errors.Is(err, stdio.ErrChanged) { + if again, _, ok2, err2 := getSend(store, req.ID); err2 == nil && ok2 && again.Decided != nil { + return *again.Decided, nil + } + } + return d, err + } + return d, nil + } + if w.Outcome != asks.OutcomeChosen { + outcome := "ended: " + string(w.Outcome) + if w.Words != "" { + outcome += " (" + w.Words + ")" + } + return settle(SendDecision{At: now, Outcome: outcome}) + } + ask := req.Ask() + option, err := w.For(Module, ask) + if err == nil { + err = option.Performs(req.Act(option.ID)) + } + if err != nil { + return settle(SendDecision{At: now, Outcome: "refused: " + err.Error()}) + } + switch option.ID { + case OptionDecline: + return settle(SendDecision{At: now, Outcome: "declined", Warrant: w.Says()}) + case OptionSend: + return decideSend(store, lookup, send, req, rev, w, now) + default: + return settle(SendDecision{At: now, Outcome: "refused: the option " + option.ID + " is not one mail_send offers"}) + } +} + +// decideSend claims the request by a compare-and-set write before it sends, so a redelivered warrant finds it +// already claimed (or decided) and acts on nothing: a person's Approve is performed once, never retried on a +// transient failure (the failure is kept in the decision, not hidden). +func decideSend(store SendStore, lookup AccountLookup, send SendActor, req SendRequest, rev uint64, w asks.Warrant, now time.Time) (SendDecision, error) { + claimed := req + claim := SendDecision{At: now, Outcome: "sending", Warrant: w.Says()} + claimed.Decided = &claim + newRev, err := store.Update(req.ID, claimed, 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, nil + } + } + return SendDecision{}, err + } + acc, found := lookup(req.Account) + var final SendDecision + switch { + case !found: + final = SendDecision{At: now, Outcome: "refused: the account no longer exists", Warrant: w.Says()} + default: + if sendErr := send(acc, req.To, req.Subject, req.Body, now); sendErr != nil { + final = SendDecision{At: now, Outcome: "failed: " + sendErr.Error(), Warrant: w.Says(), Error: sendErr.Error()} + } else { + final = SendDecision{At: now, Outcome: "sent", Warrant: w.Says()} + } + } + claimed.Decided = &final + if _, err := store.Update(req.ID, claimed, newRev); err != nil { + // The claim stands either way: a person's Approve is acted on once. The caller logs this; it is not retried. + return final, fmt.Errorf("the send %s was %s, and that could not be kept (%w)", req.ID, final.Outcome, err) + } + return final, nil +} + +// ---- reading the status ----------------------------------------------------------------------------------------- + +// ReadSendStatus is where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, +// declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own +// record; when the router's word is a close one that was not decided yet, this is also where a send is first +// decided (and so sent), exactly once (Decide). +func ReadSendStatus(store SendStore, bus AskBus, lookup AccountLookup, send SendActor, id string, now time.Time) (map[string]any, error) { + trimmed := strings.TrimSpace(id) + req, _, ok, err := getSend(store, trimmed) + if err != nil { + return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err) + } + if !ok { + return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed) + } + out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject, + "asked": req.At.Format(time.RFC3339)} + if req.Decided != nil { + reportDecision(out, *req.Decided) + 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 { + out["status"], out["words"] = "waiting", "not yet recorded by the router; nothing is sent until the operator answers" + return out, nil + } + var rec routerRecord + if json.Unmarshal(entry.Value, &rec) != nil { + out["status"], out["words"] = "not proven", "the router's record of it cannot be read" + return out, nil + } + if rec.State == "" || rec.State == "open" || rec.Warrant == nil { + out["status"], out["words"] = "waiting", "the operator has not answered yet" + return out, nil + } + d, err := Decide(store, lookup, send, *rec.Warrant, now) + if err != nil { + out["status"], out["words"] = "not proven", err.Error() + return out, nil + } + reportDecision(out, d) + 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. +func reportDecision(out map[string]any, d SendDecision) { + out["at"] = d.At.Format(time.RFC3339) + switch { + case d.Outcome == "sent": + out["status"], out["words"] = "sent", "sent on the operator's Approve ("+d.Warrant+")" + case d.Outcome == "declined": + out["status"], out["words"] = "declined", "nothing was sent: the operator declined ("+d.Warrant+")" + case d.Outcome == "sending": + out["status"], out["words"] = "waiting", "the operator's Approve is being acted on; ask again shortly" + case strings.HasPrefix(d.Outcome, "failed: "): + out["status"], out["words"] = "failed", "nothing reached the server: "+strings.TrimPrefix(d.Outcome, "failed: ") + case strings.HasPrefix(d.Outcome, "ended: "): + out["status"], out["words"] = "ended", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "ended: ") + case strings.HasPrefix(d.Outcome, "refused: "): + out["status"], out["words"] = "not proven", "nothing was sent: "+strings.TrimPrefix(d.Outcome, "refused: ") + default: + out["status"], out["words"] = "ended", d.Outcome + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go new file mode 100644 index 00000000..1793a5f2 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -0,0 +1,432 @@ +package main + +// mail_send (novox/hq ADR 0361 §2): an ungranted caller is refused before anything is asked; a granted caller's ask +// holds the account, the recipients, the subject and the first lines; the operator's Approve sends through an +// in-process fake SMTP server exactly once, even delivered twice; a Decline sends nothing. + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "net/smtp" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/asks" +) + +// ---- a fake `sends` state and a fake operator channel, as a test keeps them -------------------------------------- + +type memSends struct { + mu sync.Mutex + vals map[string]json.RawMessage + revs map[string]uint64 + n uint64 +} + +func newMemSends() *memSends { + return &memSends{vals: map[string]json.RawMessage{}, revs: map[string]uint64{}} +} + +func (m *memSends) Get(key string) (json.RawMessage, uint64, bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + v, ok := m.vals[key] + if !ok { + return nil, 0, false, nil + } + return v, m.revs[key], true, nil +} +func (m *memSends) Create(key string, value any) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if _, ok := m.vals[key]; ok { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} +func (m *memSends) Update(key string, value any, revision uint64) (uint64, error) { + m.mu.Lock() + defer m.mu.Unlock() + if revision == 0 || m.revs[key] != revision { + return 0, stdio.ErrChanged + } + return m.put(key, value) +} +func (m *memSends) put(key string, value any) (uint64, error) { + raw, err := json.Marshal(value) + if err != nil { + return 0, err + } + m.n++ + m.vals[key], m.revs[key] = raw, m.n + return m.n, nil +} + +// sendRouter is the operator channel's holder as this module reaches it, kept by a test: what was asked, and the +// router's own record of each ask. +type sendRouter struct { + mu sync.Mutex + published map[string]asks.Ask + records map[string]json.RawMessage + publishErr error +} + +func newSendRouter() *sendRouter { + return &sendRouter{published: map[string]asks.Ask{}, records: map[string]json.RawMessage{}} +} + +func (r *sendRouter) Publish(subject string, body any, id string) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.publishErr != nil { + return r.publishErr + } + raw, _ := json.Marshal(body) + var a asks.Ask + _ = json.Unmarshal(raw, &a) + r.published[a.ID] = a + r.records[a.ID], _ = json.Marshal(map[string]any{"state": "open"}) + return nil +} +func (r *sendRouter) Record(id string) (*stdio.StateEntry, error) { + r.mu.Lock() + defer r.mu.Unlock() + raw, ok := r.records[id] + if !ok { + return nil, nil + } + return &stdio.StateEntry{Key: id, Value: raw}, nil +} + +// close ends an ask with a warrant, as the router records it. +func (r *sendRouter) close(w asks.Warrant) { + r.mu.Lock() + defer r.mu.Unlock() + r.records[w.Ask], _ = json.Marshal(map[string]any{"state": string(w.Outcome), "warrant": w}) +} + +// chose is the warrant for a person's choice of an option of the ask as the router took it. +func (r *sendRouter) chose(id, option string, at time.Time) asks.Warrant { + r.mu.Lock() + a := r.published[id] + r.mu.Unlock() + o, _ := a.Option(option) + return asks.Warrant{Ask: id, Asker: Module, Outcome: asks.OutcomeChosen, Option: o.ID, Label: o.Label, Level: o.Level, + By: &asks.Person{Who: asks.Operator, Kind: "telegram", Identity: "42", Verified: "user id verified"}, + Channel: "telegram", Proofs: []string{"P1"}, At: at, AskDigest: a.Digest()} +} + +func (r *sendRouter) published1() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.published) +} + +// ---- an in-process fake SMTP server, speaking the protocol over a real socket ------------------------------------- + +type receivedMail struct { + from string + to []string + data string +} + +type fakeSMTPServer struct { + ln net.Listener + + mu sync.Mutex + received []receivedMail + refuse bool // when true, DATA is refused: a transient server error +} + +func newFakeSMTPServer(t *testing.T) *fakeSMTPServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + f := &fakeSMTPServer{ln: ln} + go f.serve() + t.Cleanup(func() { _ = ln.Close() }) + return f +} + +func (f *fakeSMTPServer) addr() string { return f.ln.Addr().String() } + +// dialer reaches the fake server directly, plain: only a test skips the TLS the mesh's own dialer always uses. +func (f *fakeSMTPServer) dialer() SMTPDialer { + addr := f.addr() + return func(Server) (*smtp.Client, error) { return smtp.Dial(addr) } +} + +func (f *fakeSMTPServer) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.received) +} + +func (f *fakeSMTPServer) last() receivedMail { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.received) == 0 { + return receivedMail{} + } + return f.received[len(f.received)-1] +} + +func (f *fakeSMTPServer) serve() { + for { + conn, err := f.ln.Accept() + if err != nil { + return + } + go f.handle(conn) + } +} + +func (f *fakeSMTPServer) handle(conn net.Conn) { + defer conn.Close() + r := bufio.NewReader(conn) + reply := func(code int, msg string) { fmt.Fprintf(conn, "%d %s\r\n", code, msg) } + reply(220, "fake.local ESMTP") + var from string + var to []string + for { + line, err := r.ReadString('\n') + if err != nil { + return + } + line = strings.TrimRight(line, "\r\n") + upper := strings.ToUpper(line) + switch { + case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"): + reply(250, "fake.local") + case strings.HasPrefix(upper, "AUTH"): + reply(235, "authenticated") + case strings.HasPrefix(upper, "MAIL FROM:"): + from = addrIn(line) + reply(250, "OK") + case strings.HasPrefix(upper, "RCPT TO:"): + to = append(to, addrIn(line)) + reply(250, "OK") + case upper == "DATA": + f.mu.Lock() + refuse := f.refuse + f.mu.Unlock() + if refuse { + reply(451, "temporary failure") + continue + } + reply(354, "End data with .") + var b strings.Builder + for { + dl, err := r.ReadString('\n') + if err != nil { + return + } + if dl == ".\r\n" || dl == ".\n" { + break + } + if strings.HasPrefix(dl, "..") { + dl = dl[1:] + } + b.WriteString(dl) + } + f.mu.Lock() + f.received = append(f.received, receivedMail{from: from, to: append([]string{}, to...), data: b.String()}) + f.mu.Unlock() + reply(250, "OK: queued") + from, to = "", nil + case upper == "RSET": + reply(250, "OK") + from, to = "", nil + case upper == "QUIT": + reply(221, "bye") + return + default: + reply(500, "unrecognized command") + } + } +} + +func addrIn(line string) string { + start, end := strings.Index(line, "<"), strings.Index(line, ">") + if start >= 0 && end > start { + return line[start+1 : end] + } + return line +} + +// ---- a bundle over an account with an outgoing server --------------------------------------------------------- + +// sendTestBundle is a bundle over one account, "mesh", granted to "triage" alone, with an outgoing server of the +// caller's choosing (a fake SMTP server's, in most tests) and its password already given. +func sendTestBundle(t *testing.T, outgoing Server) (*Bundle, *sendRouter, *memSends) { + t.Helper() + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir} + writeFile(t, env.Accounts, fmt.Sprintf(`{"accounts": {"mesh": {"kind": "imap", "address": "jschoubben@novox.be", + "incoming": {"host": "mail.novox.be", "port": 993, "security": "tls"}, + "outgoing": {"host": %q, "port": %d, "security": %q}}}}`, outgoing.Host, outgoing.Port, outgoing.Security)) + writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`) + giveSecret(t, dir, Account{Name: "mesh"}, "the-password") + router, sends := newSendRouter(), newMemSends() + b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: sends} + return b, router, sends +} + +var sendArgs = map[string]any{"account": "mesh", "to": []any{"ops@example.org", "lead@example.org"}, + "subject": "The nightly gate is red", "body": "The merge gate failed on the resolver change.\nSee the job log."} + +// (1) An ungranted caller is refused, and no ask is raised. +func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.other", sendArgs) + if !isErr || !strings.Contains(text, "refused") || !strings.Contains(text, "mesh") { + t.Fatalf("%v %s", isErr, text) + } + if n := router.published1(); n != 0 { + t.Fatalf("an ask was raised for an ungranted caller: %d", n) + } +} + +// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first lines. +func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + if json.Unmarshal([]byte(text), &out) != nil { + t.Fatalf("not JSON: %s", text) + } + id, _ := out["ask"].(string) + if id == "" || out["status"] != "waiting" { + t.Fatalf("%s", text) + } + if n := router.published1(); n != 1 { + t.Fatalf("published %d asks, not 1", n) + } + a := router.published[id] + if err := a.Check(judgedAt); err != nil { + t.Errorf("the ask does not pass the asks contract: %v", err) + } + whole := a.Headline + "\n" + a.Explanation + "\n" + a.Details + for _, want := range []string{"mesh", "ops@example.org", "lead@example.org", "The nightly gate is red", "The merge gate failed on the resolver change"} { + if !strings.Contains(whole, want) { + t.Errorf("the ask does not say %q:\n%s", want, whole) + } + } + for _, o := range a.Options { + if o.Level != asks.Approve || o.Binds == "" { + t.Errorf("the option %s is %s binding %q: both answers are an approval bound to this send", o.ID, o.Level, o.Binds) + } + } +} + +// (3) On Approve, the fake SMTP server receives exactly one message, even if the warrant is delivered twice. +func TestOnApproveTheFakeSMTPServerReceivesExactlyOneMessageEvenDeliveredTwice(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionSend, judgedAt.Add(time.Minute)) + router.close(w) + + lookup := b.lookupAccount + actor := b.sendActor() + if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(2*time.Minute)); err != nil { + t.Fatalf("deciding: %v", err) + } + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after one Approve", n) + } + // The warrant delivered a second time: still exactly one message. + if _, err := Decide(sends, lookup, actor, w, judgedAt.Add(3*time.Minute)); err != nil { + t.Fatalf("deciding again: %v", err) + } + if n := fake.count(); n != 1 { + t.Fatalf("the fake server received %d message(s) after the warrant was delivered twice", n) + } + got := fake.last() + if got.from != "jschoubben@novox.be" || len(got.to) != 2 || got.to[0] != "ops@example.org" || got.to[1] != "lead@example.org" { + t.Fatalf("received %+v", got) + } + if !strings.Contains(got.data, "The nightly gate is red") || !strings.Contains(got.data, "The merge gate failed on the resolver change") { + t.Fatalf("the message sent: %q", got.data) + } + + // mail_send_status reports it sent, and reports the same thing again without sending a third time. + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr || !strings.Contains(text, `"status":"sent"`) { + t.Fatalf("%v %s", isErr, text) + } + if n := fake.count(); n != 1 { + t.Fatalf("mail_send_status sent again: %d", n) + } +} + +// (4) On Decline, nothing is sent, and the status says declined. +func TestOnDeclineNothingIsSentAndTheStatusSaysDeclined(t *testing.T) { + fake := newFakeSMTPServer(t) + b, router, sends := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + b.Dial = fake.dialer() + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if isErr { + t.Fatalf("%s", text) + } + var out map[string]any + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + w := router.chose(id, OptionDecline, judgedAt.Add(time.Minute)) + router.close(w) + if _, err := Decide(sends, b.lookupAccount, b.sendActor(), w, judgedAt.Add(2*time.Minute)); err != nil { + t.Fatalf("deciding: %v", err) + } + if n := fake.count(); n != 0 { + t.Fatalf("the fake server received %d message(s) on Decline", n) + } + + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr || !strings.Contains(text, `"status":"declined"`) { + t.Fatalf("%v %s", isErr, text) + } +} + +// An account with no outgoing server is refused before anything is asked. +func TestMailSendRefusesAnAccountWithNoOutgoingServer(t *testing.T) { + dir := t.TempDir() + env := Env{Accounts: filepath.Join(dir, "accounts.json"), Grants: filepath.Join(dir, "grants.json"), Secrets: dir} + writeFile(t, env.Accounts, stageOne) // no outgoing server + writeFile(t, env.Grants, `{"grants": {"mesh": ["triage"]}}`) + router := newSendRouter() + b := &Bundle{Env: env, Now: func() time.Time { return judgedAt }, Asks: router, Sends: newMemSends()} + rt := startBundle(t, b.Tools()) + text, isErr := rt.call(SendTool, "novox.triage", sendArgs) + if !isErr || !strings.Contains(text, "no outgoing server") { + t.Fatalf("%v %s", isErr, text) + } + if n := router.published1(); n != 0 { + t.Fatalf("an ask was raised for an account with no outgoing server: %d", n) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/smtp.go b/modules/mesh-mail/cmd/mesh-mail/smtp.go new file mode 100644 index 00000000..7d797302 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/smtp.go @@ -0,0 +1,125 @@ +package main + +// How mail_send sends, once the operator approved it (novox/hq ADR 0361 §1): through the account's outgoing +// server, implicit TLS from the first byte on 465 or upgraded with STARTTLS on 587, as the account's outgoing +// `security` says — the same two the module already reads for an account's incoming server. The password is the +// own secret mail-credential-, the one IMAP already signs in with; it is read once, used once, and never +// logged, and neither is the body. + +import ( + "crypto/tls" + "fmt" + "mime" + "net/smtp" + "strings" + "time" + "unicode" +) + +// SMTPDialer opens a connection to an outgoing server, signed in over nothing: implicit TLS on the first byte, or +// plain and then upgraded with STARTTLS, as the server's security says. A test gives a fake dialing a server of its +// own; DialSMTP is the mesh's. +type SMTPDialer func(server Server) (*smtp.Client, error) + +// DialSMTP is the dialer for the mesh (novox/hq ADR 0361 §1). +func DialSMTP(server Server) (*smtp.Client, error) { + switch server.Security { + case "tls": + conn, err := tls.Dial("tcp", server.Address(), &tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12}) + if err != nil { + return nil, fmt.Errorf("the outgoing server %s cannot be reached over TLS: %w", server.Address(), err) + } + c, err := smtp.NewClient(conn, server.Host) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("the outgoing server %s did not answer as SMTP: %w", server.Address(), err) + } + return c, nil + case "starttls": + c, err := smtp.Dial(server.Address()) + if err != nil { + return nil, fmt.Errorf("the outgoing server %s cannot be reached: %w", server.Address(), err) + } + if ok, _ := c.Extension("STARTTLS"); !ok { + _ = c.Close() + return nil, fmt.Errorf("the outgoing server %s does not offer STARTTLS", server.Address()) + } + if err := c.StartTLS(&tls.Config{ServerName: server.Host, MinVersion: tls.VersionTLS12}); err != nil { + _ = c.Close() + return nil, fmt.Errorf("STARTTLS to %s failed: %w", server.Address(), err) + } + return c, nil + default: + return nil, fmt.Errorf("the outgoing security %q sends over neither tls nor starttls", server.Security) + } +} + +// SendMail sends one message through the account's outgoing server (novox/hq ADR 0361 §1, §2): signs in with the +// password given, states every recipient, and writes the message once. Never logs the password or the body; an +// error names the server and what it said, never either. +func SendMail(dial SMTPDialer, a Account, password string, to []string, subject, body string, now time.Time) error { + if a.Outgoing == nil { + return fmt.Errorf("the account %s has no outgoing server", a.Name) + } + client, err := dial(*a.Outgoing) + if err != nil { + return err + } + defer client.Close() + from := a.SignInAs() + auth := smtp.PlainAuth("", from, password, a.Outgoing.Host) + if err := client.Auth(auth); err != nil { + return fmt.Errorf("signing in to %s failed: %w", a.Outgoing.Address(), err) + } + if err := client.Mail(from); err != nil { + return fmt.Errorf("the outgoing server refused the sender: %w", err) + } + for _, rcpt := range to { + if err := client.Rcpt(rcpt); err != nil { + return fmt.Errorf("the outgoing server refused the recipient %s: %w", rcpt, err) + } + } + w, err := client.Data() + if err != nil { + return fmt.Errorf("the outgoing server refused the message: %w", err) + } + if _, err := w.Write(composeMessage(from, to, subject, body, now)); err != nil { + return fmt.Errorf("the message could not be written: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("the outgoing server did not accept the message: %w", err) + } + return client.Quit() +} + +// composeMessage is a plain-text message, its lines CRLF-terminated as SMTP takes them. +func composeMessage(from string, to []string, subject, body string, now time.Time) []byte { + var b strings.Builder + header := func(k, v string) { b.WriteString(k + ": " + v + "\r\n") } + header("From", from) + header("To", strings.Join(to, ", ")) + header("Subject", encodeHeader(subject)) + header("Date", now.Format(time.RFC1123Z)) + header("MIME-Version", "1.0") + header("Content-Type", "text/plain; charset=utf-8") + header("Content-Transfer-Encoding", "8bit") + b.WriteString("\r\n") + b.WriteString(crlf(body)) + return []byte(b.String()) +} + +// encodeHeader word-encodes a header's value when it holds more than ASCII; a header a phone never needs to show. +func encodeHeader(s string) string { + for _, r := range s { + if r > unicode.MaxASCII { + return mime.QEncoding.Encode("UTF-8", s) + } + } + return s +} + +// crlf gives every line break in s a CR, once, the ending SMTP's DATA takes. +func crlf(s string) string { + s = strings.ReplaceAll(s, "\r\n", "\n") + return strings.ReplaceAll(s, "\n", "\r\n") +} diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index f1d57a09..9d8345fd 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -1,8 +1,10 @@ package main -// The module's tools (novox/hq ADR 0359 §5): mail_accounts, mail_search, mail_read and mail_health. Who calls is the -// runtime's word (stdio.CallerOf), never an argument; a caller reads an account's mail — its search, a body, an -// attachment — only when the trusted setting `grants` names it for that account (§4). Nothing here writes to a mailbox. +// The module's tools (novox/hq ADR 0359 §5, ADR 0361 §2): mail_accounts, mail_search, mail_read, mail_health, +// mail_send and mail_send_status. Who calls is the runtime's word (stdio.CallerOf), never an argument; a caller +// reads an account's mail, or sends from it, only when the trusted setting `grants` names it for that account (§4). +// Nothing here writes to a mailbox; mail_send writes nothing itself either (send.go) — it only ever reaches the +// account's outgoing server on the operator's own Approve. import ( "context" @@ -17,9 +19,11 @@ import ( // Tool names, as the manifest declares them. const ( - AccountsTool = "mail_accounts" - SearchTool = "mail_search" - ReadTool = "mail_read" + AccountsTool = "mail_accounts" + SearchTool = "mail_search" + ReadTool = "mail_read" + SendTool = "mail_send" + SendStatusTool = "mail_send_status" ) // Bounds of what a tool answers. @@ -30,13 +34,20 @@ const ( MostAttachment = 8 << 20 ) -// Bundle is what the tools reach: the settings, the index and the attachment store. +// Bundle is what the tools reach: the settings, the index, the attachment store, and — for mail_send — the +// operator channel, the `sends` state and the outgoing dialer. The last three are nil outside a test: the real +// bus and dialer (sdkAskBus, keptSends, DialSMTP) are used lazily, so building a Bundle for the read-only tools +// never needs them. type Bundle struct { Env Env Index func(ctx context.Context) (Store, error) Objects func() (Objects, error) Caller func(args map[string]any) string Now func() time.Time + + Asks AskBus + Sends SendStore + Dial SMTPDialer } func (b *Bundle) now() time.Time { @@ -120,6 +131,27 @@ func (b *Bundle) Tools() []stdio.Tool { "waits, naming the account's password, while the operator has not given it at the desk.", Input: schema(nil, map[string]any{}), Run: func(map[string]any) (any, error) { return ReadStatus(b.Env.Status, b.now()), nil }}, + {Name: SendTool, + Description: "Send mail from an account you are granted (the same check as mail_read, novox/hq ADR 0361 §2). It " + + "sends nothing itself: it raises an ask on the operator channel — the account, the recipients, the subject and " + + "the first lines of the body — and answers at once with the ask's id. The mail reaches the account's outgoing " + + "server only once the operator's Approve comes back on a channel that proves who answered, and once; on " + + "Decline, or no answer before it expires, nothing is sent. Refused for an account you are not granted, or one " + + "with no outgoing server. mail_send_status, with this id, says where it stands.", + Input: schema([]string{"account", "to", "subject", "body"}, map[string]any{ + "account": text("the account to send from, by its name in mail_accounts"), + "to": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, + "description": "the recipients' mail addresses, at most 20"}, + "subject": text("the message's subject, at most 200 characters"), + "body": text("the message's plain-text body"), + }), + Run: b.sendTool}, + {Name: SendStatusTool, + Description: "Where a send asked with mail_send stands, by its ask id: waiting for the operator, sent, declined, " + + "ended without a choice (expired, refused, cancelled), or not proven. Read from the router's own record, held " + + "to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here).", + Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}), + Run: b.sendStatusTool}, } } @@ -304,6 +336,112 @@ func refused(caller, account string) error { return fmt.Errorf("refused: %s is not granted the mail account %s; the operator grants it through the setting grants", caller, account) } +// ---- mail_send, mail_send_status --------------------------------------------------------------------------------- + +// askBus is the operator channel this bundle reaches: the bus's own, unless a test gave one. +func (b *Bundle) askBus() AskBus { + if b.Asks != nil { + return b.Asks + } + return sdkAskBus{} +} + +// sends is the `sends` state this bundle keeps: the bus's own, unless a test gave one. +func (b *Bundle) sends() SendStore { + if b.Sends != nil { + return b.Sends + } + return keptSends{stdio.State(SendsState)} +} + +// lookupAccount resolves an account by name as the settings currently hold it — read fresh at decide time, never +// the account as it stood when it was asked. +func (b *Bundle) lookupAccount(name string) (Account, bool) { + accounts, err := ReadAccounts(b.Env.Accounts) + if err != nil { + return Account{}, false + } + return accountByName(accounts, name) +} + +func accountByName(accounts []Account, name string) (Account, bool) { + for _, a := range accounts { + if a.Name == name { + return a, true + } + } + return Account{}, false +} + +// sendActor performs one send on the operator's Approve: it reads the account's own secret itself, so Decide and +// the `sends` state never hold a password. +func (b *Bundle) sendActor() SendActor { + dial := b.Dial + if dial == nil { + dial = DialSMTP + } + return func(a Account, to []string, subject, body string, now time.Time) error { + pw, given, err := ReadSecret(b.Env.Secrets, a) + if err != nil { + return fmt.Errorf("the account %s's password cannot be read: %w", a.Name, err) + } + if !given { + return fmt.Errorf("the account %s has no password yet; the operator gives it at the desk", a.Name) + } + return SendMail(dial, a, pw, to, subject, body, now) + } +} + +func stringList(v any) []string { + list, _ := v.([]any) + out := make([]string, 0, len(list)) + for _, item := range list { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out +} + +func (b *Bundle) sendTool(args map[string]any) (any, error) { + if err := only(args, "account", "to", "subject", "body"); err != nil { + return nil, err + } + account, _ := args["account"].(string) + subject, _ := args["subject"].(string) + body, _ := args["body"].(string) + to := stringList(args["to"]) + if account == "" { + return nil, fmt.Errorf("%s needs account, by its name in mail_accounts", SendTool) + } + accounts, grants, err := b.settings() + if err != nil { + return nil, err + } + caller := b.caller(args) + // The same grants check as mail_read (novox/hq ADR 0359 §4, ADR 0361 §2): an ungranted caller is refused, and + // nothing is asked. + if !grants.Allows(caller, account) { + return nil, grants.refuse(caller, account) + } + acc, found := accountByName(accounts, account) + if !found { + return nil, fmt.Errorf("no account %q is held; mail_accounts lists the accounts", account) + } + return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller}, b.sends(), b.askBus(), b.now()) +} + +func (b *Bundle) sendStatusTool(args map[string]any) (any, error) { + if err := only(args, "id"); err != nil { + return nil, err + } + id, _ := args["id"].(string) + if id == "" { + return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool) + } + return ReadSendStatus(b.sends(), b.askBus(), b.lookupAccount, b.sendActor(), id, b.now()) +} + // ---- arguments ---------------------------------------------------------------------------------------------------- // only refuses an argument the tool does not take: never ignored, and none can say who calls. diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index 8a40df5d..0d6e32b1 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -29,7 +29,15 @@ "mail_accounts", "mail_search", "mail_read", - "mail_health" + "mail_health", + "mail_send", + "mail_send_status" + ], + "uses": [ + "operator-channel" + ], + "state": [ + "sends" ], "streams": [ {