mesh/merge-gate head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: builds mesh-mail → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head b0e4a12c8840 merged into trunk 21a499ba5976 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Review round 1: the test judged Headline+Explanation+Details, but messenger refuses an ask as a whole on
Headline, Explanation, OnExpiry and each option's Label and Does alone (asks.go's TakeAsk); Details is
filtered line by line instead (holder.go's sayOn). send.go's Ask() now builds that exact judged text and
checks it with messenger's real content rule, carried at run time as content_rule.go (messenger's
content.go, renamed, byte-identical, pinned by copies_test.go) rather than only in a test copy. When a
subject or first line the masking does not catch (an IP, a hardware address, a path-shaped reference, a
hex hash, ...) would still refuse the whole ask, the Explanation drops the subject and first lines and
points at Details instead, so the ask always reaches the operator. A recipient written with a display
name ('Name <addr>') is now matched against the operator's own accounts, and masked, by its address alone
(net/mail.ParseAddress).
219 lines
9.3 KiB
Go
219 lines
9.3 KiB
Go
package main
|
|
|
|
// mail_send raises its ask on the operator channel, and messenger's content rule (ADR 0253 §3) refuses any
|
|
// message that carries a mail address, a URL or a bare host name. ADR 0361 §2 needs the ask to show the
|
|
// account, the recipients, the subject and the first lines regardless — so the ask must say all of that in
|
|
// a form the content rule lets through (novox/hq issue 868, "Decided" note and review round 1). These tests
|
|
// build real asks through mail_send and judge them with the real rule, copied verbatim as content_rule.go.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// wholeAsk is every word the operator can ever see for one ask: its Headline, Explanation and Details
|
|
// together. Useful for a readability check (nothing unmasked is visible anywhere), but never for judging
|
|
// whether messenger would refuse the ask as a whole — judgedWords is that (novox/hq issue 868, review
|
|
// round 1): Details is filtered line by line, not judged with the rest (holder.go's sayOn).
|
|
func wholeAsk(r *sendRouter, id string) string {
|
|
a := r.published[id]
|
|
return a.Headline + "\n" + a.Explanation + "\n" + a.Details
|
|
}
|
|
|
|
// judgedWords is exactly the text messenger refuses (or lets through) an ask as a whole on (asks.go's
|
|
// TakeAsk): its headline, its explanation, what happens at expiry, and each option's label and effect.
|
|
// Details is never part of it.
|
|
func judgedWords(a asks.Ask) string {
|
|
words := []string{a.Headline, a.Explanation, a.OnExpiry}
|
|
for _, o := range a.Options {
|
|
words = append(words, o.Label, o.Does)
|
|
}
|
|
return strings.Join(words, "\n")
|
|
}
|
|
|
|
// mustNotBeRefused fails the test naming what the content rule refused, and why — judged the way messenger
|
|
// itself judges an ask as a whole, on judgedWords alone.
|
|
func mustNotBeRefused(t *testing.T, a asks.Ask) {
|
|
t.Helper()
|
|
if refusal, ok := Check(judgedWords(a)); !ok {
|
|
t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, judgedWords(a))
|
|
}
|
|
}
|
|
|
|
// (1) A recipient that is one of the operator's own mesh-mail accounts (sendTestBundle's "mesh", address
|
|
// jschoubben@novox.be) is named by the account's name alone: the ask carries no mail address at all, and
|
|
// passes the real content rule. This is the first test issue 868's decision asked for; it fails today
|
|
// because AskMailSend puts the raw address into the ask.
|
|
func TestMailSendAskForOwnAccountRecipientCarriesNoAddress(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"jschoubben@novox.be"},
|
|
"subject": "Testing issue 868", "body": "Does the ask reach the operator now?"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
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 == "" {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") {
|
|
t.Errorf("the ask carries a mail address for its own account recipient:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "your account mesh") {
|
|
t.Errorf("the ask does not name the own account:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (2) A recipient outside the operator's own accounts is said without its address or host: its first
|
|
// letter, an ellipsis and the domain's labels as words.
|
|
func TestMailSendAskForOutsideRecipientCarriesNoAddress(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) // to: ops@example.org, lead@example.org
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "example.org") {
|
|
t.Errorf("the ask carries the recipients' address or host:\n%s", whole)
|
|
}
|
|
for _, want := range []string{"o… at example org", "l… at example org"} {
|
|
if !strings.Contains(whole, want) {
|
|
t.Errorf("the ask does not say %q:\n%s", want, whole)
|
|
}
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (3) More than three recipients are counted rather than said one by one.
|
|
func TestMailSendAskForManyRecipientsCountsTheRest(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh",
|
|
"to": []any{"a@example.org", "b@example.org", "c@example.org", "d@example.org", "e@example.org"},
|
|
"subject": "Many recipients", "body": "body"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") {
|
|
t.Errorf("the ask carries a mail address among many recipients:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "and 2 more") {
|
|
t.Errorf("the ask does not count the recipients past the first three:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (4) A subject holding a mail address and a link has both masked before the ask carries it.
|
|
func TestMailSendAskMasksAnAddressAndALinkInTheSubject(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"},
|
|
"subject": "Contact ops@example.org or see https://example.com/issue/42",
|
|
"body": "See https://example.com/issue/42 and write to ops@example.org for details."}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "example.com") || strings.Contains(whole, "://") {
|
|
t.Errorf("the ask carries an unmasked address or link:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "[address]") || !strings.Contains(whole, "[link]") {
|
|
t.Errorf("the ask does not say [address] and [link] in place of what it masked:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|
|
|
|
// (5) review round 1: a subject the masking does not catch (an IP, a two-slash path-shaped reference, a
|
|
// 40-character hex hash) does not refuse the whole ask: the real content rule (content_rule.go) still judges
|
|
// the masked explanation as refused, so the subject and first lines are left out of it and pointed at Details
|
|
// instead — the ask still names the account and recipients, and still reaches the operator.
|
|
func TestMailSendAskFallsBackToDetailsWhenTheSubjectStillRefuses(t *testing.T) {
|
|
for _, subject := range []string{
|
|
"Invoice 2026/10/15",
|
|
"server 10.0.0.1",
|
|
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", // 40 hex characters: reHex's shape
|
|
} {
|
|
t.Run(subject, func(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
args := map[string]any{"account": "mesh", "to": []any{"ops@example.org"}, "subject": subject, "body": "body"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
a := router.published[id]
|
|
mustNotBeRefused(t, a)
|
|
if !strings.Contains(a.Explanation, "mesh") {
|
|
t.Errorf("the explanation does not name the account:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Explanation, "o… at example org") {
|
|
t.Errorf("the explanation does not name the recipient:\n%s", a.Explanation)
|
|
}
|
|
if strings.Contains(a.Explanation, subject) {
|
|
t.Errorf("the explanation still carries the subject that refused the ask:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Details, "Details") && !strings.Contains(a.Explanation, "Details") {
|
|
t.Errorf("the explanation does not point at Details:\n%s", a.Explanation)
|
|
}
|
|
if !strings.Contains(a.Details, subject) {
|
|
t.Errorf("Details does not carry the subject (filtered line by line by messenger, as the subject's own line): %s", a.Details)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// (6) review round 1: a recipient written with a display name ("Name <addr>") is matched against the
|
|
// operator's own accounts, and masked, by its address alone.
|
|
func TestMailSendAskMatchesADisplayNameRecipientByItsAddress(t *testing.T) {
|
|
b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"})
|
|
rt := startBundle(t, b.Tools())
|
|
|
|
args := map[string]any{"account": "mesh", "to": []any{"Jochen Schoubben <jschoubben@novox.be>"},
|
|
"subject": "Testing a display name", "body": "body"}
|
|
text, isErr := rt.call(SendTool, "novox.triage", args)
|
|
if isErr {
|
|
t.Fatalf("%s", text)
|
|
}
|
|
var out map[string]any
|
|
_ = json.Unmarshal([]byte(text), &out)
|
|
id := out["ask"].(string)
|
|
whole := wholeAsk(router, id)
|
|
if strings.Contains(whole, "@") || strings.Contains(whole, "Jochen Schoubben") {
|
|
t.Errorf("the ask carries the display-name recipient's address or name:\n%s", whole)
|
|
}
|
|
if !strings.Contains(whole, "your account mesh") {
|
|
t.Errorf("the ask does not match the display-name recipient to its own account:\n%s", whole)
|
|
}
|
|
mustNotBeRefused(t, router.published[id])
|
|
}
|