oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1package calls
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "strings"
9
10 "amadan.net/rastrillo/rastrillo/mail"
11)
12
13// RedactMailErrors wraps a mail sender so a delivery failure never
14// carries the recipient's address out of this package (review C1-2,
15// C2-1).
16//
17// rastrillo/mail wraps every SMTP failure as "send to <address>: <err>"
18// (mail/mail.go:163) and rastrillo/auth logs that error verbatim
19// (auth/handlers.go:51). An outsider who asks for a magic link during an
20// SMTP outage would therefore leave their plaintext address in the log,
21// before they have signed in and before Calls has decided to admit them —
22// which spec §0.1's falsifiable claim covers as squarely as the database
23// does. So the wrapper logs the failure itself and hands rastrillo an
24// error that says only that delivery failed.
25//
26// What the log line keeps is the root cause's *type* and a boolean
27// saying whether the address appeared in the message. It does not keep
28// the message, and never calls err.Error() for logging purposes — only
29// to test it for the address, and that result is thrown away, kept only
30// as a bool. That is a real loss — an SMTP reply's text is the most
31// useful thing about it — and it is the deliberate trade: a remote
32// server's error text is arbitrary bytes that may carry the address in a
33// shape no scrubber anticipated (the local part alone, the domain alone,
34// re-encoded, split across a fold). A type plus a yes/no is small, and
35// it cannot leak.
36//
37// The first version of this file tried to do better: replace every
38// occurrence of the address in the message with "[address]" and log the
39// result. That slices the message at byte offsets found in a
40// strings.ToLower copy, and ToLower can change a string's byte length
41// (the Kelvin sign U+212A is three bytes and lowercases to one-byte "k";
42// capital dotted I U+0130 is two bytes and lowercases to two-byte "i̇"),
43// so an offset found in the lowered copy does not address the same bytes
44// in the original — an address containing either could leave a fragment
45// of itself in the "redacted" text (codex round 2, C2-1). Logging
46// nothing derived from the message at all, only a boolean computed with
47// no slicing, has no such trap: strings.Contains never needs its
48// operands to line up byte-for-byte with anything else.
49//
50// The wrapper forwards Delivers and SendList when the inner sender has
51// them, so mail.IsConfigured still sees through it (mail/mail.go:59) and
52// a ListSender does not silently lose its list headers.
53func RedactMailErrors(inner mail.Sender, logger *slog.Logger) mail.Sender {
54 if inner == nil {
55 return nil
56 }
57 if logger == nil {
58 logger = slog.Default()
59 }
60 s := &redactingSender{inner: inner, logger: logger}
61 list, isList := inner.(mail.ListSender)
62 deliverer, isDeliverer := inner.(mail.Deliverer)
63 switch {
64 case isList && isDeliverer:
65 return &redactingListDeliverer{redactingListSender{*s, list}, deliverer}
66 case isList:
67 return &redactingListSender{*s, list}
68 case isDeliverer:
69 return &redactingDeliverer{*s, deliverer}
70 }
71 return s
72}
73
74type redactingSender struct {
75 inner mail.Sender
76 logger *slog.Logger
77}
78
79func (s *redactingSender) Send(ctx context.Context, to, subject, body string) error {
80 return s.redact(to, s.inner.Send(ctx, to, subject, body))
81}
82
83// redact logs a delivery failure and returns the only error this package
84// lets out. It never returns anything derived from err.
85func (s *redactingSender) redact(to string, err error) error {
86 if err == nil {
87 return nil
88 }
89 s.logger.Error("calls: mail delivery failed",
90 "type", fmt.Sprintf("%T", rootCause(err)),
91 "address_seen", addressSeen(err.Error(), to))
92 return errors.New("calls: mail delivery failed")
93}
94
95// redactingListSender forwards SendList; redactingDeliverer forwards
96// Delivers; redactingListDeliverer forwards both. Three types rather
97// than one with nil checks, so the interface a caller type-asserts for
98// is the interface the inner sender actually had.
99type redactingListSender struct {
100 redactingSender
101 list mail.ListSender
102}
103
104func (s *redactingListSender) SendList(ctx context.Context, m mail.ListMessage) error {
105 return s.redact(m.To, s.list.SendList(ctx, m))
106}
107
108type redactingDeliverer struct {
109 redactingSender
110 deliverer mail.Deliverer
111}
112
113func (s *redactingDeliverer) Delivers() bool { return s.deliverer.Delivers() }
114
115type redactingListDeliverer struct {
116 redactingListSender
117 deliverer mail.Deliverer
118}
119
120func (s *redactingListDeliverer) Delivers() bool { return s.deliverer.Delivers() }
121
122// rootCause is the innermost error in the chain. rastrillo/mail wraps
123// with fmt.Errorf, so %T on what it returns says "*fmt.wrapError" and
124// nothing about what actually went wrong; the cause names the dial
125// failure, the TLS refusal or the SMTP reply type.
126func rootCause(err error) error {
127 for {
128 next := errors.Unwrap(err)
129 if next == nil {
130 return err
131 }
132 err = next
133 }
134}
135
136// addressSeen reports whether the recipient's address appears in an
137// error message, case-insensitively — the one bit of the message worth
138// keeping, and the only thing this package computes from it. No
139// slicing: strings.Contains over two independently-lowered strings never
140// needs an offset from one to mean anything in the other, so the byte-
141// length change strings.ToLower can produce (the Kelvin sign U+212A,
142// capital dotted I U+0130) cannot desynchronise anything the way it did
143// when the first version of this file used lowered offsets to slice the
144// original message.
145func addressSeen(msg, address string) bool {
146 address = strings.TrimSpace(address)
147 if address == "" {
148 return false
149 }
150 return strings.Contains(strings.ToLower(msg), strings.ToLower(address))
151}
152