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))
}