mesh-mail: add mail_send, asked and sent once on the operator's Approve (issue 790)

This commit is contained in:
jochen
2026-10-15 13:57:47 +02:00
parent 0914a33fba
commit 13cd710071
10 changed files with 1185 additions and 25 deletions
+14 -7
View File
@@ -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), `<machine>.<module>`, or
`person.<name>`. 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),
`<machine>.<module>`, or `person.<name>`. 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-<account>`, given at the desk; the mesh never makes one.
@@ -49,8 +52,12 @@ The password of an account is the own secret `mail-credential-<account>`, 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-<account>` 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
@@ -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"}},
+9 -4
View File
@@ -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")
+5 -3
View File
@@ -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
@@ -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
+443
View File
@@ -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
}
}
@@ -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 <CR><LF>.<CR><LF>")
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)
}
}
+125
View File
@@ -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-<account>, 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")
}
+145 -7
View File
@@ -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.
+9 -1
View File
@@ -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": [
{