444 lines
18 KiB
Go
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
|
|
}
|
|
}
|