carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "strconv"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/mail"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16func bounceTo(recipient, message string, at time.Time) mail.Event {
17 return mail.Event{Type: mail.EventBounce, Domain: "lgc.li", MessageID: message, At: at,
18 Outcomes: []mail.Outcome{{Recipient: recipient, Status: "5.1.1", Diagnostic: "550 5.1.1 no such user"}},
19 Detail: mail.EventDetail{BounceType: "Permanent", BounceSubType: "NoEmail"}}
20}
21
22// A bounce is kept as what it was: which message, to whom, and what the
23// receiving server answered — and a notification SNS delivers twice is
24// still one row.
25func TestTheMailLogKeepsEachRecipientsOutcomeOnce(t *testing.T) {
26 fx := newConsoleFixture(t)
27 ctx := context.Background()
28 now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
29 ev := bounceTo("ann@example.com", "0102-ann", now.Add(-time.Minute))
30
31 for i := 0; i < 2; i++ {
32 if err := fx.st.RecordMailMessageEvent(ctx, "beb", "jb-book", "sns-1", ev, true, now); err != nil {
33 t.Fatal(err)
34 }
35 }
36 got, err := fx.st.MailEvents(ctx, "beb", "jb-book", MailEventQuery{}, now)
37 if err != nil {
38 t.Fatal(err)
39 }
40 if len(got) != 1 {
41 t.Fatalf("rows = %d, want 1 — SNS redelivered one notification: %+v", len(got), got)
42 }
43 e := got[0]
44 if e.MessageID != "0102-ann" || e.Type != mail.EventBounce || e.Recipient != "ann@example.com" ||
45 e.Status != "5.1.1" || e.Detail != "Permanent/NoEmail" || !e.BySender {
46 t.Errorf("row = %+v", e)
47 }
48 if !strings.Contains(e.Diagnostic, "no such user") {
49 t.Errorf("the receiving server's answer was not kept: %q", e.Diagnostic)
50 }
51}
52
53func TestTheMailLogFindsAMessageOrAnAddress(t *testing.T) {
54 fx := newConsoleFixture(t)
55 ctx := context.Background()
56 now := time.Date(2026, 9, 15, 9, 0, 0, 0, time.UTC)
57 for i, c := range []struct{ to, id string }{
58 {"ann@example.com", "m-ann"}, {"bob@example.com", "m-bob"}, {"ann@example.com", "m-ann-2"},
59 } {
60 ev := bounceTo(c.to, c.id, now.Add(-time.Duration(3-i)*time.Hour))
61 if err := fx.st.RecordMailMessageEvent(ctx, "beb", "jb-book", "sns-"+c.id, ev, true, now); err != nil {
62 t.Fatal(err)
63 }
64 }
65 for _, c := range []struct {
66 name string
67 q MailEventQuery
68 want []string
69 }{
70 {"everything, newest first", MailEventQuery{}, []string{"m-ann-2", "m-bob", "m-ann"}},
71 {"one message", MailEventQuery{MessageID: "m-bob"}, []string{"m-bob"}},
72 {"one address, however it is typed", MailEventQuery{Recipient: "ANN@Example.com"}, []string{"m-ann-2", "m-ann"}},
73 {"since", MailEventQuery{Since: now.Add(-150 * time.Minute)}, []string{"m-ann-2", "m-bob"}},
74 {"a limit", MailEventQuery{Limit: 1}, []string{"m-ann-2"}},
75 } {
76 t.Run(c.name, func(t *testing.T) {
77 got, err := fx.st.MailEvents(ctx, "beb", "jb-book", c.q, now)
78 if err != nil {
79 t.Fatal(err)
80 }
81 var ids []string
82 for _, e := range got {
83 ids = append(ids, e.MessageID)
84 }
85 if strings.Join(ids, ",") != strings.Join(c.want, ",") {
86 t.Errorf("got %v, want %v", ids, c.want)
87 }
88 })
89 }
90 if other, _ := fx.st.MailEvents(ctx, "beb", "another-app", MailEventQuery{}, now); len(other) != 0 {
91 t.Errorf("another app read this app's log: %+v", other)
92 }
93}
94
95// The log holds recipients' addresses, so it is a window. A row past it is
96// neither shown nor kept.
97func TestTheMailLogForgetsAfterThirtyDays(t *testing.T) {
98 fx := newConsoleFixture(t)
99 ctx := context.Background()
100 then := time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
101 if err := fx.st.RecordMailMessageEvent(ctx, "beb", "jb-book", "sns-old", bounceTo("ann@example.com", "m-old", then), true, then); err != nil {
102 t.Fatal(err)
103 }
104 now := then.Add(mailEventsRetention + time.Hour)
105 if got, _ := fx.st.MailEvents(ctx, "beb", "jb-book", MailEventQuery{}, now); len(got) != 0 {
106 t.Errorf("a row past the window was shown: %+v", got)
107 }
108 // The next notification prunes it for good.
109 if err := fx.st.RecordMailMessageEvent(ctx, "beb", "jb-book", "sns-new", bounceTo("bob@example.com", "m-new", now), true, now); err != nil {
110 t.Fatal(err)
111 }
112 var n int
113 if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_events WHERE message_id = 'm-old'`).Scan(&n); err != nil {
114 t.Fatal(err)
115 }
116 if n != 0 {
117 t.Errorf("the old row is still in the table")
118 }
119}
120
121// Two apps can send from one domain. The log is narrower wherever SES names
122// the sender, because a recipient's address is one app's business, not
123// every app's that happens to share a From domain; the counts follow the
124// same caller-named sender now (design §8) and fall back to charging every
125// app on the domain only when SES names nobody CARLOS minted.
126func TestTheMailLogGoesToTheAppSESSaysSentIt(t *testing.T) {
127 fx := newConsoleFixture(t)
128 ctx := context.Background()
129 now := time.Now()
130 for _, app := range []string{"jb-book", "jb-site"} {
131 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now); err != nil {
132 t.Fatal(err)
133 }
134 }
135 rec := &mail.DomainRecord{
136 Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book", "jb-site"},
137 Regions: map[string]*mail.RegionState{"eu-west-1": {
138 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", MailFrom: "mail-eu-west-1.lgc.li", Status: mail.StatusVerified,
139 }},
140 CreatedAt: now,
141 }
142 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
143 t.Fatal(err)
144 }
145 logOf := func(app string) []MailEvent {
146 t.Helper()
147 got, err := fx.st.MailEvents(ctx, release.OpsAccount, app, MailEventQuery{}, time.Now())
148 if err != nil {
149 t.Fatal(err)
150 }
151 return got
152 }
153
154 named := bounceTo("ann@example.com", "m-named", now)
155 named.Caller = mail.UserName(release.OpsAccount, "jb-book")
156 fx.srv.recordAndEnforceMail(ctx, named, "sns-named")
157
158 if got := logOf("jb-book"); len(got) != 1 || !got[0].BySender {
159 t.Errorf("the app SES named should have the row, marked as its own: %+v", got)
160 }
161 if got := logOf("jb-site"); len(got) != 0 {
162 t.Errorf("the other app on the domain was shown another app's recipient: %+v", got)
163 }
164 // THE VISIBLE CHANGE, and it belongs in the roll note (design §8).
165 // Before senders, every app on a shared domain was charged for every
166 // event on that domain. Now only the app SES named is charged, so a
167 // sibling's cap stops being consumed by another app's sends.
168 day := now.UTC().Format("2006-01-02")
169 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "", day); c.Bounces != 1 {
170 t.Errorf("jb-book bounces = %d, want the 1 SES attributed to it", c.Bounces)
171 }
172 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-site", "", day); c.Bounces != 0 {
173 t.Errorf("jb-site bounces = %d, want 0 — a sibling on the same domain is no longer charged", c.Bounces)
174 }
175
176 // SES naming one of CARLOS's own identities that is not a live app gives
177 // the row to nobody — the name is the sender, and it is not here to be
178 // told. It does NOT fall back to the domain's other apps.
179 stray := bounceTo("eve@example.com", "m-stray", now)
180 stray.Caller = mail.UserName(release.OpsAccount, "not-an-app")
181 fx.srv.recordAndEnforceMail(ctx, stray, "sns-stray")
182 for _, app := range []string{"jb-book", "jb-site"} {
183 for _, e := range logOf(app) {
184 if e.MessageID == "m-stray" {
185 t.Errorf("%s was given a row SES attributed to another CARLOS app: %+v", app, e)
186 }
187 }
188 }
189
190 // SES naming nobody CARLOS minted falls back to the domain, and says so
191 // on the row.
192 for i, caller := range []string{"", "someone-else"} {
193 ev := bounceTo("bob@example.com", "m-unnamed", now)
194 ev.Caller = caller
195 fx.srv.recordAndEnforceMail(ctx, ev, "sns-unnamed-"+string(rune('a'+i)))
196 }
197 for _, app := range []string{"jb-book", "jb-site"} {
198 var unnamed []MailEvent
199 for _, e := range logOf(app) {
200 if e.MessageID == "m-unnamed" {
201 unnamed = append(unnamed, e)
202 }
203 }
204 if len(unnamed) != 2 {
205 t.Errorf("%s: %d domain-attributed rows, want 2", app, len(unnamed))
206 }
207 for _, e := range unnamed {
208 if e.BySender {
209 t.Errorf("%s: a row attributed by domain claims SES named the sender: %+v", app, e)
210 }
211 }
212 }
213
214 // An unknown caller keeps today's behaviour exactly: the domain is all
215 // there is to go on, so every app on it is charged.
216 for _, app := range []string{"jb-book", "jb-site"} {
217 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, app, "", day); c.Bounces < 2 {
218 t.Errorf("%s bounces = %d, want the two domain-attributed ones", app, c.Bounces)
219 }
220 }
221
222 // The stray caller parses into a well-formed account and app, so it is
223 // "named" — but "not-an-app" is not a live app, and a count row keyed on
224 // a name no app answers to is a row nothing reads and nothing reaps.
225 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "not-an-app", "", day); c.Bounces != 0 {
226 t.Errorf("a name that is not an app was given %d bounces", c.Bounces)
227 }
228}
229
230// An event whose caller names a slugged sender is charged to that sender
231// alone, resolved through the caller index rather than by parsing.
232func TestAnIndexedCallerIsChargedToItsOwnSender(t *testing.T) {
233 fx := newConsoleFixture(t)
234 ctx := context.Background()
235 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
236 if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil {
237 t.Fatal(err)
238 }
239 names, err := mail.SenderNames(release.OpsAccount, "jb-book", "book.example.com", "")
240 if err != nil {
241 t.Fatal(err)
242 }
243 if err := mail.PutCallerIndex(ctx, fx.rel, names.IAMUser, release.OpsAccount, "jb-book", "book.example.com", now); err != nil {
244 t.Fatal(err)
245 }
246 ev := bounceTo("ann@example.com", "m-instance", now)
247 // bounceTo sets At but not SentAt, and the count buckets by SentAt
248 // (falling back to time.Now() when it's zero) — set it explicitly so
249 // this test's fixed date is the day the count actually lands in,
250 // rather than depending on which real day the suite happens to run.
251 ev.SentAt = now
252 ev.Caller = names.IAMUser
253 fx.srv.recordAndEnforceMail(ctx, ev, "sns-instance")
254
255 day := now.UTC().Format("2006-01-02")
256 one, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", "book.example.com", day)
257 if one.Bounces != 1 {
258 t.Fatalf("the named sender's counts = %+v, want 1 bounce", one)
259 }
260 appSender, _ := fx.st.MailCounts(ctx, release.OpsAccount, "jb-book", mail.SenderAppID, day)
261 if appSender.Bounces != 0 {
262 t.Fatalf("the app sender was charged for an instance sender's bounce: %+v", appSender)
263 }
264}
265
266// A trashed app is told nothing new, and a name that is freed — by a purge,
267// a rename or a move to another account — hands none of its email history
268// to whatever holds the name next. The history of an app that is renamed or
269// moved goes with it.
270func TestTheMailLogBelongsToTheAppNotItsName(t *testing.T) {
271 fx := newConsoleFixture(t)
272 ctx := context.Background()
273 now := time.Now()
274 logOf := func(acct, app string) int {
275 t.Helper()
276 got, err := fx.st.MailEvents(ctx, acct, app, MailEventQuery{}, time.Now())
277 if err != nil {
278 t.Fatal(err)
279 }
280 return len(got)
281 }
282 send := func(app, message string) {
283 t.Helper()
284 ev := bounceTo("ann@example.com", message, now)
285 ev.Caller = mail.UserName(release.OpsAccount, app)
286 fx.srv.recordAndEnforceMail(ctx, ev, "sns-"+message)
287 }
288 for _, app := range []string{"harbour", "renamed-from", "moving"} {
289 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now.Add(-time.Hour)); err != nil {
290 t.Fatal(err)
291 }
292 }
293
294 // Trashed: nothing new is kept.
295 send("harbour", "m-live")
296 if err := fx.st.TrashAppRow(OpsAccountID, "harbour", now); err != nil {
297 t.Fatal(err)
298 }
299 send("harbour", "m-trashed")
300 if n := logOf(release.OpsAccount, "harbour"); n != 1 {
301 t.Errorf("harbour has %d rows, want 1 — nothing arriving after the trash is kept", n)
302 }
303
304 // Purged: the name starts clean for the next app to claim it.
305 if err := fx.st.PurgeAppRow(OpsAccountID, "harbour"); err != nil {
306 t.Fatal(err)
307 }
308 if n := logOf(release.OpsAccount, "harbour"); n != 0 {
309 t.Errorf("a purged app's history is still there for its name: %d rows", n)
310 }
311
312 // Renamed: the history goes with the app, and the old name holds none.
313 send("renamed-from", "m-before-rename")
314 if err := fx.st.RenameAppIn(OpsAccountID, "renamed-from", "renamed-to"); err != nil {
315 t.Fatal(err)
316 }
317 if logOf(release.OpsAccount, "renamed-to") != 1 || logOf(release.OpsAccount, "renamed-from") != 0 {
318 t.Errorf("after a rename: to=%d from=%d, want 1 and 0",
319 logOf(release.OpsAccount, "renamed-to"), logOf(release.OpsAccount, "renamed-from"))
320 }
321
322 // Moved to another account: the same, keyed by the new account's sqid.
323 send("moving", "m-before-move")
324 if err := fx.st.CreateAccount(99, "dest", "Destination", "bill@tito.io", now); err != nil {
325 t.Fatal(err)
326 }
327 if err := fx.st.MoveApp("moving", OpsAccountID, 99); err != nil {
328 t.Fatal(err)
329 }
330 if logOf("dest", "moving") != 1 || logOf(release.OpsAccount, "moving") != 0 {
331 t.Errorf("after a move: dest=%d ops=%d, want 1 and 0", logOf("dest", "moving"), logOf(release.OpsAccount, "moving"))
332 }
333}
334
335// A bounce, a complaint or a delivery can arrive long after its message was
336// sent, and the rate limiter divides a day's bounces by that same day's
337// sends. So the counts file an event under the day it was SENT, whatever
338// day it arrives — a late bounce landing on the new day would read as a
339// bounce rate over a handful of sends and pause a healthy tenant.
340func TestALateBounceCountsAgainstTheDayItWasSent(t *testing.T) {
341 fx := newConsoleFixture(t)
342 ctx := context.Background()
343 now := time.Now()
344 if _, err := fx.st.CreateAppIn(OpsAccountID, "harbour", "bill@tito.io", now); err != nil {
345 t.Fatal(err)
346 }
347 rec := &mail.DomainRecord{
348 Domain: "harbour.example", Kind: mail.KindCustom, Apps: []string{"harbour"},
349 Regions: map[string]*mail.RegionState{"eu-west-1": {
350 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/harbour.example", Status: mail.StatusVerified,
351 }},
352 CreatedAt: now,
353 }
354 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
355 t.Fatal(err)
356 }
357 sent := time.Date(2026, 9, 14, 23, 50, 0, 0, time.UTC)
358 ev := bounceTo("ann@example.com", "m-late", sent.Add(15*time.Minute)) // bounces at 00:05 the next day
359 ev.Domain, ev.SentAt = "harbour.example", sent
360 fx.srv.recordAndEnforceMail(ctx, ev, "sns-late")
361
362 for day, want := range map[string]int{"2026-09-14": 1, "2026-09-15": 0} {
363 c, err := fx.st.MailCounts(ctx, release.OpsAccount, "harbour", "", day)
364 if err != nil {
365 t.Fatal(err)
366 }
367 if c.Bounces != want {
368 t.Errorf("bounces on %s = %d, want %d — counted against the day it arrived, not the day it was sent", day, c.Bounces, want)
369 }
370 }
371}
372
373// A row attributed only by a shared sending domain may name another app's
374// recipient. It stays in the account where that other app lives: a move to
375// another account carries only the rows SES said were the app's own. A
376// rename within the account carries both, since the same members could read
377// both before and after.
378func TestAMoveLeavesDomainAttributedRowsBehind(t *testing.T) {
379 fx := newConsoleFixture(t)
380 ctx := context.Background()
381 now := time.Now()
382 for _, app := range []string{"moving", "renaming"} {
383 if _, err := fx.st.CreateAppIn(OpsAccountID, app, "bill@tito.io", now.Add(-time.Hour)); err != nil {
384 t.Fatal(err)
385 }
386 for i, bySender := range []bool{true, false} {
387 ev := bounceTo("ann@example.com", app+"-"+strconv.FormatBool(bySender), now)
388 if err := fx.st.RecordMailMessageEvent(ctx, release.OpsAccount, app, "sns-"+app+strconv.Itoa(i), ev, bySender, now); err != nil {
389 t.Fatal(err)
390 }
391 }
392 }
393 if err := fx.st.CreateAccount(99, "dest", "Destination", "bill@tito.io", now); err != nil {
394 t.Fatal(err)
395 }
396 if err := fx.st.MoveApp("moving", OpsAccountID, 99); err != nil {
397 t.Fatal(err)
398 }
399 moved, _ := fx.st.MailEvents(ctx, "dest", "moving", MailEventQuery{}, time.Now())
400 if len(moved) != 1 || !moved[0].BySender {
401 t.Errorf("a move to another account carried %+v, want only the app's own row", moved)
402 }
403 if left, _ := fx.st.MailEvents(ctx, release.OpsAccount, "moving", MailEventQuery{}, time.Now()); len(left) != 0 {
404 t.Errorf("rows were left under the old account's key: %+v", left)
405 }
406
407 if err := fx.st.RenameAppIn(OpsAccountID, "renaming", "renamed"); err != nil {
408 t.Fatal(err)
409 }
410 if renamed, _ := fx.st.MailEvents(ctx, release.OpsAccount, "renamed", MailEventQuery{}, time.Now()); len(renamed) != 2 {
411 t.Errorf("a rename within the account carried %d rows, want both", len(renamed))
412 }
413}
414
415// A reaped account's email history goes with it.
416func TestReapingAnAccountForgetsItsMailLog(t *testing.T) {
417 fx := newConsoleFixture(t)
418 ctx := context.Background()
419 now := time.Now()
420 if err := fx.st.CreateAccount(99, "reaped", "Going", "bill@tito.io", now); err != nil {
421 t.Fatal(err)
422 }
423 if err := fx.st.RecordMailMessageEvent(ctx, "reaped", "gone", "sns-1", bounceTo("ann@example.com", "m", now), true, now); err != nil {
424 t.Fatal(err)
425 }
426 if err := fx.st.ReapAccount(99, now); err != nil {
427 t.Fatal(err)
428 }
429 var n int
430 fx.st.db.QueryRow(`SELECT count(*) FROM mail_events WHERE account = 'reaped'`).Scan(&n)
431 if n != 0 {
432 t.Errorf("%d rows outlived the account", n)
433 }
434}
435
436// A prune that fails is not stamped, so the next notification tries again
437// rather than waiting out the hour.
438func TestAFailedPruneIsTriedAgain(t *testing.T) {
439 fx := newConsoleFixture(t)
440 ctx, cancel := context.WithCancel(context.Background())
441 cancel()
442 if err := fx.st.pruneMailEvents(ctx, time.Now()); err == nil {
443 t.Fatal("a prune with a cancelled context should fail")
444 }
445 if got := fx.st.mailEventsPruned.Load(); got != 0 {
446 t.Errorf("the stamp was left at %d after a failed prune", got)
447 }
448}
449
450// The log's domain fallback gives recipients' addresses only to a domain its
451// account has verified. The counts keep matching as they always did.
452func TestTheDomainFallbackNeedsAVerifiedDomain(t *testing.T) {
453 fx := newConsoleFixture(t)
454 ctx := context.Background()
455 now := time.Now()
456 if _, err := fx.st.CreateAppIn(OpsAccountID, "squatter", "bill@tito.io", now); err != nil {
457 t.Fatal(err)
458 }
459 rec := &mail.DomainRecord{
460 Domain: "mail-eu-west-1.lgc.li", Kind: mail.KindCustom, Apps: []string{"squatter"},
461 Regions: map[string]*mail.RegionState{"eu-west-1": {
462 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/mail-eu-west-1.lgc.li", Status: mail.StatusPending,
463 }},
464 CreatedAt: now,
465 }
466 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
467 t.Fatal(err)
468 }
469 ev := bounceTo("ann@example.com", "m-unverified", now)
470 ev.Domain, ev.SentAt = "mail-eu-west-1.lgc.li", now
471 fx.srv.recordAndEnforceMail(ctx, ev, "sns-unverified")
472 if got, _ := fx.st.MailEvents(ctx, release.OpsAccount, "squatter", MailEventQuery{}, time.Now()); len(got) != 0 {
473 t.Errorf("an unverified domain was given recipients' addresses: %+v", got)
474 }
475 if c, _ := fx.st.MailCounts(ctx, release.OpsAccount, "squatter", "", now.UTC().Format("2006-01-02")); c.Bounces != 1 {
476 t.Errorf("bounces = %d — the counts' matching is not this change's to narrow", c.Bounces)
477 }
478}
479
480// End to end through the SNS door: a signed notification lands in the log
481// under SNS's own MessageId, with what the receiving server said, and a
482// redelivery of the same notification is still one row.
483func TestANotificationThroughTheDoorLandsInTheLog(t *testing.T) {
484 fx := newConsoleFixture(t)
485 fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail"
486 fx.srv.snsVerify = func(_ snsEnvelope) error { return nil }
487 ctx := context.Background()
488 now := time.Now()
489 if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil {
490 t.Fatal(err)
491 }
492 rec := &mail.DomainRecord{
493 Domain: "lgc.li", Kind: mail.KindCustom, Apps: []string{"jb-book"},
494 Regions: map[string]*mail.RegionState{"eu-west-1": {
495 IdentityARN: "arn:aws:ses:eu-west-1:1:identity/lgc.li", Status: mail.StatusVerified,
496 }},
497 CreatedAt: now,
498 }
499 if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil {
500 t.Fatal(err)
501 }
502 message, _ := json.Marshal(map[string]any{
503 "eventType": "Bounce",
504 "mail": map[string]any{"source": "bookings@lgc.li", "messageId": "0102-door", "timestamp": now.UTC().Format(time.RFC3339),
505 "tags": map[string][]string{"ses:caller-identity": {mail.UserName(release.OpsAccount, "jb-book")}}},
506 "bounce": map[string]any{"bounceType": "Permanent", "bounceSubType": "NoEmail", "timestamp": now.UTC().Format("2006-01-02T15:04:05.000Z"),
507 "bouncedRecipients": []map[string]string{{"emailAddress": "ann@example.com", "status": "5.1.1", "diagnosticCode": "550 no such user"}}},
508 })
509 envelope, _ := json.Marshal(map[string]string{
510 "Type": "Notification", "MessageId": "sns-door-1", "TopicArn": "arn:aws:sns:eu-west-1:1:carlos-mail",
511 "Message": string(message), "SigningCertURL": "https://sns.eu-west-1.amazonaws.com/x.pem", "Signature": "x",
512 })
513 for i := 0; i < 2; i++ {
514 resp := sesPost(t, fx, string(envelope))
515 resp.Body.Close()
516 if resp.StatusCode != http.StatusOK {
517 t.Fatalf("status = %d", resp.StatusCode)
518 }
519 }
520 got, err := fx.st.MailEvents(ctx, release.OpsAccount, "jb-book", MailEventQuery{}, time.Now())
521 if err != nil {
522 t.Fatal(err)
523 }
524 if len(got) != 1 {
525 t.Fatalf("rows = %d, want 1: %+v", len(got), got)
526 }
527 e := got[0]
528 if e.MessageID != "0102-door" || e.Recipient != "ann@example.com" || e.Status != "5.1.1" || e.Detail != "Permanent/NoEmail" || !e.BySender {
529 t.Errorf("row = %+v", e)
530 }
531 var notice string
532 fx.st.db.QueryRow(`SELECT notice FROM mail_events WHERE message_id = '0102-door'`).Scan(&notice)
533 if notice != "sns-door-1" {
534 t.Errorf("notice = %q, want SNS's own MessageId", notice)
535 }
536}
537
538// A sender's counts belong to the app, not to its name. A rename carries
539// them; a purge drops them, so the next app of that name starts at zero;
540// an account reap takes the lot. The comparison is on the day STRING,
541// because this table has no timestamp column for mailAppTimeColumn to name.
542func TestSenderCountsFollowTheAppAndDieWithIt(t *testing.T) {
543 fx := newConsoleFixture(t)
544 ctx := context.Background()
545 now := time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC)
546 yesterday := now.AddDate(0, 0, -1).UTC().Format("2006-01-02")
547 today := now.UTC().Format("2006-01-02")
548
549 seed := func(app, sender, day string) {
550 t.Helper()
551 if err := fx.st.RecordMailEvent(ctx, release.OpsAccount, app, sender, day, mail.EventSend); err != nil {
552 t.Fatal(err)
553 }
554 }
555 countRows := func(app string) int {
556 t.Helper()
557 var n int
558 if err := fx.st.db.QueryRow(`SELECT count(*) FROM mail_sender_counts WHERE account = ? AND app = ?`,
559 release.OpsAccount, app).Scan(&n); err != nil {
560 t.Fatal(err)
561 }
562 return n
563 }
564
565 if _, err := fx.st.CreateAppIn(OpsAccountID, "jb-book", "bill@tito.io", now); err != nil {
566 t.Fatal(err)
567 }
568 seed("jb-book", mail.SenderAppID, today)
569 seed("jb-book", "book.example.com", today)
570 // A row from BEFORE the app row was created belongs to a predecessor of
571 // the name and must not travel, the rule RenameAppIn already applies to
572 // audit rows.
573 seed("jb-book", mail.SenderAppID, yesterday)
574
575 tx, err := fx.st.db.Begin()
576 if err != nil {
577 t.Fatal(err)
578 }
579 if err := carryMailApp(ctx, tx, release.OpsAccount, "jb-book", release.OpsAccount, "jb-reader", now.Unix()); err != nil {
580 t.Fatal(err)
581 }
582 if err := tx.Commit(); err != nil {
583 t.Fatal(err)
584 }
585 if got := countRows("jb-reader"); got != 2 {
586 t.Fatalf("the rename carried %d rows, want the 2 from today", got)
587 }
588 if got := countRows("jb-book"); got != 0 {
589 t.Fatalf("%d rows were left under the old name", got)
590 }
591
592 tx, err = fx.st.db.Begin()
593 if err != nil {
594 t.Fatal(err)
595 }
596 if err := forgetMailApp(ctx, tx, release.OpsAccount, "jb-reader"); err != nil {
597 t.Fatal(err)
598 }
599 if err := tx.Commit(); err != nil {
600 t.Fatal(err)
601 }
602 if got := countRows("jb-reader"); got != 0 {
603 t.Fatalf("a purge left %d rows for the next app of that name", got)
604 }
605
606 seed("jb-reader", mail.SenderAppID, today)
607 tx, err = fx.st.db.Begin()
608 if err != nil {
609 t.Fatal(err)
610 }
611 if err := forgetMailAccount(ctx, tx, release.OpsAccount); err != nil {
612 t.Fatal(err)
613 }
614 if err := tx.Commit(); err != nil {
615 t.Fatal(err)
616 }
617 if got := countRows("jb-reader"); got != 0 {
618 t.Fatalf("the account reap left %d rows", got)
619 }
620}
621