carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// SES event parsing (spec §9). Events are the abuse signal AND the only
2// source of a per-tenant send count — SES has no per-identity rate limit to
3// read, so a cap can only be enforced by counting what already went out.
4package mail
5
6import (
7 "encoding/json"
8 "fmt"
9 "strings"
10 "time"
11 "unicode"
12 "unicode/utf8"
13)
14
15const (
16 EventSend = "Send"
17 EventBounce = "Bounce"
18 EventComplaint = "Complaint"
19 EventReject = "Reject"
20 EventDelivery = "Delivery"
21 EventDeliveryDelay = "DeliveryDelay"
22)
23
24// Event is the part of an SES notification this package acts on.
25type Event struct {
26 Type string
27 Domain string
28 From string
29 MessageID string
30 // At is when what the event reports happened: the bounce, the delivery,
31 // the complaint — falling back to the send, which is all a Send or a
32 // Reject has. SentAt is always the send.
33 At time.Time
34 SentAt time.Time
35
36 // Caller is the IAM identity SES says sent the message — the automatic
37 // "ses:caller-identity" tag. CARLOS mints one per app (UserName), so
38 // where the sending domain can only name every app that shares it, this
39 // names the one app that actually sent. Empty when SES did not say;
40 // AppOfCaller reads it back.
41 Caller string
42 // Destination is who the message was addressed to (mail.destination).
43 Destination []string
44 // Outcomes is what this event says about each recipient it concerns:
45 // the bounced, complained-about, delivered or delayed addresses, each
46 // with what the far end said. A Send or a Reject is about the whole
47 // message and carries none.
48 Outcomes []Outcome
49 // Detail is the event-wide explanation, empty where the type has none.
50 Detail EventDetail
51 // Incomplete says why part of what only the log uses could not be read —
52 // a field in a shape this code did not expect. The event is still good
53 // for the counts; the caller logs this.
54 Incomplete string
55}
56
57// Outcome is one recipient's part in an event.
58type Outcome struct {
59 Recipient string
60 // Status is the enhanced status code the receiving server gave, e.g.
61 // "5.1.1". Empty on a delivery and a complaint.
62 Status string
63 // Diagnostic is the receiving server's own words: the bounce or delay
64 // diagnostic, or the SMTP response it accepted a delivery with.
65 Diagnostic string
66}
67
68// EventDetail is what an event says about the message as a whole.
69type EventDetail struct {
70 BounceType string // Permanent, Transient, Undetermined
71 BounceSubType string // General, NoEmail, Suppressed, MailboxFull, ...
72 ComplaintType string // abuse, fraud, not-spam, ... when the provider said
73 RejectReason string // e.g. "Bad content"
74 DelayType string // e.g. MailboxFull, TransientCommunicationFailure
75}
76
77// The longest a field from an event is kept at. An address is at most 254
78// octets by RFC 5321; a diagnostic is the far end's free text and has no
79// limit of its own, so it gets one here — enough for any real explanation,
80// not enough for a receiving server to fill a table with.
81const (
82 maxAddressLen = 254
83 maxDiagnosticLen = 1000
84 maxDetailLen = 64
85)
86
87// cleanField drops control characters and bounds the length. Every string
88// that comes out of an event and is kept goes through it: the event is
89// signed by SNS, but the text in it was written by whichever mail server
90// answered, and it ends up in a table and on a terminal.
91func cleanField(s string, max int) string {
92 s = strings.Map(func(r rune) rune {
93 // C0, DEL and C1 (U+009B is a CSI on some terminals), and the
94 // invisible formatting class — bidi overrides among it — which can
95 // make a line on a terminal say something the bytes do not.
96 if unicode.IsControl(r) || unicode.Is(unicode.Cf, r) {
97 return ' '
98 }
99 return r
100 }, strings.TrimSpace(s))
101 if len(s) > max {
102 s = s[:max]
103 for len(s) > 0 && !utf8.ValidString(s) {
104 s = s[:len(s)-1]
105 }
106 }
107 return s
108}
109
110// DomainOf extracts the lowercase domain from an address, tolerating a
111// display-name wrapper. Empty when there is no domain to find. Strips one
112// trailing dot and any stray angle bracket.
113func DomainOf(address string) string {
114 s := strings.TrimSpace(address)
115 if i := strings.LastIndex(s, "<"); i >= 0 {
116 if j := strings.LastIndex(s, ">"); j > i {
117 s = s[i+1 : j]
118 }
119 }
120 at := strings.LastIndex(s, "@")
121 if at < 0 || at == len(s)-1 {
122 return ""
123 }
124 dom := strings.TrimSpace(s[at+1:])
125 // Strip stray trailing > from unbalanced brackets
126 if len(dom) > 0 && dom[len(dom)-1] == '>' {
127 dom = strings.TrimRight(dom, ">")
128 if dom == "" {
129 return ""
130 }
131 }
132 // Strip exactly one trailing dot (FQDN normalisation)
133 if len(dom) > 1 && dom[len(dom)-1] == '.' {
134 dom = dom[:len(dom)-1]
135 }
136 return strings.ToLower(dom)
137}
138
139// ParseEvent reads one SES notification.
140//
141// An UNKNOWN event type parses successfully and is carried through as-is.
142// SES adds event types, and refusing one would make the ingest endpoint
143// answer 500, which makes SNS retry it forever — a self-inflicted flood
144// over a field nothing acts on.
145//
146// A missing source IS refused: an event that can be attributed to no tenant
147// is worse than no event, because it would be silently dropped from the
148// counts that enforce a cap.
149func ParseEvent(body []byte) (Event, error) {
150 // Two reads of the same body, deliberately. This one is everything the
151 // counts and the cap depend on, typed exactly as it was before the log
152 // existed, and it is the only one allowed to refuse the event: a refused
153 // event is a count silently dropped.
154 var raw struct {
155 EventType string `json:"eventType"`
156 Mail struct {
157 Timestamp string `json:"timestamp"`
158 MessageID string `json:"messageId"`
159 Source string `json:"source"`
160 } `json:"mail"`
161 }
162 if err := json.Unmarshal(body, &raw); err != nil {
163 return Event{}, fmt.Errorf("mail: parse ses event: %w", err)
164 }
165 if raw.Mail.Source == "" {
166 return Event{}, fmt.Errorf("mail: ses event has no mail.source — it can be attributed to no tenant")
167 }
168 // Refuse control characters on the raw source: an injected newline anywhere
169 // in the field indicates malformation and must not be trusted.
170 for _, c := range raw.Mail.Source {
171 if c < 32 || c == 127 {
172 return Event{}, fmt.Errorf("mail: ses event source contains control character (0x%02x)", c)
173 }
174 }
175 // Extract the address part (stripping display-name wrapper if present) and
176 // test it for ambiguity. A single envelope MAIL FROM must extract to exactly
177 // one @ and no commas.
178 extractedAddr := raw.Mail.Source
179 s := strings.TrimSpace(raw.Mail.Source)
180 if i := strings.LastIndex(s, "<"); i >= 0 {
181 if j := strings.LastIndex(s, ">"); j > i {
182 // Has a bracketed span; refuse if there are multiple bracket pairs
183 // (indicated by another < before the final one).
184 if strings.Contains(s[:i], "<") {
185 return Event{}, fmt.Errorf("mail: ses event source %q is ambiguous — multiple bracketed address spans", raw.Mail.Source)
186 }
187 extractedAddr = strings.TrimSpace(s[i+1 : j])
188 }
189 }
190 // Check the extracted address for ambiguity: commas or multiple @ symbols.
191 // Note: "a@b"@example.com (a quoted local part containing @) is refused here.
192 // This is deliberate: SES mail.source is a bare envelope MAIL FROM with no
193 // display name, and quoted local parts with @ essentially never appear there.
194 // Quote-aware parsing is not worth the machinery for a case that does not occur.
195 if strings.Contains(extractedAddr, ",") || strings.Count(extractedAddr, "@") > 1 {
196 return Event{}, fmt.Errorf("mail: ses event source %q is ambiguous — cannot reliably attribute to one tenant", raw.Mail.Source)
197 }
198 dom := DomainOf(raw.Mail.Source)
199 if dom == "" {
200 return Event{}, fmt.Errorf("mail: ses event source %q has no domain", raw.Mail.Source)
201 }
202 ev := Event{
203 Type: raw.EventType, Domain: dom,
204 From: raw.Mail.Source, MessageID: raw.Mail.MessageID,
205 }
206 if raw.Mail.Timestamp != "" {
207 if at, err := time.Parse(time.RFC3339, raw.Mail.Timestamp); err == nil {
208 ev.SentAt = at
209 ev.At = at
210 }
211 }
212 ev.MessageID = cleanField(ev.MessageID, maxDiagnosticLen)
213 readLogFields(body, raw.EventType, &ev)
214 return ev, nil
215}
216
217// readLogFields is the second read: what only the per-message log uses —
218// who the message went to, who sent it, and what the receiving server said.
219// A field here in a shape this code does not expect is not a reason to lose
220// the event, so nothing here refuses it: the part that does not read is left
221// empty, the reason is kept in ev.Incomplete for the caller to log, and the
222// count goes ahead. The envelope and the event's own block are read
223// separately, so a malformed bounce block does not also cost the sender.
224func readLogFields(body []byte, eventType string, ev *Event) {
225 type recipient struct {
226 EmailAddress string `json:"emailAddress"`
227 Status string `json:"status"`
228 DiagnosticCode string `json:"diagnosticCode"`
229 }
230 var problems []string
231 var envelope struct {
232 Mail struct {
233 Destination []string `json:"destination"`
234 Tags map[string][]string `json:"tags"`
235 } `json:"mail"`
236 }
237 if err := json.Unmarshal(body, &envelope); err != nil {
238 problems = append(problems, "mail: "+err.Error())
239 }
240 if ids := envelope.Mail.Tags["ses:caller-identity"]; len(ids) > 0 {
241 ev.Caller = cleanField(ids[0], maxDiagnosticLen)
242 }
243 for _, to := range envelope.Mail.Destination {
244 if to = cleanField(to, maxAddressLen); to != "" {
245 ev.Destination = append(ev.Destination, to)
246 }
247 }
248
249 // Each type carries its own block; the others are simply empty. Read
250 // only the one this event is, so a malformed message that carries two
251 // cannot have one type's recipients reported under another's name.
252 var raw struct {
253 Bounce struct {
254 Timestamp string `json:"timestamp"`
255 BounceType string `json:"bounceType"`
256 BounceSubType string `json:"bounceSubType"`
257 BouncedRecipients []recipient `json:"bouncedRecipients"`
258 } `json:"bounce"`
259 Complaint struct {
260 Timestamp string `json:"timestamp"`
261 ComplaintFeedbackType string `json:"complaintFeedbackType"`
262 ComplainedRecipients []recipient `json:"complainedRecipients"`
263 } `json:"complaint"`
264 Delivery struct {
265 Timestamp string `json:"timestamp"`
266 Recipients []string `json:"recipients"`
267 SMTPResponse string `json:"smtpResponse"`
268 } `json:"delivery"`
269 Reject struct {
270 Reason string `json:"reason"`
271 } `json:"reject"`
272 DeliveryDelay struct {
273 Timestamp string `json:"timestamp"`
274 DelayType string `json:"delayType"`
275 DelayedRecipients []recipient `json:"delayedRecipients"`
276 } `json:"deliveryDelay"`
277 }
278 block := map[string]string{
279 EventBounce: "bounce", EventComplaint: "complaint", EventDelivery: "delivery",
280 EventReject: "reject", EventDeliveryDelay: "deliveryDelay",
281 }[eventType]
282 if block != "" {
283 var blocks map[string]json.RawMessage
284 if err := json.Unmarshal(body, &blocks); err == nil {
285 // Wrap the one block back under its own key and read only that.
286 if one, ok := blocks[block]; ok {
287 if err := json.Unmarshal([]byte(`{"`+block+`":`+string(one)+`}`), &raw); err != nil {
288 problems = append(problems, block+": "+err.Error())
289 }
290 }
291 }
292 }
293 if len(problems) > 0 {
294 ev.Incomplete = cleanField(strings.Join(problems, "; "), maxDiagnosticLen)
295 }
296
297 // When the thing this event reports happened, where SES says: a bounce
298 // or a delay can arrive a day after the send, and an event dated by the
299 // send is both wrong on a screen and invisible to "since yesterday".
300 eventTime := map[string]string{
301 EventBounce: raw.Bounce.Timestamp, EventComplaint: raw.Complaint.Timestamp,
302 EventDelivery: raw.Delivery.Timestamp, EventDeliveryDelay: raw.DeliveryDelay.Timestamp,
303 }[eventType]
304 if eventTime != "" {
305 if at, err := time.Parse(time.RFC3339, eventTime); err == nil {
306 ev.At = at
307 }
308 }
309
310 outcomes := func(rs []recipient) {
311 for _, r := range rs {
312 to := cleanField(r.EmailAddress, maxAddressLen)
313 if to == "" {
314 continue
315 }
316 ev.Outcomes = append(ev.Outcomes, Outcome{Recipient: to,
317 Status: cleanField(r.Status, maxDetailLen), Diagnostic: cleanField(r.DiagnosticCode, maxDiagnosticLen)})
318 }
319 }
320 switch eventType {
321 case EventBounce:
322 ev.Detail.BounceType = cleanField(raw.Bounce.BounceType, maxDetailLen)
323 ev.Detail.BounceSubType = cleanField(raw.Bounce.BounceSubType, maxDetailLen)
324 outcomes(raw.Bounce.BouncedRecipients)
325 case EventComplaint:
326 ev.Detail.ComplaintType = cleanField(raw.Complaint.ComplaintFeedbackType, maxDetailLen)
327 outcomes(raw.Complaint.ComplainedRecipients)
328 case EventDelivery:
329 said := cleanField(raw.Delivery.SMTPResponse, maxDiagnosticLen)
330 for _, to := range raw.Delivery.Recipients {
331 if to = cleanField(to, maxAddressLen); to != "" {
332 ev.Outcomes = append(ev.Outcomes, Outcome{Recipient: to, Diagnostic: said})
333 }
334 }
335 case EventReject:
336 ev.Detail.RejectReason = cleanField(raw.Reject.Reason, maxDiagnosticLen)
337 case EventDeliveryDelay:
338 ev.Detail.DelayType = cleanField(raw.DeliveryDelay.DelayType, maxDetailLen)
339 outcomes(raw.DeliveryDelay.DelayedRecipients)
340 }
341}
342
343// Recipients is who this event is about, one entry per address: the
344// recipients it reports an outcome for, or — for an event about the whole
345// message, a Send or a Reject — everyone it was addressed to. A per-recipient
346// event (a bounce, a delivery, a complaint, a delay) that names nobody is NOT
347// widened to every addressee: that would report a bounce for addresses that
348// did not bounce. It, like any event that names nobody at all, yields a
349// single empty entry, so it is still recorded once rather than not at all.
350func (ev Event) Recipients() []Outcome {
351 if len(ev.Outcomes) > 0 {
352 return ev.Outcomes
353 }
354 wholeMessage := ev.Type == EventSend || ev.Type == EventReject
355 if len(ev.Destination) == 0 || !wholeMessage {
356 return []Outcome{{}}
357 }
358 out := make([]Outcome, 0, len(ev.Destination))
359 for _, to := range ev.Destination {
360 out = append(out, Outcome{Recipient: to})
361 }
362 return out
363}
364
365// Summary is Detail in a few words, for a table: "Permanent/NoEmail",
366// "abuse", "Bad content". Empty when the event has nothing to add.
367func (d EventDetail) Summary() string {
368 switch {
369 case d.BounceType != "" && d.BounceSubType != "":
370 return d.BounceType + "/" + d.BounceSubType
371 case d.BounceType != "":
372 return d.BounceType
373 case d.ComplaintType != "":
374 return d.ComplaintType
375 case d.RejectReason != "":
376 return d.RejectReason
377 }
378 return d.DelayType
379}
380