package calls import ( "context" "errors" "fmt" "log/slog" "strings" "amadan.net/rastrillo/rastrillo/mail" ) // RedactMailErrors wraps a mail sender so a delivery failure never // carries the recipient's address out of this package (review C1-2, // C2-1). // // rastrillo/mail wraps every SMTP failure as "send to
: " // (mail/mail.go:163) and rastrillo/auth logs that error verbatim // (auth/handlers.go:51). An outsider who asks for a magic link during an // SMTP outage would therefore leave their plaintext address in the log, // before they have signed in and before Calls has decided to admit them — // which spec §0.1's falsifiable claim covers as squarely as the database // does. So the wrapper logs the failure itself and hands rastrillo an // error that says only that delivery failed. // // What the log line keeps is the root cause's *type* and a boolean // saying whether the address appeared in the message. It does not keep // the message, and never calls err.Error() for logging purposes — only // to test it for the address, and that result is thrown away, kept only // as a bool. That is a real loss — an SMTP reply's text is the most // useful thing about it — and it is the deliberate trade: a remote // server's error text is arbitrary bytes that may carry the address in a // shape no scrubber anticipated (the local part alone, the domain alone, // re-encoded, split across a fold). A type plus a yes/no is small, and // it cannot leak. // // The first version of this file tried to do better: replace every // occurrence of the address in the message with "[address]" and log the // result. That slices the message at byte offsets found in a // strings.ToLower copy, and ToLower can change a string's byte length // (the Kelvin sign U+212A is three bytes and lowercases to one-byte "k"; // capital dotted I U+0130 is two bytes and lowercases to two-byte "i̇"), // so an offset found in the lowered copy does not address the same bytes // in the original — an address containing either could leave a fragment // of itself in the "redacted" text (codex round 2, C2-1). Logging // nothing derived from the message at all, only a boolean computed with // no slicing, has no such trap: strings.Contains never needs its // operands to line up byte-for-byte with anything else. // // The wrapper forwards Delivers and SendList when the inner sender has // them, so mail.IsConfigured still sees through it (mail/mail.go:59) and // a ListSender does not silently lose its list headers. func RedactMailErrors(inner mail.Sender, logger *slog.Logger) mail.Sender { if inner == nil { return nil } if logger == nil { logger = slog.Default() } s := &redactingSender{inner: inner, logger: logger} list, isList := inner.(mail.ListSender) deliverer, isDeliverer := inner.(mail.Deliverer) switch { case isList && isDeliverer: return &redactingListDeliverer{redactingListSender{*s, list}, deliverer} case isList: return &redactingListSender{*s, list} case isDeliverer: return &redactingDeliverer{*s, deliverer} } return s } type redactingSender struct { inner mail.Sender logger *slog.Logger } func (s *redactingSender) Send(ctx context.Context, to, subject, body string) error { return s.redact(to, s.inner.Send(ctx, to, subject, body)) } // redact logs a delivery failure and returns the only error this package // lets out. It never returns anything derived from err. func (s *redactingSender) redact(to string, err error) error { if err == nil { return nil } s.logger.Error("calls: mail delivery failed", "type", fmt.Sprintf("%T", rootCause(err)), "address_seen", addressSeen(err.Error(), to)) return errors.New("calls: mail delivery failed") } // redactingListSender forwards SendList; redactingDeliverer forwards // Delivers; redactingListDeliverer forwards both. Three types rather // than one with nil checks, so the interface a caller type-asserts for // is the interface the inner sender actually had. type redactingListSender struct { redactingSender list mail.ListSender } func (s *redactingListSender) SendList(ctx context.Context, m mail.ListMessage) error { return s.redact(m.To, s.list.SendList(ctx, m)) } type redactingDeliverer struct { redactingSender deliverer mail.Deliverer } func (s *redactingDeliverer) Delivers() bool { return s.deliverer.Delivers() } type redactingListDeliverer struct { redactingListSender deliverer mail.Deliverer } func (s *redactingListDeliverer) Delivers() bool { return s.deliverer.Delivers() } // rootCause is the innermost error in the chain. rastrillo/mail wraps // with fmt.Errorf, so %T on what it returns says "*fmt.wrapError" and // nothing about what actually went wrong; the cause names the dial // failure, the TLS refusal or the SMTP reply type. func rootCause(err error) error { for { next := errors.Unwrap(err) if next == nil { return err } err = next } } // addressSeen reports whether the recipient's address appears in an // error message, case-insensitively — the one bit of the message worth // keeping, and the only thing this package computes from it. No // slicing: strings.Contains over two independently-lowered strings never // needs an offset from one to mean anything in the other, so the byte- // length change strings.ToLower can produce (the Kelvin sign U+212A, // capital dotted I U+0130) cannot desynchronise anything the way it did // when the first version of this file used lowered offsets to slice the // original message. func addressSeen(msg, address string) bool { address = strings.TrimSpace(address) if address == "" { return false } return strings.Contains(strings.ToLower(msg), strings.ToLower(address)) }