carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Per-tenant send, bounce and complaint counts, and the limits they enforce
2// (tenant-email spec §9, §10).
3//
4// ENFORCEMENT IS REACTIVE. SES has no per-identity rate limit, so a cap can
5// only be applied by counting what has already been sent and then stopping
6// further sends. There are minutes of latency between the send and the
7// event, which means a cap of 50 is a cap of "about 50". That is stated
8// here rather than implied, because a caller who believes it is exact will
9// eventually be surprised by a bill or a complaint.
10package console
11
12import (
13 "context"
14 "fmt"
15 "log"
16 "time"
17
18 "github.com/carlosframework/platform/internal/mail"
19)
20
21// MailCounts is one app's activity for one UTC day.
22type MailCounts struct {
23 Sends int
24 Bounces int
25 Complaints int
26 Rejects int
27 // Deliveries is the denominator that makes a bounce count readable —
28 // "12 bounces" means something different against 50 delivered than
29 // against 50 sent and 12 delivered. Display-only, deliberately: see
30 // BounceRate below and the migration's own note.
31 Deliveries int
32}
33
34// BounceRate and ComplaintRate are zero on a day with no sends — not NaN.
35// A NaN compares false against every threshold, so a divide-by-zero here
36// would present as a limiter that silently never trips.
37func (c MailCounts) BounceRate() float64 {
38 if c.Sends == 0 {
39 return 0
40 }
41 return float64(c.Bounces) / float64(c.Sends)
42}
43
44func (c MailCounts) ComplaintRate() float64 {
45 if c.Sends == 0 {
46 return 0
47 }
48 return float64(c.Complaints) / float64(c.Sends)
49}
50
51// The thresholds SES reviews an account on. Applied per tenant so one
52// tenant's stale list cannot put every tenant under review.
53const (
54 BounceThreshold = 0.05
55 ComplaintThreshold = 0.001
56 // MinSendsForRates stops a single bounce on a two-message day reading
57 // as a 50% bounce rate and pausing a healthy tenant.
58 MinSendsForRates = 50
59)
60
61// TierCap is the daily send cap for a tier.
62//
63// An untiered or unrecognised account gets the FREE cap, never an unlimited
64// one. That default is what lets this ship before the billing work lands,
65// and the direction matters: falling back to unlimited would hand every
66// unknown account an uncapped estate.
67func TierCap(tier string) int {
68 switch tier {
69 case "pro":
70 return 10000
71 case "starter":
72 return 1000
73 default:
74 return 50
75 }
76}
77
78// mailCountColumn maps an mail.Event* type to the column RecordMailEvent
79// increments. false means "not one of the four counted types" — the same
80// tolerance mail.ParseEvent extends to a type it does not recognise: SES
81// adds event types, and refusing one here would need to fail the whole
82// ingest rather than just skip counting it.
83func mailCountColumn(evType string) (string, bool) {
84 switch evType {
85 case mail.EventSend:
86 return "sends", true
87 case mail.EventBounce:
88 return "bounces", true
89 case mail.EventComplaint:
90 return "complaints", true
91 case mail.EventReject:
92 return "rejects", true
93 case mail.EventDelivery:
94 return "deliveries", true
95 default:
96 return "", false
97 }
98}
99
100// RecordMailEvent increments the one counter evType names for (acct, app,
101// sender, day). An event type this function does not know is silently
102// ignored — mirrored from mail.ParseEvent's own tolerance of unknown types,
103// for the same reason: SES adding a type must never turn into a 500 that
104// makes SNS retry forever.
105//
106// Writes mail_sender_counts alone. mail_counts stays on disk, unwritten,
107// until DropLegacyMailCounts removes it a release later.
108func (s *Store) RecordMailEvent(ctx context.Context, acct, app, sender, day, evType string) error {
109 col, ok := mailCountColumn(evType)
110 if !ok {
111 return nil
112 }
113 if sender == "" {
114 sender = mail.SenderAppID
115 }
116 q := fmt.Sprintf(`INSERT INTO mail_sender_counts (account, app, sender, day, %s)
117VALUES (?, ?, ?, ?, 1)
118ON CONFLICT(account, app, sender, day) DO UPDATE SET %s = %s + 1`, col, col, col)
119 // A write of acct/app's row alone, under that app's permit
120 // (claim_mail_sql.go).
121 return s.withMailAppSQL(ctx, acct, app, "sql.RecordMailEvent", func(v *Store) error {
122 _, err := v.db.ExecContext(ctx, q, acct, app, sender, day)
123 return err
124 })
125}
126
127// MailCounts returns one sender's counts for one UTC day. An EMPTY sender
128// reads every sender the app has, which is what the status reply's
129// unchanged top-level figures mean and what a roll-up of an app in
130// per-instance mode needs. A day with no rows at all is a legitimate zero.
131func (s *Store) MailCounts(ctx context.Context, acct, app, sender, day string) (MailCounts, error) {
132 q := `SELECT COALESCE(SUM(sends), 0), COALESCE(SUM(bounces), 0), COALESCE(SUM(complaints), 0),
133 COALESCE(SUM(rejects), 0), COALESCE(SUM(deliveries), 0)
134FROM mail_sender_counts WHERE account = ? AND app = ? AND day = ?`
135 args := []any{acct, app, day}
136 if sender != "" {
137 q += ` AND sender = ?`
138 args = append(args, sender)
139 }
140 var c MailCounts
141 // An aggregate always returns exactly one row, so there is no ErrNoRows
142 // branch to get wrong and a quiet day reads as zeroes by construction.
143 if err := s.db.QueryRowContext(ctx, q, args...).Scan(
144 &c.Sends, &c.Bounces, &c.Complaints, &c.Rejects, &c.Deliveries); err != nil {
145 return MailCounts{}, err
146 }
147 return c, nil
148}
149
150// resumeStaleCapPause is the ONE place the day-roll resume check lives
151// (R2, review follow-up). enforceMailLimits' only production caller is the
152// SES event path (recordAndEnforceMail): a paused app's IAM key is
153// deactivated, so it sends nothing, so no events ever arrive to run that
154// check, so an app alone on its own domain saw its cap pause as permanent
155// as before the cap/rate split — while the CLI now promises it clears
156// automatically. The email-status READ path (handleAPIEmailStatus) is the
157// other caller: a member running `carlos email status` is exactly the
158// moment a rolled-over daily budget should clear, and it is a moment that
159// happens without SES traffic.
160//
161// Both callers share this one function so they can never drift.
162//
163// Fail-safe by construction, matching "RATE AND MANUAL PAUSES NEVER
164// AUTO-RESUME" below: resumes ONLY when sn is currently paused (Active ==
165// false), the pause is a CAP pause on a sender that is not PerInstance
166// (PauseKind == mail.PauseKindCap — a RATE pause, a PER-INSTANCE pause or a
167// kind-less manual/pre-existing pause, PauseKind == "", all fall through
168// untouched), and the pause was applied on a UTC day STRICTLY EARLIER than
169// now — same-day resumes would make the cap meaningless.
170//
171// Returns the freshly re-read sender when it resumes (sn's SetSenderPaused
172// write moves Active, PauseKind and At, so callers must not patch the
173// in-memory copy by hand) and whether a resume happened; sn is passed
174// straight back, and resumed is false, in every other case, including
175// sn == nil or sn.Provisioned == nil.
176func (s *Server) resumeStaleCapPause(ctx context.Context, acct, app, sender string, sn *mail.Sender, now time.Time) (*mail.Sender, bool, error) {
177 if sn == nil || sn.Provisioned == nil || sn.Provisioned.Active {
178 return sn, false, nil
179 }
180 // A RATE pause, a MANUAL pause and a PER-INSTANCE pause all fall through
181 // untouched. The first two never auto-resume by design; the third must
182 // not, because resuming reactivates every key including the standalones
183 // and the app credential every instance still holds.
184 if sn.Provisioned.PauseKind != mail.PauseKindCap || sn.PerInstance {
185 return sn, false, nil
186 }
187 pausedDay := sn.Provisioned.At.UTC().Format("2006-01-02")
188 today := now.UTC().Format("2006-01-02")
189 if pausedDay >= today {
190 // Paused earlier TODAY (or a clock oddity puts the pause in the
191 // future) — not a day roll yet.
192 return sn, false, nil
193 }
194 if _, err := mail.SetSenderPaused(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct, app, sender, false, "", now); err != nil {
195 return nil, false, err
196 }
197 if a, err := s.store.AccountBySqid(acct); err != nil {
198 log.Printf("console: mail resume audit: resolving account %s: %v", acct, err)
199 } else if a != nil {
200 s.audit(a, app, "ses", "mail.resumed", fmt.Sprintf("%s: daily cap pause cleared on the UTC day roll", mailAuditSubject(app, sender)))
201 }
202 fresh, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
203 if err != nil {
204 return nil, false, err
205 }
206 return fresh, true, nil
207}
208
209// enforceMailLimits pauses a sender that has exceeded its daily cap or is
210// past either SES threshold, and reports why. The cap and the pause are the
211// sender's own, so one sender's breach leaves its siblings sending.
212//
213// The rate thresholds are applied only once MinSendsForRates messages have
214// gone out that day: without that floor, one bounce on a two-message morning
215// is a 50% bounce rate and a healthy tenant is paused before it has sent
216// anything real.
217//
218// A pause MUST be visible. It is written to the account's audit log with the
219// counts that caused it and surfaced on the app page — a silent pause
220// presents to the member as "email stopped working", which is the failure
221// this whole mechanism is supposed to prevent someone else's tenant causing.
222func (s *Server) enforceMailLimits(ctx context.Context, acct, app, sender string, now time.Time) (bool, string, error) {
223 sn, _, err := mail.GetSender(ctx, s.cfg.Releases, acct, app, sender)
224 if err != nil {
225 return false, "", err
226 }
227 if sn == nil || sn.Provisioned == nil {
228 return false, "", nil
229 }
230 if !sn.Provisioned.Active {
231 fresh, resumed, err := s.resumeStaleCapPause(ctx, acct, app, sender, sn, now)
232 if err != nil {
233 return false, "", err
234 }
235 if !resumed {
236 return false, "", nil
237 }
238 // Re-read via resumeStaleCapPause: SetSenderPaused just wrote a
239 // fresh sender (Active, PauseKind and At all moved), and everything
240 // below reads sn fresh rather than patching the in-memory copy by
241 // hand.
242 sn = fresh
243 if sn == nil || sn.Provisioned == nil || !sn.Provisioned.Active {
244 return false, "", nil
245 }
246 }
247 day := now.UTC().Format("2006-01-02")
248 // The real sender, not "": MailCounts treats "" as every sender the app
249 // has, and this cap belongs to ONE sender — summing every sibling's
250 // traffic into it would let one sender's cap trip on another sender's
251 // mail, which is exactly what keying the cap to the sender is meant to
252 // stop.
253 c, err := s.store.MailCounts(ctx, acct, app, sender, day)
254 if err != nil {
255 return false, "", err
256 }
257 cap := TierCap("")
258 if sn.Grant != nil && sn.Grant.DailyCap > 0 {
259 cap = sn.Grant.DailyCap
260 }
261
262 var reason, kind string
263 switch {
264 case c.Sends >= cap:
265 reason = fmt.Sprintf("daily cap %d reached (%d sent)", cap, c.Sends)
266 kind = mail.PauseKindCap
267 // The send floor is what stops one bounce on a two-message morning
268 // reading as a 50% bounce rate and pausing a healthy tenant.
269 case c.Sends >= MinSendsForRates && c.BounceRate() > BounceThreshold:
270 reason = fmt.Sprintf("bounce rate %.1f%% over %.0f%% (%d of %d)",
271 c.BounceRate()*100, BounceThreshold*100, c.Bounces, c.Sends)
272 kind = mail.PauseKindRate
273 case c.Sends >= MinSendsForRates && c.ComplaintRate() > ComplaintThreshold:
274 reason = fmt.Sprintf("complaint rate %.2f%% over %.1f%% (%d of %d)",
275 c.ComplaintRate()*100, ComplaintThreshold*100, c.Complaints, c.Sends)
276 kind = mail.PauseKindRate
277 default:
278 return false, "", nil
279 }
280
281 if _, err := mail.SetSenderPaused(ctx, s.claimMailStore(), s.cfg.MailProvisioner, acct, app, sender, true, kind, now); err != nil {
282 return false, "", err
283 }
284 // Audit AFTER the write it announces, and never before — a pause the
285 // member cannot see is the failure §9 exists to prevent someone else's
286 // tenant causing.
287 if a, err := s.store.AccountBySqid(acct); err != nil {
288 log.Printf("console: mail pause audit: resolving account %s: %v", acct, err)
289 } else if a != nil {
290 s.audit(a, app, "ses", "mail.paused", fmt.Sprintf("%s: %s", mailAuditSubject(app, sender), reason))
291 }
292 return true, reason, nil
293}
294
295// mailAuditSubject names what a pause or resume audit line is about. These
296// lines render on the app overview page, so they are member-facing copy
297// that never went through this repo's copy-review gate for the "/sender"
298// form. Every app has exactly one sender today (the app sender, mail.
299// SenderAppID), so naming it on that line would add "/app" for zero
300// information a member could use, and would be an unreviewed copy change.
301// Only a sender OTHER than the app sender earns the "/sender" suffix, which
302// keeps every live app's audit line byte-identical to what it already
303// prints.
304func mailAuditSubject(app, sender string) string {
305 if sender == mail.SenderAppID {
306 return app
307 }
308 return app + "/" + sender
309}
310
311// RATE AND MANUAL PAUSES NEVER AUTO-RESUME. A RATE pause that lifted itself
312// at midnight would let an abusive tenant breach SES's account-review
313// thresholds again every day forever, which is not a limit — it is a
314// schedule. A MANUAL (operator) pause is a human's deliberate decision,
315// which only a human — through the door — gets to undo. Resume for either
316// is a decision, taken through the door.
317//
318// A CAP pause is different (I3, review follow-up, amending the rule this
319// comment used to state without exception): it is computed from PER-DAY
320// counts, so "never auto-resumes" turned an ordinary tier limit into a
321// permanent one — see the day-roll check above.
322