mail_send's ask names recipients and masks addresses so the operator channel can carry it (issue 868)

messenger's content rule (ADR 0253 §3) refuses any message carrying a mail address, so mail_send's ask
never reached the operator: no send could ever be approved. A recipient that is one of the operator's own
mesh-mail accounts is now named by that account's name; any other loses its address and host; the subject
and body's first lines have every address and link masked before they reach the ask. The mail itself is
sent unchanged.

Tests run messenger's real content rule against the asks mail_send builds (content.go copied verbatim as
content_test.go, pinned byte-identical by copies_test.go, since messenger's package main cannot be
imported from another module).
This commit is contained in:
jochen
2026-10-16 01:57:06 +02:00
parent 62ec68355c
commit a76b0742ff
6 changed files with 523 additions and 15 deletions
+134 -12
View File
@@ -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 <name>"; 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 <name>" 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: