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

444 lines
18 KiB
Go

package main
// mail_send (novox/hq ADR 0361 §2): the tool that replaces the no-send rule of ADR 0359 §5, for sending alone. It
// sends nothing itself. A caller granted the account (the same check as mail_read, ADR 0359 §4) asks for one send;
// this raises an ask on the operator channel (novox/hq ADR 0259) showing the account, the recipients, the subject
// and the first lines of the body, and the tool returns at once with the ask's id. The mail reaches the account's
// outgoing server only on the operator's Approve, on a channel that proves who answered, and once: the id is kept
// with the decision it was given, so a warrant delivered twice is acted on once (asks.Warrant.For and
// asks.Option.Performs hold the decision to exactly the request that was asked). A Decline, or no answer before it
// expires, sends nothing, which mail_send_status says. The password is read only to sign in, and the body only to
// send; neither is ever logged.
//
// The pattern — publish an ask under this module's own name, compose it again from what was kept to hold the
// router's warrant, act once on a compare-and-set claim — follows mesh-knowledge's own approval of a record
// (cmd/mesh-knowledge/operator.go) and claude-code's proposals and questions (cmd/claude-code/proposals.go,
// questions.go).
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/mail"
"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
}
}