diff --git a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go index c4031ed9..5673e945 100644 --- a/modules/mesh-mail/cmd/mesh-mail/manifest_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/manifest_test.go @@ -94,7 +94,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { } wantTools := []string{ "mail_accounts", "mail_search", "mail_read", "mail_health", "mail_send", "mail_send_status", - "mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", + "mail-store.accounts", "mail-store.search", "mail-store.read", "mail-store.send", "mail-store.send-status", } if !reflect.DeepEqual(m.Tools, names) || !reflect.DeepEqual(names, wantTools) { t.Fatalf("tools %v, the code serves %v", m.Tools, names) @@ -140,7 +140,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { // mail-webclient each held once for the mesh, mail-notifier held once per machine and needing node-notifier // there — and claims only mail-store, serving the same four verbs as its own tools (§2, §3). wantSeats := []seat{ - {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}}, {Name: "mail-webclient", Scope: "mesh", Serves: []string{"open", "status"}}, {Name: "mail-notifier", Scope: "node", Serves: []string{"status"}, Needs: []string{"node-notifier"}}, } @@ -148,7 +148,7 @@ func TestTheManifestSaysWhatTheCodeDoes(t *testing.T) { t.Fatalf("seats %+v, want %+v", m.Seats, wantSeats) } wantClaims := []seat{ - {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send"}}, + {Name: "mail-store", Scope: "mesh", Serves: []string{"accounts", "search", "read", "send", "send-status"}}, } if !reflect.DeepEqual(m.Claims, wantClaims) { t.Fatalf("claims %+v, want %+v", m.Claims, wantClaims) diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go index 66d130d3..6dbbb3fe 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send.go +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -1312,19 +1312,30 @@ func tellWaits(store SendStore, tell TellOperator, id string, now time.Time) err // ---- 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, publish QueuePublish, id string, now time.Time) (map[string]any, error) { +// ReadSendStatus is where a send asked with mail_send stands, answered only to the caller that asked for it +// (novox/hq ADR 0440 §7, issue 1261): an id no send is kept under, or a send whose own caller differs from the +// one asking, answers status "unknown" and nothing else of it — never an error, and never the account, +// recipients or subject of a send that is not the caller's own. The sweep, which settles a send regardless of +// who asked for it, calls readSendStatus directly instead, never through this caller check. +func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id, caller string, now time.Time) (map[string]any, error) { trimmed := strings.TrimSpace(id) req, rev, ok, err := getSend(store, trimmed) if err != nil { return nil, fmt.Errorf("the send %q cannot be read now (%v); ask again", trimmed, err) } - if !ok { - return nil, fmt.Errorf("no send %q is kept: the id is the one mail_send gave", trimmed) + if !ok || req.Caller != caller { + return map[string]any{"status": "unknown"}, nil } + return readSendStatus(store, bus, publish, req, rev, now) +} + +// readSendStatus is ReadSendStatus's own reading of one send already fetched (req, rev): waiting for the +// operator, sent, declined, ended without a choice (expired, refused, cancelled), or not proven. Read from the +// router's own record; when the router's word is a close one that was not decided yet, this is also where a +// send is first decided (and so sent), exactly once (Decide). It never checks who is asking: ReadSendStatus +// does that before calling it, and the sweep (which settles a send no matter who asked for it) calls it +// directly. +func readSendStatus(store SendStore, bus AskBus, publish QueuePublish, req SendRequest, rev uint64, now time.Time) (map[string]any, error) { out := map[string]any{"ask": req.ID, "account": req.Account, "to": req.To, "subject": req.Subject, "asked": req.At.Format(time.RFC3339)} if req.Decided != nil { @@ -1342,7 +1353,7 @@ func ReadSendStatus(store SendStore, bus AskBus, publish QueuePublish, id string if now.Sub(req.At) > SendAskLasts { d, _, err := settleSend(store, req, rev, SendDecision{At: now, Outcome: "ended: never heard"}) if err != nil { - return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", trimmed, err) + return nil, fmt.Errorf("the send %q could not be settled as ended (%v); ask again", req.ID, err) } reportDecision(out, d, now) return out, nil @@ -1448,11 +1459,13 @@ func (b *Bundle) Sweep(now time.Time) { } } else { for _, id := range undecided { - req, _, ok, err := getSend(store, id) + req, rev, ok, err := getSend(store, id) if err != nil || !ok || req.Decided != nil { continue } - if _, err := ReadSendStatus(store, b.askBus(), b.queuePublish(), id, now); err != nil { + // 1261/AC6: the sweep settles a send regardless of who asked for it — readSendStatus directly, + // never ReadSendStatus's caller check. + if _, err := readSendStatus(store, b.askBus(), b.queuePublish(), req, rev, now); err != nil { if b.Logf != nil { b.Logf("[mesh-mail] sweeping the send %s: %v", id, err) } diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go index 36939403..ea27bbc4 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -861,3 +861,163 @@ func TestADigestMismatchIsRefusedAndNothingIsQueued(t *testing.T) { t.Fatalf("the queue received %d message(s) despite the digest mismatch", n) } } + +// ---- mail_send_status answers only its caller (novox/hq ADR 0440 §7, issue 1261) --------------------------------- + +// 1261/AC1: the caller that made a send reads its own status in full, with its account, recipients and subject +// as before. +func TestSendStatusAnswersTheCallersOwnSendInFull(t *testing.T) { + b, _, _ := 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 + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + text, isErr = rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + if isErr { + t.Fatalf("%s", text) + } + var status map[string]any + if err := json.Unmarshal([]byte(text), &status); err != nil { + t.Fatal(err) + } + if status["status"] != "waiting" || status["account"] != "mesh" || status["subject"] != sendArgs["subject"] { + t.Fatalf("the caller's own send: %v", status) + } + to, _ := status["to"].([]any) + if len(to) != 2 { + t.Fatalf("to: %v", status["to"]) + } +} + +// 1261/AC2: a caller other than the one that made the send reads status unknown and nothing else of it — no +// account, recipients or subject, and no error either. +func TestSendStatusAnswersUnknownAndNothingElseToAnotherCaller(t *testing.T) { + b, _, _ := 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 + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + text, isErr = rt.call(SendStatusTool, "novox.someone-else", map[string]any{"id": id}) + if isErr { + t.Fatalf("another caller's read of the send was an error: %s", text) + } + var status map[string]any + if err := json.Unmarshal([]byte(text), &status); err != nil { + t.Fatal(err) + } + if len(status) != 1 || status["status"] != "unknown" { + t.Fatalf("another caller's read of the send: %v, want only status unknown", status) + } +} + +// 1261/AC3: an id no send is kept under answers status unknown and no error, for any caller. +func TestSendStatusAnswersUnknownForAnUnknownIDWithoutAnError(t *testing.T) { + b, _, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + rt := startBundle(t, b.Tools()) + + text, isErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": "send-0000000000000000"}) + if isErr { + t.Fatalf("an unknown id was an error: %s", text) + } + var status map[string]any + if err := json.Unmarshal([]byte(text), &status); err != nil { + t.Fatal(err) + } + if len(status) != 1 || status["status"] != "unknown" { + t.Fatalf("%v", status) + } +} + +// 1261/AC4: every kept decision's status word is one of the fixed vocabulary (waiting, sent, failed, refused, +// not proven, declined, expired), answered to the send's own caller. +func TestSendStatusWordsAreTheFixedVocabularyForTheCallersOwnSend(t *testing.T) { + sends := newMemSends() + const caller = "novox.triage" + cases := []struct { + name, outcome, want string + }{ + {"queued", "queued", "waiting"}, + {"sending", "sending", "waiting"}, + {"retrying", "retrying", "waiting"}, + {"sent", "sent", "sent"}, + {"declined", "declined", "declined"}, + {"not-proven", "not proven", "not proven"}, + {"failed", "failed: boom", "failed"}, + {"refused", "refused: nope", "refused"}, + {"ended-never-heard", "ended: never heard", "expired"}, + {"ended-other", "ended: cancelled", "expired"}, + } + for _, c := range cases { + id := "send-" + c.name + req := SendRequest{ID: id, Account: "mesh", To: []string{"ops@example.org"}, Subject: "s", Caller: caller, + At: judgedAt, Decided: &SendDecision{At: judgedAt, Outcome: c.outcome}} + if _, err := sends.Create(id, req); err != nil { + t.Fatalf("%s: keeping: %v", c.name, err) + } + out, err := ReadSendStatus(sends, nil, nil, id, caller, judgedAt) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + if out["status"] != c.want { + t.Fatalf("%s: status %v, want %v", c.name, out["status"], c.want) + } + } +} + +// 1261/AC5: the seat verb mail-store.send-status answers exactly as mail_send_status, for the same caller. +func TestSeatSendStatusAnswersExactlyAsMailSendStatus(t *testing.T) { + b, _, _ := 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 + _ = json.Unmarshal([]byte(text), &out) + id := out["ask"].(string) + + toolText, toolErr := rt.call(SendStatusTool, "novox.triage", map[string]any{"id": id}) + seatText, seatErr := rt.call(SeatSendStatusTool, "novox.triage", map[string]any{"id": id}) + if toolErr != seatErr || toolText != seatText { + t.Fatalf("mail_send_status: %v %s; mail-store.send-status: %v %s", toolErr, toolText, seatErr, seatText) + } +} + +// 1261/AC6: the sweep settles a send left undecided no matter who asked for it — never refused by the caller +// check mail_send_status itself applies. +func TestSweepSettlesASendRegardlessOfCaller(t *testing.T) { + b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) + queue := newMemQueue() + b.Queue = queue.publish + 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) // only the router's record holds the answer + + // The sweep itself names no caller at all; it must still settle "novox.triage"'s send. + b.Sweep(judgedAt.Add(2 * time.Minute)) + if n := queue.count(); n != 1 { + t.Fatalf("the sweep did not settle the send regardless of caller: %d message(s)", n) + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index 14c9e9d3..c03e74c9 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -30,10 +30,11 @@ const ( // its verbs through the same handlers as the mail_* tools above — the seat's name is the one a caller grants // reach the same account's mail through, whichever name it calls. const ( - SeatAccountsTool = "mail-store.accounts" - SeatSearchTool = "mail-store.search" - SeatReadTool = "mail-store.read" - SeatSendTool = "mail-store.send" + SeatAccountsTool = "mail-store.accounts" + SeatSearchTool = "mail-store.search" + SeatReadTool = "mail-store.read" + SeatSendTool = "mail-store.send" + SeatSendStatusTool = "mail-store.send-status" ) // Bounds of what a tool answers. @@ -212,7 +213,9 @@ func (b *Bundle) Tools() []stdio.Tool { {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).", + "to the request mail_send asked (so a warrant for another request, or one changed since, sends nothing here). " + + "Answered only to the caller that asked for the send: an id this caller did not ask for, known or not, " + + "answers status unknown and nothing else of it (novox/hq ADR 0440 §7, issue 1261).", Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}), Run: b.sendStatusTool}, {Name: SeatAccountsTool, @@ -242,6 +245,10 @@ func (b *Bundle) Tools() []stdio.Tool { "body": text("the message's plain-text body"), }), Run: b.sendTool}, + {Name: SeatSendStatusTool, + Description: "The mail-store seat's send-status verb (novox/hq ADR 0378 §2, issue 1261): the same as mail_send_status.", + Input: schema([]string{"id"}, map[string]any{"id": text("the ask id mail_send gave")}), + Run: b.sendStatusTool}, } } @@ -612,7 +619,8 @@ func (b *Bundle) sendStatusTool(args map[string]any) (any, error) { if id == "" { return nil, fmt.Errorf("%s needs id, the ask %s gave", SendStatusTool, SendTool) } - return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, b.now()) + caller := b.caller(args) + return ReadSendStatus(b.sends(), b.askBus(), b.queuePublish(), id, caller, b.now()) } // ---- arguments ---------------------------------------------------------------------------------------------------- diff --git a/modules/mesh-mail/module.json b/modules/mesh-mail/module.json index b6aaa7ce..d1399727 100644 --- a/modules/mesh-mail/module.json +++ b/modules/mesh-mail/module.json @@ -6,7 +6,7 @@ { "name": "mail-store", "scope": "mesh", - "serves": ["accounts", "search", "read", "send"] + "serves": ["accounts", "search", "read", "send", "send-status"] }, { "name": "mail-webclient", @@ -24,7 +24,7 @@ { "name": "mail-store", "scope": "mesh", - "serves": ["accounts", "search", "read", "send"] + "serves": ["accounts", "search", "read", "send", "send-status"] } ], "requires": [ @@ -80,7 +80,8 @@ "mail-store.accounts", "mail-store.search", "mail-store.read", - "mail-store.send" + "mail-store.send", + "mail-store.send-status" ], "uses": [ "operator-channel"