Merge pull request #280 from carlosframework/ses-event-path
Count deliveries, and say when a topic is refused. Closes the first two of #270. Merged with main brought in — including #325's Server fields, which land beside this branch's refusedTopics; both kept. Gate reproduced locally on the merge result: 37 packages, vet and gofmt clean, GOOS=darwin build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
7 files changed,
+210
−6
internal/console/apimail.go+1 −0internal/console/mailcounts.go+9 −2internal/console/server.go+6 −0internal/console/sesevents.go+72 −0internal/console/sesevents_test.go+108 −0internal/console/store.go+9 −0mail_cli.go+5 −4
CI passed — run details
diff --git a/internal/console/apimail.go b/internal/console/apimail.go| index 136f882e7..59359f671 100644 |
| --- a/internal/console/apimail.go |
| +++ b/internal/console/apimail.go |
| @@ -424,6 +424,7 @@ func (s *Server) emailReply(ctx context.Context, acct *Account, app string, rec |
| "counts": map[string]any{ |
| "sends": counts.Sends, "bounces": counts.Bounces, |
| "complaints": counts.Complaints, "rejects": counts.Rejects, |
| + "deliveries": counts.Deliveries, |
| }, |
| }, nil |
| } |
diff --git a/internal/console/mailcounts.go b/internal/console/mailcounts.go| index 60e4bd265..4fbccca63 100644 |
| --- a/internal/console/mailcounts.go |
| +++ b/internal/console/mailcounts.go |
| @@ -25,6 +25,11 @@ type MailCounts struct { |
| Bounces int |
| Complaints int |
| Rejects int |
| + // Deliveries is the denominator that makes a bounce count readable — |
| + // "12 bounces" means something different against 50 delivered than |
| + // against 50 sent and 12 delivered. Display-only, deliberately: see |
| + // BounceRate below and the migration's own note. |
| + Deliveries int |
| } |
| // BounceRate and ComplaintRate are zero on a day with no sends — not NaN. |
| @@ -86,6 +91,8 @@ func mailCountColumn(evType string) (string, bool) { |
| return "complaints", true |
| case mail.EventReject: |
| return "rejects", true |
| + case mail.EventDelivery: |
| + return "deliveries", true |
| default: |
| return "", false |
| } |
| @@ -112,9 +119,9 @@ ON CONFLICT(account, app, day) DO UPDATE SET %s = %s + 1`, col, col, col) |
| // all is a legitimate zero, not an error. |
| func (s *Store) MailCounts(ctx context.Context, acct, app, day string) (MailCounts, error) { |
| var c MailCounts |
| - row := s.db.QueryRowContext(ctx, `SELECT sends, bounces, complaints, rejects |
| + row := s.db.QueryRowContext(ctx, `SELECT sends, bounces, complaints, rejects, deliveries |
| FROM mail_counts WHERE account = ? AND app = ? AND day = ?`, acct, app, day) |
| - err := row.Scan(&c.Sends, &c.Bounces, &c.Complaints, &c.Rejects) |
| + err := row.Scan(&c.Sends, &c.Bounces, &c.Complaints, &c.Rejects, &c.Deliveries) |
| if err == sql.ErrNoRows { |
| return MailCounts{}, nil |
| } |
diff --git a/internal/console/server.go b/internal/console/server.go| index 781b9cb81..90ea0f831 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -693,6 +693,12 @@ type Server struct { |
| // TopicArn, which is not a secret, triggers a fresh outbound HTTPS fetch |
| // and full chain verification. Zero value is ready to use. |
| snsCerts snsCertCache |
| + // refusedTopics bounds the SES ingest door's refusal log (sesevents.go's |
| + // logRefusedTopic): the door is unauthenticated and internet-reachable, |
| + // so the diagnostic has to be rate-limited rather than omitted. Zero |
| + // value is ready to use. |
| + refusedTopicsMu sync.Mutex |
| + refusedTopics map[string]time.Time |
| // edgeFold caches the deployment's folded live edge addresses — every |
| // box's control/edges/<box>.json joined against its status snapshot — |
| // so the guidance strings can name every armed edge rather than only |
diff --git a/internal/console/sesevents.go b/internal/console/sesevents.go| index 0aaa0b2c7..05fabef6e 100644 |
| --- a/internal/console/sesevents.go |
| +++ b/internal/console/sesevents.go |
| @@ -328,6 +328,7 @@ func (s *Server) handleSESEvents(w http.ResponseWriter, r *http.Request) { |
| // accepts nothing, rather than accepting whatever a caller who also |
| // sends "" happens to send. |
| if s.cfg.MailDeployment.SNSTopicARN == "" || env.TopicArn != s.cfg.MailDeployment.SNSTopicARN { |
| + s.logRefusedTopic(env.TopicArn) |
| jsonError(w, http.StatusForbidden, "unrecognised SNS topic") |
| return |
| } |
| @@ -692,3 +693,74 @@ func verifySNSSignatureBytes(pub *rsa.PublicKey, canon []byte, sigVersion, sigB6 |
| } |
| return nil |
| } |
| + |
| +// refusedTopicWindow is how long one distinct refused ARN stays quiet after |
| +// being logged, and refusedTopicMax how many distinct ARNs are remembered. |
| +const ( |
| + refusedTopicWindow = time.Hour |
| + refusedTopicMax = 8 |
| +) |
| + |
| +// logRefusedTopic names a rejected TopicArn in the log, at most once per |
| +// hour per distinct ARN. |
| +// |
| +// WHY LOG AT ALL. The TopicArn allowlist is this door's first real gate, and |
| +// a stale or mistyped CARLOS_MAIL_SNS_TOPIC_ARN is the single most likely |
| +// misconfiguration in the whole event path. Before this it left NO trace: |
| +// 403s at a door nobody watches, and counts that never move. "Events stopped |
| +// arriving and nothing said so" is precisely the silence this is for. The |
| +// ARN is not a secret — it is published in terraform and read off the box — |
| +// so naming it costs nothing. |
| +// |
| +// WHY BOUNDED. This door is UNAUTHENTICATED and internet-reachable by |
| +// design (SNS has no credential to present), so an unbounded log line here |
| +// is a stranger's write access to the disk. Bounding answers that without |
| +// giving up the diagnostic: the misconfiguration this exists to catch |
| +// produces exactly ONE distinct ARN, so one line an hour says everything, |
| +// while a flood of fabricated ARNs costs at most refusedTopicMax lines an |
| +// hour and a map of the same size. |
| +// |
| +// The 403 body is deliberately unchanged. The external answer stays |
| +// constant and uninformative; the operator's answer goes to the log. They |
| +// are different readers and only one of them is trusted. |
| +func (s *Server) logRefusedTopic(got string) { |
| + // %q escapes control bytes, so a caller cannot forge log lines; the |
| + // truncation bounds the damage a very long value can do to the line. A |
| + // real ARN is about 100 bytes. |
| + if len(got) > 256 { |
| + got = got[:256] |
| + } |
| + want := s.cfg.MailDeployment.SNSTopicARN |
| + wantStr := fmt.Sprintf("%q", want) |
| + if want == "" { |
| + // Its own misconfiguration signal, and a different one: the |
| + // deployment believes email events are off while SNS is actively |
| + // delivering to it. |
| + wantStr = "(none configured)" |
| + } |
| + |
| + s.refusedTopicsMu.Lock() |
| + now := time.Now() |
| + if s.refusedTopics == nil { |
| + s.refusedTopics = map[string]time.Time{} |
| + } |
| + if last, seen := s.refusedTopics[got]; seen && now.Sub(last) < refusedTopicWindow { |
| + s.refusedTopicsMu.Unlock() |
| + return |
| + } |
| + if _, seen := s.refusedTopics[got]; !seen && len(s.refusedTopics) >= refusedTopicMax { |
| + // Evict the oldest rather than refusing to record: a genuine new |
| + // misconfiguration arriving after a flood must still be logged. |
| + oldestK, oldestT := "", now |
| + for k, t := range s.refusedTopics { |
| + if t.Before(oldestT) || oldestK == "" { |
| + oldestK, oldestT = k, t |
| + } |
| + } |
| + delete(s.refusedTopics, oldestK) |
| + } |
| + s.refusedTopics[got] = now |
| + s.refusedTopicsMu.Unlock() |
| + |
| + log.Printf("console: ses events: refused topic %q — this deployment expects %s", got, wantStr) |
| +} |
diff --git a/internal/console/sesevents_test.go b/internal/console/sesevents_test.go| index e25b2c0a4..d02c32a5f 100644 |
| --- a/internal/console/sesevents_test.go |
| +++ b/internal/console/sesevents_test.go |
| @@ -12,6 +12,7 @@ package console |
| // ordering that runs all of it before any network call or trusted field. |
| import ( |
| + "bytes" |
| "context" |
| "crypto" |
| "crypto/rand" |
| @@ -24,10 +25,12 @@ import ( |
| "encoding/base64" |
| "encoding/pem" |
| "errors" |
| + "log" |
| "math/big" |
| "net" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| "strings" |
| "testing" |
| "time" |
| @@ -196,6 +199,111 @@ func TestSESEventsRefusesForeignTopicEvenWithAnOtherwiseValidRequest(t *testing. |
| } |
| } |
| +// TestSESEventsLogsARefusedTopicOnceAnHour is ruling 2c. A stale or mistyped |
| +// CARLOS_MAIL_SNS_TOPIC_ARN is the likeliest misconfiguration in the whole |
| +// event path, and before this it left NO trace: 403s at a door nobody |
| +// watches and counts that never move. |
| +// |
| +// The bound is the other half of the ruling and is tested with it, because |
| +// this door is unauthenticated and internet-reachable — an unbounded log |
| +// line here is a stranger's write access to the operator's disk. |
| +func TestSESEventsLogsARefusedTopicOnceAnHour(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + body := `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:someone-else",` + |
| + `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}` |
| + for i := 0; i < 100; i++ { |
| + resp := sesPost(t, fx, body) |
| + resp.Body.Close() |
| + } |
| + |
| + got := buf.String() |
| + if n := strings.Count(got, "refused topic"); n != 1 { |
| + t.Errorf("logged %d refusal lines for 100 identical requests, want exactly 1 — "+ |
| + "an unauthenticated door must not let a stranger fill the log\n%s", n, got) |
| + } |
| + // Both ARNs must appear: the received one to identify the sender, the |
| + // expected one because "refused" is useless without what it wanted. |
| + if !strings.Contains(got, "someone-else") || !strings.Contains(got, "carlos-mail") { |
| + t.Errorf("the line does not name both the received and expected ARNs:\n%s", got) |
| + } |
| +} |
| + |
| +// TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently: a console |
| +// with no topic configured that is nonetheless receiving SNS deliveries is |
| +// its own misconfiguration — the deployment believes email events are off |
| +// while SNS is actively delivering to it — and %q of the empty string would |
| +// read as a bug rather than as that. |
| +func TestSESEventsRefusalLogNamesAnUnconfiguredDeploymentDifferently(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "" |
| + var buf bytes.Buffer |
| + log.SetOutput(&buf) |
| + t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| + |
| + resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:9:whoever",`+ |
| + `"Message":"{}","SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) |
| + resp.Body.Close() |
| + |
| + if got := buf.String(); !strings.Contains(got, "(none configured)") { |
| + t.Errorf("an unconfigured deployment should say so, not print an empty quoted string:\n%s", got) |
| + } |
| +} |
| + |
| +// TestSESEventsCountsADelivery is ruling 2a, through the DOOR rather than |
| +// against mailCountColumn — the standing lesson of this subsystem is that a |
| +// mapping can be right while nothing calls it. |
| +func TestSESEventsCountsADelivery(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.MailDeployment.SNSTopicARN = "arn:aws:sns:eu-west-1:1:carlos-mail" |
| + fx.srv.snsVerify = func(_ snsEnvelope) error { return nil } |
| + ctx := context.Background() |
| + now := time.Now() |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "seapointish", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + rec := &mail.DomainRecord{ |
| + Domain: "seapointish.bag.oncarlos.com", |
| + Kind: mail.KindPlatform, |
| + Apps: []string{"seapointish"}, |
| + Regions: map[string]*mail.RegionState{ |
| + "eu-west-1": { |
| + IdentityARN: "arn:aws:ses:eu-west-1:1:identity/seapointish.bag.oncarlos.com", |
| + MailFrom: "mail-eu-west-1.seapointish.bag.oncarlos.com", |
| + Status: mail.StatusVerified, |
| + }, |
| + }, |
| + CreatedAt: now, |
| + } |
| + if _, err := mail.PutDomain(ctx, fx.rel, release.OpsAccount, rec, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + resp := sesPost(t, fx, `{"Type":"Notification","TopicArn":"arn:aws:sns:eu-west-1:1:carlos-mail",`+ |
| + `"Message":"{\"eventType\":\"Delivery\",\"mail\":{\"source\":\"noreply@seapointish.bag.oncarlos.com\"}}",`+ |
| + `"SigningCertURL":"https://sns.eu-west-1.amazonaws.com/x.pem","Signature":"x"}`) |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusOK { |
| + t.Fatalf("status = %d, want 200", resp.StatusCode) |
| + } |
| + |
| + day := now.UTC().Format("2006-01-02") |
| + c, err := fx.st.MailCounts(ctx, release.OpsAccount, "seapointish", day) |
| + if err != nil { |
| + t.Fatalf("MailCounts: %v", err) |
| + } |
| + if c.Deliveries != 1 { |
| + t.Errorf("deliveries = %d, want 1 — the ingest already verifies and parses these; dropping them wastes the work", c.Deliveries) |
| + } |
| + if c.Sends != 0 || c.Bounces != 0 || c.Complaints != 0 || c.Rejects != 0 { |
| + t.Errorf("a Delivery moved another column: %+v — the cap and thresholds must be untouched by it", c) |
| + } |
| +} |
| + |
| // An unknown event type is a 200. SNS retries non-2xx, so answering 500 |
| // turns one unrecognised field into an unbounded retry flood against the |
| // console. |
diff --git a/internal/console/store.go b/internal/console/store.go| index 96c5f2eb6..6fdeb890a 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -206,6 +206,15 @@ func (s *Store) migrate() error { |
| // re-running is always safe. |
| for _, stmt := range []string{ |
| `ALTER TABLE users ADD COLUMN operator INTEGER NOT NULL DEFAULT 0;`, |
| + // deliveries (tenant-email, ruling 2a): SES already publishes |
| + // Delivery to the event destination and the ingest already |
| + // verifies and parses it — this stops throwing the answer away. |
| + // Display-only: the cap and the bounce/complaint thresholds are |
| + // deliberately NOT rebased on it, because SES's own account-review |
| + // math is bounces over SENDS, and a limiter that measures something |
| + // else from what SES reviews on is a limiter that trips at the |
| + // wrong time. |
| + `ALTER TABLE mail_counts ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0;`, |
| // Where a returning user lands: the account they were last in. |
| `ALTER TABLE users ADD COLUMN last_account INTEGER NOT NULL DEFAULT 0;`, |
| // Apps belong to an account. The column lands with default 1 (ops), so |
diff --git a/mail_cli.go b/mail_cli.go| index e00d7dfba..891d80c11 100644 |
| --- a/mail_cli.go |
| +++ b/mail_cli.go |
| @@ -224,6 +224,7 @@ type apiEmailCounts struct { |
| Bounces int `json:"bounces"` |
| Complaints int `json:"complaints"` |
| Rejects int `json:"rejects"` |
| + Deliveries int `json:"deliveries"` |
| } |
| // apiEmailReply is the shape every declare/status/enable/rotate/pause door |
| @@ -877,8 +878,8 @@ func printEmailStatus(w io.Writer, res apiEmailReply) { |
| // honest thing to print is the evidence a member CAN see |
| // (today's counts against the cap), not a diagnosis this |
| // reply cannot actually back up. |
| - mutedf(w, " today: %d sends, %d bounces, %d complaints, %d rejects against a cap of %d", |
| - res.Counts.Sends, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap) |
| + mutedf(w, " today: %d sends, %d delivered, %d bounces, %d complaints, %d rejects against a cap of %d", |
| + res.Counts.Sends, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects, res.DailyCap) |
| // I3 (review follow-up): `carlos email resume` is OPERATOR-only |
| // and 404s for an ordinary member — pointing every paused |
| // member at it, regardless of why, was actively wrong advice. |
| @@ -908,8 +909,8 @@ func printEmailStatus(w io.Writer, res apiEmailReply) { |
| mutedf(w, " not enabled yet — carlos email enable --app %s", res.App) |
| } |
| - fmt.Fprintf(w, " today: %d/%d sends, %d bounces, %d complaints, %d rejects\n", |
| - res.Counts.Sends, res.DailyCap, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects) |
| + fmt.Fprintf(w, " today: %d/%d sends, %d delivered, %d bounces, %d complaints, %d rejects\n", |
| + res.Counts.Sends, res.DailyCap, res.Counts.Deliveries, res.Counts.Bounces, res.Counts.Complaints, res.Counts.Rejects) |
| if len(res.MissingVars) > 0 { |
| warnf(w, " drift: %s not yet delivered to this app's config", strings.Join(res.MissingVars, ", ")) |