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
3// The per-message email log: what happened to each message an app sent, to
4// whom, and what the receiving server said. The counts in mailcounts.go are
5// the abuse signal and stay exactly as they were; this sits beside them and
6// is fed from the same verified SES notifications, one row per recipient
7// per notification.
8
9import (
10 "context"
11 "database/sql"
12 "strings"
13 "time"
14
15 "github.com/carlosframework/platform/internal/mail"
16)
17
18// mailEventsRetention is how long a row is kept. The log carries recipient
19// addresses — somebody's guests, customers, users — so it is a window, not
20// an archive: long enough to answer "did that email last week arrive", no
21// longer.
22const mailEventsRetention = 30 * 24 * time.Hour
23
24// mailEventsPruneEvery bounds how often the prune runs. It is one indexed
25// DELETE, but it would otherwise run once per notification, and an hour's
26// slack on a thirty-day window changes nothing anybody can see.
27const mailEventsPruneEvery = time.Hour
28
29// MailEvent is one row of the log.
30type MailEvent struct {
31 MessageID string `json:"message_id"`
32 Type string `json:"type"`
33 Recipient string `json:"recipient"`
34 Status string `json:"status,omitempty"`
35 Diagnostic string `json:"diagnostic,omitempty"`
36 Detail string `json:"detail,omitempty"`
37 BySender bool `json:"by_sender"`
38 At time.Time `json:"at"`
39}
40
41// RecordMailMessageEvent keeps one notification for one app: a row per
42// recipient it concerns. notice is SNS's MessageId for the notification,
43// and a notification SNS delivers twice writes nothing the second time.
44// bySender says whether SES itself named this app as the sender, rather
45// than the app being one of several that share the sending domain.
46func (s *Store) RecordMailMessageEvent(ctx context.Context, acct, app, notice string, ev mail.Event, bySender bool, now time.Time) error {
47 at := ev.At
48 if at.IsZero() {
49 at = now
50 }
51 notice = mailNoticeKey(notice, ev, now)
52 sender := 0
53 if bySender {
54 sender = 1
55 }
56 detail := ev.Detail.Summary()
57 if recipients := ev.Recipients(); len(recipients) > 0 {
58 // acct/app's rows only, under that app's permit (claim_mail_sql.go).
59 err := s.withMailAppSQL(ctx, acct, app, "sql.RecordMailMessageEvent", func(v *Store) error {
60 for _, o := range recipients {
61 if _, err := v.db.ExecContext(ctx, `INSERT OR IGNORE INTO mail_events
62 (account, app, notice, recipient, message_id, type, status, diagnostic, detail, by_sender, at, received_at)
63VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
64 acct, app, notice, o.Recipient, ev.MessageID, ev.Type, o.Status, o.Diagnostic, detail, sender,
65 at.Unix(), now.Unix()); err != nil {
66 return err
67 }
68 }
69 return nil
70 })
71 if err != nil {
72 return err
73 }
74 }
75 // After the app's own write and outside it: the prune touches other
76 // apps' rows, each under its own permit.
77 return s.pruneMailEvents(ctx, now)
78}
79
80// mailNoticeKey is the key a notification is kept and queued under: SNS's
81// own MessageId, or — for a notification with no id of its own, which is
82// nothing SNS sends but the recording path is reachable directly — what it
83// says, so a repeat still collapses and two different notifications do not.
84// The log and the push queue share it, so the id an app is handed is the id
85// the log has.
86func mailNoticeKey(notice string, ev mail.Event, now time.Time) string {
87 if notice != "" {
88 return notice
89 }
90 at := ev.At
91 if at.IsZero() {
92 at = now
93 }
94 var to []string
95 for _, o := range ev.Recipients() {
96 to = append(to, o.Recipient)
97 }
98 // Two bounces for one message — one per recipient, or a delay reported
99 // twice — are two notifications, so what they are about is in the key.
100 return ev.MessageID + "/" + ev.Type + "/" + at.UTC().Format(time.RFC3339Nano) + "/" + strings.Join(to, ",")
101}
102
103// mailAppTables are the tables keyed by an app's (account sqid, name) that
104// belong to that app alone: a name that is freed must not hand them on.
105//
106// mail_sender_counts joins the list (senders design §8), which also closes
107// the gap where a rename left an app's counts behind under its old name.
108// It cannot join blindly, though: every sweep here keys on a TIMESTAMP
109// column and this table has a day string instead, so it gets the explicit
110// case in mailAppTimeColumn and carryMailApp below. mail_counts is
111// deliberately not here: nothing writes it any more and
112// DropLegacyMailCounts removes it whole.
113var mailAppTables = []string{"mail_events", "mail_event_hooks", "mail_event_deliveries", "mail_sender_counts"}
114
115// execer is what carryMailApp and forgetMailApp need from a transaction.
116type execer interface {
117 ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
118}
119
120// carryMailApp moves an app's email history with the app, when a rename or
121// a move to another account changes the (sqid, name) it is keyed by. Rows
122// already at the destination belong to a purged predecessor of that name
123// and are dropped first, and only rows received since this app's own row was
124// created travel — the same rule RenameAppIn applies to audit rows, for the
125// same reason. Whatever is left under the old key is dropped.
126//
127// Across accounts, a row attributed only by its sending domain (by_sender =
128// 0) does not travel. Such a row was given to every app on the domain because
129// SES did not say which of them sent the message, so it may name another
130// app's recipient — and that app stays behind in the source account. Within
131// one account both kinds carry, since the same members could read both
132// before and after.
133func carryMailApp(ctx context.Context, tx execer, fromSqid, fromApp, toSqid, toApp string, createdAt int64) error {
134 for _, table := range mailAppTables {
135 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, toSqid, toApp); err != nil {
136 return err
137 }
138 if table == "mail_events" && fromSqid != toSqid {
139 if _, err := tx.ExecContext(ctx, `DELETE FROM mail_events WHERE account = ? AND app = ? AND by_sender = 0`, fromSqid, fromApp); err != nil {
140 return err
141 }
142 }
143 if table == "mail_event_deliveries" {
144 // Not carried: a queued body names the account and app it was
145 // cut for, so after a move or a rename it names neither, and an
146 // app checking its host is right to refuse it. The final DELETE
147 // below drops them.
148 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, fromSqid, fromApp); err != nil {
149 return err
150 }
151 continue
152 }
153 if _, err := tx.ExecContext(ctx, `UPDATE `+table+` SET account = ?, app = ? WHERE account = ? AND app = ? AND `+
154 mailAppTimeColumn(table)+` >= ?`, toSqid, toApp, fromSqid, fromApp, mailAppTimeSince(table, createdAt)); err != nil {
155 return err
156 }
157 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, fromSqid, fromApp); err != nil {
158 return err
159 }
160 }
161 return nil
162}
163
164// forgetMailAccount drops everything an account's apps left in these tables,
165// when the account itself is reaped. Nothing can reach the rows afterwards —
166// the sqid is tombstoned and never reused — but they are recipients'
167// addresses, and a window is not an archive.
168func forgetMailAccount(ctx context.Context, tx execer, sqid string) error {
169 for _, table := range mailAppTables {
170 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ?`, sqid); err != nil {
171 return err
172 }
173 }
174 return nil
175}
176
177// forgetMailApp drops everything a purged app left in these tables, so a
178// later app of the same name starts with none of it.
179func forgetMailApp(ctx context.Context, tx execer, sqid, app string) error {
180 for _, table := range mailAppTables {
181 if _, err := tx.ExecContext(ctx, `DELETE FROM `+table+` WHERE account = ? AND app = ?`, sqid, app); err != nil {
182 return err
183 }
184 }
185 return nil
186}
187
188// mailAppTimeColumn is the column that says when a row came to be. Most are
189// unix seconds; mail_sender_counts keys on a UTC date string, which compares
190// correctly against another ISO date and against nothing else.
191func mailAppTimeColumn(table string) string {
192 switch table {
193 case "mail_events":
194 return "received_at"
195 case "mail_event_hooks":
196 return "set_at"
197 case "mail_sender_counts":
198 return "day"
199 }
200 return "created_at"
201}
202
203// mailAppTimeSince renders createdAt in whatever mailAppTimeColumn(table)
204// compares against: a unix stamp for a timestamp column, a UTC date string
205// for the one day column. Comparing a unix integer against a 'YYYY-MM-DD'
206// string in SQLite is not an error, it is simply always false, so getting
207// this wrong would silently carry nothing.
208func mailAppTimeSince(table string, createdAt int64) any {
209 if mailAppTimeColumn(table) == "day" {
210 return time.Unix(createdAt, 0).UTC().Format("2006-01-02")
211 }
212 return createdAt
213}
214
215// pruneMailEvents trims the log to its retention window, at most once per
216// mailEventsPruneEvery. The stamp is taken before the DELETE runs, so two
217// notifications arriving together do not both prune.
218func (s *Store) pruneMailEvents(ctx context.Context, now time.Time) error {
219 last := s.mailEventsPruned.Load()
220 if last != 0 && now.Unix()-last < int64(mailEventsPruneEvery/time.Second) {
221 return nil
222 }
223 if !s.mailEventsPruned.CompareAndSwap(last, now.Unix()) {
224 return nil
225 }
226 if s.gate != nil && s.bound == nil {
227 // Gated: one app at a time, each under its own permit. The stamp
228 // stays on a partial failure: an app that refuses (an operation
229 // holds it) is retried next window, not on every notification,
230 // which would open an operation per app per event.
231 return s.pruneMailAppRows(ctx, "mail_events", "received_at", now.Add(-mailEventsRetention).Unix(), "sql.PruneMailEvents")
232 }
233 if _, err := s.db.ExecContext(ctx, `DELETE FROM mail_events WHERE received_at < ?`, now.Add(-mailEventsRetention).Unix()); err != nil {
234 // Not pruned, so not stamped: the next notification tries again
235 // rather than waiting out the hour.
236 s.mailEventsPruned.CompareAndSwap(now.Unix(), last)
237 return err
238 }
239 return nil
240}
241
242// MailEventQuery narrows a read of the log. The zero value is the newest
243// mailEventsDefaultLimit rows of the whole window.
244type MailEventQuery struct {
245 Since time.Time // only rows SES timestamped at or after this
246 MessageID string // only this message
247 Recipient string // only this address, case-insensitively
248 Limit int
249}
250
251const (
252 mailEventsDefaultLimit = 100
253 mailEventsMaxLimit = 1000
254)
255
256// MailEvents reads one app's log, newest first. A row older than the
257// retention window is never returned, whether or not the prune has reached
258// it yet — what a member is told is kept for thirty days has to be what
259// they can see.
260func (s *Store) MailEvents(ctx context.Context, acct, app string, q MailEventQuery, now time.Time) ([]MailEvent, error) {
261 limit := q.Limit
262 if limit <= 0 {
263 limit = mailEventsDefaultLimit
264 }
265 if limit > mailEventsMaxLimit {
266 limit = mailEventsMaxLimit
267 }
268 where := []string{"account = ?", "app = ?", "received_at >= ?"}
269 args := []any{acct, app, now.Add(-mailEventsRetention).Unix()}
270 if !q.Since.IsZero() {
271 where = append(where, "at >= ?")
272 args = append(args, q.Since.Unix())
273 }
274 if q.MessageID != "" {
275 where = append(where, "message_id = ?")
276 args = append(args, q.MessageID)
277 }
278 if q.Recipient != "" {
279 where = append(where, "recipient = ? COLLATE NOCASE")
280 args = append(args, q.Recipient)
281 }
282 args = append(args, limit)
283 rows, err := s.db.QueryContext(ctx, `SELECT message_id, type, recipient, status, diagnostic, detail, by_sender, at
284FROM mail_events WHERE `+strings.Join(where, " AND ")+`
285ORDER BY at DESC, received_at DESC, recipient LIMIT ?`, args...)
286 if err != nil {
287 return nil, err
288 }
289 defer rows.Close()
290 var out []MailEvent
291 for rows.Next() {
292 var e MailEvent
293 var at int64
294 var sender int
295 if err := rows.Scan(&e.MessageID, &e.Type, &e.Recipient, &e.Status, &e.Diagnostic, &e.Detail, &sender, &at); err != nil {
296 return nil, err
297 }
298 e.BySender = sender == 1
299 e.At = time.Unix(at, 0).UTC()
300 out = append(out, e)
301 }
302 if err := rows.Err(); err != nil && err != sql.ErrNoRows {
303 return nil, err
304 }
305 return out, nil
306}
307