diff --git a/modules/mesh-mail/cmd/mesh-mail/ask_test.go b/modules/mesh-mail/cmd/mesh-mail/ask_test.go new file mode 100644 index 00000000..ad7144ac --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/ask_test.go @@ -0,0 +1,135 @@ +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 on issue 868). These tests build +// real asks through mail_send and judge them with the real rule, copied verbatim as content_test.go. + +import ( + "encoding/json" + "strings" + "testing" +) + +// wholeAsk is every word the operator sees for one ask, the same text messenger's content rule judges as a +// whole (the asks router hands it Headline, Explanation and Details together). +func wholeAsk(r *sendRouter, id string) string { + a := r.published[id] + return a.Headline + "\n" + a.Explanation + "\n" + a.Details +} + +// mustNotBeRefused fails the test naming what the content rule refused, and why. +func mustNotBeRefused(t *testing.T, whole string) { + t.Helper() + if refusal, ok := Check(whole); !ok { + t.Fatalf("the content rule refuses the ask: %s\n%s", refusal, whole) + } +} + +// (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, whole) +} + +// (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, whole) +} + +// (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, whole) +} + +// (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, whole) +} diff --git a/modules/mesh-mail/cmd/mesh-mail/content_test.go b/modules/mesh-mail/cmd/mesh-mail/content_test.go new file mode 100644 index 00000000..2c0b5552 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/content_test.go @@ -0,0 +1,215 @@ +package main + +// What may leave the mesh (novox/hq to-be 45 §5, research 028 Q8, ADR 0227): roles and words. A +// message carrying an address, a path or anything shaped like a secret is refused here, by the holder, +// because Telegram is not end-to-end encrypted for bots and this rule is the only thing between a +// condition's words and someone else's server. It is not trusted to each source. +// +// Deliberately wider than it must be: a commit id or a long random name is refused too. A source +// that wants its message through says it in words; a refusal is said, never silent. + +import ( + "math" + "regexp" + "strings" + "unicode" +) + +// Refusal says why a text may not leave: the class of what it carried, never the text itself. +type Refusal struct { + Class string // address, path or secret + What string // a few words: "an IPv4 address", "a URL", … +} + +func (r Refusal) String() string { return r.Class + " (" + r.What + ")" } + +var ( + reURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://`) + reEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + reIPv4 = regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}\b`) + reIPv6 = regexp.MustCompile(`(?i)(^|[^0-9a-z:])(([0-9a-f]{1,4}:){4,7}[0-9a-f]{1,4}|([0-9a-f]{1,4}:)*[0-9a-f]{0,4}::([0-9a-f]{1,4}:)*[0-9a-f]{0,4})([^0-9a-z:]|$)`) + reMAC = regexp.MustCompile(`(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b`) + rePEM = regexp.MustCompile(`-----BEGIN [A-Z ]+-----`) + reJWT = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}`) + reBotToken = regexp.MustCompile(`\b\d{6,}:[A-Za-z0-9_-]{30,}`) + reKnown = regexp.MustCompile(`\b(gh[pousr]_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9_-]{16,}|xox[abprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})`) + reAssigned = regexp.MustCompile(`(?i)\b(password|passwd|passphrase|secret|token|api[_-]?key|apikey|credential|private[_-]?key)\s*[=:]\s*\S`) + reHex = regexp.MustCompile(`(?i)\b[0-9a-f]{32,}\b`) + reRun = regexp.MustCompile(`[A-Za-z0-9+/=_]{20,}`) + reWinPath = regexp.MustCompile(`(?i)\b[a-z]:\\`) +) + +// topLevel are names that end a host name — the generic and country ones a mesh's names use, and the +// private ones (.internal, .lan, .home, .local) a mesh is likelier to. +var topLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + topLevel[t] = true + } +} + +// Check says whether a text may leave the mesh, and when not, why. +func Check(text string) (Refusal, bool) { + switch { + case reURL.MatchString(text): + return Refusal{"address", "a URL"}, false + case reEmail.MatchString(text): + return Refusal{"address", "a mail address"}, false + case reIPv4.MatchString(text): + return Refusal{"address", "an IPv4 address"}, false + case reMAC.MatchString(text): + return Refusal{"address", "a hardware address"}, false + case reIPv6.MatchString(text): + return Refusal{"address", "an IPv6 address"}, false + } + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + if reWinPath.MatchString(text) { + return Refusal{"path", "a drive path"}, false + } + if refusal, ok := randomRun(text); !ok { + return refusal, false + } + for _, word := range strings.FieldsFunc(text, func(r rune) bool { + return unicode.IsSpace(r) || strings.ContainsRune("\"'`()[]{}<>,;|", r) + }) { + w := strings.TrimRight(word, ".:!?") + if isPath(w) { + return Refusal{"path", "a file path"}, false + } + if isHostName(w) { + return Refusal{"address", "a host name"}, false + } + } + return Refusal{}, true +} + +// CheckSecret says whether a text carries a secret's shape — the one class that leaves the mesh nowhere. It is +// what an ask's whole words (asks.Ask.Whole, novox/hq issue 383) are held to: on a kind that proves who answers, +// a path or an address is what the operator approves and may be shown, a secret never. A path is read as one: +// a run with a slash in it is judged piece by piece between the slashes, so "/mnt/Photos_2024/Jochen" is a +// path and not a random string (the review of 2026-10-10); the named shapes — a key block, a signed token, a +// bot token, a known token shape, a value given to a secret's name, a long hexadecimal string — hold whatever +// the run holds. +func CheckSecret(text string) (Refusal, bool) { + if refusal, ok := secretShape(text); !ok { + return refusal, false + } + return randomRunOutsidePaths(text) +} + +// randomRunOutsidePaths is randomRun with each run that holds a slash judged by its pieces between the slashes. +func randomRunOutsidePaths(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + for _, piece := range strings.Split(run, "/") { + if len(piece) >= 20 && looksRandom(piece) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + } + return Refusal{}, true +} + +// secretShape is the secret shapes a pattern names. +func secretShape(text string) (Refusal, bool) { + switch { + case rePEM.MatchString(text): + return Refusal{"secret", "a key block"}, false + case reJWT.MatchString(text): + return Refusal{"secret", "a signed token"}, false + case reBotToken.MatchString(text): + return Refusal{"secret", "a bot token"}, false + case reKnown.MatchString(text): + return Refusal{"secret", "a known token shape"}, false + case reAssigned.MatchString(text): + return Refusal{"secret", "a value given to a secret's name"}, false + case reHex.MatchString(text): + return Refusal{"secret", "a long hexadecimal string"}, false + } + return Refusal{}, true +} + +// randomRun is a long unbroken run of characters spread as a random string's are. +func randomRun(text string) (Refusal, bool) { + for _, run := range reRun.FindAllString(text, -1) { + if looksRandom(run) { + return Refusal{"secret", "a long random-looking string"}, false + } + } + return Refusal{}, true +} + +// isPath: absolute, home-relative or dot-relative, or two separators deep. A mesh address names one +// machine and one tool (`ace/postgres.query`) and has one; a ratio ("3/4") has digits only. +func isPath(w string) bool { + if w == "" { + return false + } + if strings.HasPrefix(w, "/") && len(w) > 1 { + return true + } + for _, p := range []string{"~/", "./", "../", "$HOME", "${"} { + if strings.HasPrefix(w, p) { + return true + } + } + return strings.Count(w, "/") >= 2 || strings.Contains(w, "\\") +} + +// isHostName: two names or more, the last a top-level one. A condition key's last name is its kind +// (`machine.ace.silent`), which none of these is. +func isHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return topLevel[parts[len(parts)-1]] +} + +// looksRandom: letters and digits mixed, and the characters spread as a random string's are. A +// sentence's words are separated, so only an unbroken run reaches here. +func looksRandom(s string) bool { + var letters, digits int + counts := map[rune]int{} + for _, r := range s { + counts[r]++ + switch { + case unicode.IsLetter(r): + letters++ + case unicode.IsDigit(r): + digits++ + } + } + if letters == 0 || digits == 0 { + // One class only: a word, or a number. A long number of digits alone is a count or a time. + return letters > 0 && hasUpperAndLower(s) && entropy(counts, len(s)) >= 4.0 + } + return entropy(counts, len(s)) >= 3.3 +} + +func hasUpperAndLower(s string) bool { + return strings.IndexFunc(s, unicode.IsUpper) >= 0 && strings.IndexFunc(s, unicode.IsLower) >= 0 +} + +func entropy(counts map[rune]int, n int) float64 { + e := 0.0 + for _, c := range counts { + p := float64(c) / float64(n) + e -= p * math.Log2(p) + } + return e +} diff --git a/modules/mesh-mail/cmd/mesh-mail/copies_test.go b/modules/mesh-mail/cmd/mesh-mail/copies_test.go new file mode 100644 index 00000000..78543ed1 --- /dev/null +++ b/modules/mesh-mail/cmd/mesh-mail/copies_test.go @@ -0,0 +1,30 @@ +package main + +// content_test.go is messenger's own content rule (modules/messenger/cmd/messenger/content.go, novox/hq ADR +// 0253 §3), copied here verbatim so mail_send's tests can judge its own asks against the real rule rather +// than a guess at it (novox/hq issue 868). Messenger's content rule stays messenger's: this copy is never +// built into the mesh-mail binary (it is a _test.go file, test-only), and this test pins it to the same +// bytes as its source, the way modules/networkmanager/cmd/uplink-tools/copies_test.go pins uplink.go. + +import ( + "bytes" + "os" + "path/filepath" + "testing" +) + +func TestContentRuleCopyMatchesMessengers(t *testing.T) { + src := filepath.Join("..", "..", "..", "messenger", "cmd", "messenger", "content.go") + theirs, err := os.ReadFile(src) + if err != nil { + t.Skipf("messenger's content.go is not beside this module (%v); nothing to pin against", err) + } + mine, err := os.ReadFile("content_test.go") + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(mine, theirs) { + t.Error("mesh-mail's copy of messenger's content.go (content_test.go) differs from " + src + + ": change this copy to match messenger's content rule, verbatim, whenever it changes") + } +} diff --git a/modules/mesh-mail/cmd/mesh-mail/send.go b/modules/mesh-mail/cmd/mesh-mail/send.go index f05f828a..d4043125 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send.go +++ b/modules/mesh-mail/cmd/mesh-mail/send.go @@ -23,6 +23,7 @@ import ( "errors" "fmt" "net/mail" + "regexp" "strings" "time" @@ -169,6 +170,12 @@ type SendRequest struct { Body string `json:"body"` Caller string `json:"caller"` At time.Time `json:"at"` + // AskTo is To, already composed in a form the operator channel's content rule lets through (novox/hq + // issue 868): a recipient that is one of the operator's own mesh-mail accounts named by that account's + // name, any other said without its address or host. Composed once, from the accounts configured when + // the send was asked, and kept so the ask reads the same way every time it is recomposed (Decide, + // mail_send_status) as it did when the operator was first asked. + AskTo string `json:"ask_to"` // Decided is set once, and never changed again: the ask is acted on once (ADR 0361 §2). Decided *SendDecision `json:"decided,omitempty"` } @@ -205,19 +212,24 @@ func firstChars(s string, n int) string { } // 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). +// lines of the body (novox/hq ADR 0361 §2), in a form the operator channel's content rule (ADR 0253 §3) lets +// through: AskTo names recipients without an address (composeAskTo, at AskMailSend time), and the subject and +// body have every mail address and link or host name masked (maskText) before they reach the ask. The mail +// itself is sent unchanged: neither masking touches s.To, s.Subject or s.Body (novox/hq issue 868). func (s SendRequest) Ask() asks.Ask { - to := strings.Join(s.To, ", ") + to := s.AskTo + subject := maskText(s.Subject) + body := maskText(firstChars(s.Body, ShownBodyChars)) 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) + explanation := fmt.Sprintf("From %s to %s: %q.", s.Account, to, subject) if len([]rune(explanation)) > asks.SummaryLength { - explanation = fmt.Sprintf("From %s: %q.", s.Account, s.Subject) + explanation = fmt.Sprintf("From %s: %q.", s.Account, 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)) + s.Caller, s.At.UTC().Format("15:04 on 2 Jan"), s.Account, to, subject, body) 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 }{ @@ -298,13 +310,16 @@ type routerRecord struct { // ---- 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). +// as mail_read's, is the tool's to make before this is called). Accounts is every account the operator has +// configured (not only the one sending from), read fresh by the tool: composeAskTo needs the whole list to know +// which recipients are the operator's own (novox/hq issue 868). type SendInput struct { - Account Account - To []string - Subject string - Body string - Caller string + Account Account + To []string + Subject string + Body string + Caller string + Accounts []Account } // cleanAddresses trims and drops empty entries. @@ -368,7 +383,7 @@ func AskMailSend(in SendInput, store SendStore, bus AskBus, now time.Time) (map[ 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()} + Caller: in.Caller, At: now.UTC(), AskTo: composeAskTo(to, in.Accounts)} ask := req.Ask() if err := ask.Check(now); err != nil { return refuse(err.Error()) @@ -402,6 +417,113 @@ func onwards(to []string) string { return fmt.Sprintf(" and %d more", len(to)-1) } +// ---- masking the ask for the operator channel's content rule (ADR 0253 §3, novox/hq issue 868) ------------------ +// +// messenger's content rule refuses any message that carries a mail address, a URL or a bare host name, because +// Telegram is not end-to-end encrypted for bots. ADR 0361 §2 still needs the ask to say the recipients, the +// subject and the first lines of the body, so mail_send composes them in a form the rule lets through instead: +// a recipient of the operator's own is named by its account; any other loses its address and host; the subject +// and body have every address and link masked. The mail itself is sent unchanged — only the ask's words differ. + +// mostShownRecipients is how many recipients the ask names one by one before it counts the rest. +const mostShownRecipients = 3 + +// composeAskTo is the recipient line for an ask: a recipient that is one of the accounts given is named "your +// account "; any other is said without its address or host (its first letter, an ellipsis, and its +// domain's labels as words); past the first few, the rest are counted rather than named. +func composeAskTo(to []string, accounts []Account) string { + own := map[string]string{} + for _, a := range accounts { + if a.Address != "" { + own[strings.ToLower(a.Address)] = a.Name + } + } + shown, more := to, 0 + if len(shown) > mostShownRecipients { + shown, more = shown[:mostShownRecipients], len(shown)-mostShownRecipients + } + parts := make([]string, 0, len(shown)) + for _, addr := range shown { + parts = append(parts, oneAskRecipient(addr, own)) + } + line := strings.Join(parts, ", ") + if more > 0 { + line += fmt.Sprintf(" and %d more", more) + } + return line +} + +// oneAskRecipient is one recipient's words in the ask: "your account " for one of the accounts given, or +// its first letter, an ellipsis and its domain's labels as words for any other ("j… at example com"). +func oneAskRecipient(addr string, own map[string]string) string { + if name, ok := own[strings.ToLower(addr)]; ok { + return "your account " + name + } + local, domain, found := strings.Cut(addr, "@") + if !found || local == "" { + return "a recipient" + } + first := string([]rune(local)[:1]) + return first + "… at " + strings.ReplaceAll(domain, ".", " ") +} + +// maskEmail and maskURL mirror messenger's content rule's own patterns (content_test.go's reEmail, reURL), +// so what they catch here is masked before the rule ever judges it. +var ( + maskEmail = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}`) + maskURL = regexp.MustCompile(`(?i)\b[a-z][a-z0-9+.-]*://\S+`) + // maskWord splits text the same way the content rule's Check does (its own strings.FieldsFunc separators), + // so a bare host name is found on the same tokens the rule itself would judge. + maskWord = regexp.MustCompile(`[^\s"'` + "`" + `()\[\]{}<>,;|]+`) +) + +// maskTopLevel are the top-level labels a bare host name's last part may be, for masking alone: the same list +// content_test.go's own topLevel carries, so a word this masks is also a word the rule would have refused. +var maskTopLevel = map[string]bool{} + +func init() { + for _, t := range strings.Fields(`com net org edu gov mil int io dev app cloud ai co me info biz xyz + site online tech page link + be nl de fr uk lu eu ch at it es pt se no dk fi pl cz us ca au nz jp cn ru in br ie + internal lan home local localdomain corp intranet private arpa test example invalid localhost`) { + maskTopLevel[t] = true + } +} + +// maskText replaces every mail address with "[address]" and every link or host name with "[link]", so the +// subject and body first lines may reach the operator channel. It never touches s.Subject or s.Body themselves: +// the mail is sent with what was actually written (novox/hq issue 868). +func maskText(s string) string { + s = maskEmail.ReplaceAllString(s, "[address]") + s = maskURL.ReplaceAllString(s, "[link]") + return maskWord.ReplaceAllStringFunc(s, func(word string) string { + trimmed := strings.TrimRight(word, ".,:;!?") + if looksLikeHostName(trimmed) { + return "[link]" + word[len(trimmed):] + } + return word + }) +} + +// looksLikeHostName: two labels or more, the last a top-level one — the same shape content.go's isHostName +// judges a bare host name by. +func looksLikeHostName(w string) bool { + w = strings.ToLower(w) + if w == "localhost" { + return true + } + parts := strings.Split(w, ".") + if len(parts) < 2 { + return false + } + for _, p := range parts { + if p == "" { + return false + } + } + return maskTopLevel[parts[len(parts)-1]] +} + // ---- deciding --------------------------------------------------------------------------------------------------- // AccountLookup resolves an account by name, as the settings currently hold it (not as it stood when it was asked: diff --git a/modules/mesh-mail/cmd/mesh-mail/send_test.go b/modules/mesh-mail/cmd/mesh-mail/send_test.go index 056400c9..176a7287 100644 --- a/modules/mesh-mail/cmd/mesh-mail/send_test.go +++ b/modules/mesh-mail/cmd/mesh-mail/send_test.go @@ -334,7 +334,9 @@ func TestMailSendRefusesAnUngrantedCallerAndRaisesNoAsk(t *testing.T) { } } -// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first lines. +// (2) A granted caller raises one ask whose text holds the account, the recipients, the subject and the first +// lines — the recipients without their addresses, in the form the operator channel's content rule (ADR 0253 §3) +// lets through (novox/hq issue 868; ask_test.go judges the real rule on asks like this one). func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) { b, router, _ := sendTestBundle(t, Server{Host: "127.0.0.1", Port: 25, Security: "tls"}) rt := startBundle(t, b.Tools()) @@ -358,11 +360,14 @@ func TestMailSendAsksWithTheAccountRecipientsSubjectAndFirstLines(t *testing.T) 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"} { + for _, want := range []string{"mesh", "o… at example org", "l… at 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) } } + if strings.Contains(whole, "ops@example.org") || strings.Contains(whole, "lead@example.org") { + t.Errorf("the ask carries a recipient's mail address, which the operator channel's content rule refuses:\n%s", 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) diff --git a/modules/mesh-mail/cmd/mesh-mail/tools.go b/modules/mesh-mail/cmd/mesh-mail/tools.go index b957f0c0..095ee3d4 100644 --- a/modules/mesh-mail/cmd/mesh-mail/tools.go +++ b/modules/mesh-mail/cmd/mesh-mail/tools.go @@ -471,7 +471,8 @@ func (b *Bundle) sendTool(args map[string]any) (any, error) { 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()) + return AskMailSend(SendInput{Account: acc, To: to, Subject: subject, Body: body, Caller: caller, Accounts: accounts}, + b.sends(), b.askBus(), b.now()) } func (b *Bundle) sendStatusTool(args map[string]any) (any, error) {