Short answer: for a gaming compliance notice, make bounce, complaint, suppression, and poll freshness explicit state, then record the send decision against the exact evidence that authorized it.
An accepted SMTP submission is not a delivered notice. The useful control point is the worker that is about to send: it must see a current local projection and leave an audit record whether it sends or blocks. I have been paged for missed jobs and duplicate deliveries; both incidents got worse when the system had no durable answer to “what did we know when this message left?”
The rule is simple. No fresh feedback, no compliance send. That is a reliability choice, not a universal email rule.
Start with an evidence contract, not a provider list
Give every feedback observation four identities: a source name, a stable event or snapshot identifier, an observation time, and the original payload. Normalize the recipient address, event kind, and policy version into an application record, but keep the original bytes beside it. A normalized row answers “may we send now?”; the retained envelope answers “why did we decide that?”
For notice notice-2048, an auditor should be able to follow one chain: source envelope, normalized feedback event, recipient projection, send decision, and provider message identifier (when returned). Store the decision reason, template version, and decision timestamp as well. Do not put the full notice body in ordinary logs; proving which content version was used does not require copying player data everywhere.
This contract makes migrations reviewable. Run a new adapter in shadow mode, write its records under a new source identifier, and compare decisions during a complete reconciliation window. Keep the old reader for historical decisions. Rollback is then a pointer change to the prior projection and policy version, not a guess about which list was current.
Keep it boring.
How should a transactional app poll email bounce, complaint, and suppression data?
Treat polling as an ordered input log. Fetch a bounded page using the provider’s documented cursor or page token, validate every record, normalize it, and commit the events, recipient projection, cursor, and freshness timestamp in one database transaction. Advance the cursor only in that transaction. A retry of the same page must be harmless.
| Signal | Local meaning | Default action |
|---|---|---|
| Permanent bounce | Current evidence says the address is not deliverable | Suppress later notices |
| Complaint | The recipient reported the message | Suppress later notices |
| Provider suppression | Upstream policy blocks the address | Mirror the local block |
| Poll freshness | The feedback view may be stale | Pause the compliance worker |
Do not turn every temporary delivery failure into a permanent suppression. That loses valid recipients. Classification belongs to the documented event fields; an unknown kind should be quarantined and reviewed rather than silently treated as eligible. I’m not sure a vendor-neutral taxonomy can preserve every provider nuance, so the raw payload stays authoritative evidence.
Snapshot and event-feed transports also make different audit claims. A snapshot can prove membership at an observation time, while an ordered feed can prove a transition if its event ID and cursor are stable. Push notifications may reduce delay, but a periodic poll or reconciliation pass is still the completeness check. Pick the transport that can answer the investigation question your team actually receives.
Make the send gate a small, testable state machine
The application can be written in Node.js, but the invariant is language-independent. The Go example below uses an in-memory store so it runs as-is; production should replace the mutex with a transaction that has the same atomic boundary.
package main
import (
"fmt"
"strings"
"sync"
"time"
)
type Kind string
const (
Bounce Kind = "permanent_bounce"
Complaint Kind = "complaint"
Suppressed Kind = "provider_suppression"
)
type Event struct {
ID string
Recipient string
Kind Kind
When time.Time
}
type Store struct {
mu sync.Mutex
seen map[string]bool
blocked map[string]Event
polledAt time.Time
}
func NewStore() *Store {
return &Store{seen: map[string]bool{}, blocked: map[string]Event{}}
}
func (s *Store) Apply(events []Event, committedAt time.Time) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, e := range events {
if e.ID == "" || strings.TrimSpace(e.Recipient) == "" {
return fmt.Errorf("event identity is required")
}
if s.seen[e.ID] {
continue
}
s.seen[e.ID] = true
s.blocked[strings.ToLower(strings.TrimSpace(e.Recipient))] = e
}
s.polledAt = committedAt
return nil
}
func (s *Store) MaySend(address string, now time.Time, maxAge time.Duration) (bool, string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.polledAt.IsZero() || now.Sub(s.polledAt) > maxAge {
return false, "feedback_stale"
}
if e, ok := s.blocked[strings.ToLower(strings.TrimSpace(address))]; ok {
return false, string(e.Kind)
}
return true, "eligible"
}
func main() {
s := NewStore()
now := time.Date(2026, 8, 22, 14, 3, 0, 0, time.UTC)
_ = s.Apply([]Event{{ID: "feedback-8472", Recipient: "Player@example.test", Kind: Complaint, When: now}}, now)
ok, reason := s.MaySend("player@example.test", now, 5*time.Minute)
fmt.Printf("allowed=%t reason=%s\n", ok, reason)
}
The output is allowed=false reason=complaint. The important behavior is the commit boundary: a crash after writing an event but before advancing a cursor causes a replay, and ID makes that replay idempotent. Writing a cursor first can skip evidence forever, so cursor and projection belong in the same transaction.
In the worker, check once when enqueueing to avoid pointless work, then check again immediately before the provider call. The second check is authoritative because a complaint can arrive while a notice waits in the queue. Record suppressed with its evidence ID, or send_authorized with the policy version. Use the notice ID as the application idempotency key where the selected transport supports one, and record the returned message ID.
Verify freshness, duplicates, and rollback before rollout
Test the failure paths first: replay the same page, deliver two distinct events for one address, truncate a payload, and stop the poller between database writes. The expected outcomes are one audit event per stable ID, a blocked recipient projection, a quarantined invalid record, and no cursor advance on a failed transaction. Add a metric for poll age and an alert for a stale gate; a green process with an old projection is not healthy.
One failure deserves a longer rehearsal. Imagine the poller has committed feedback-8472, then the process dies before its cursor write in a system that uses separate transactions. On restart, the same page arrives again. The event table must reject the duplicate by stable ID while the projection remains blocked, and the cursor can safely move only after that idempotent write succeeds. If the implementation instead advances the cursor before the event transaction, the next run may start after the complaint; the sender will see an apparently fresh projection that never contained the evidence. In a postmortem, that is a lost-input defect, not a provider mystery. Capture the page token, transaction outcome, policy version, and worker decision so the timeline can be reconstructed without replaying production mail.
Short logs help.
During a deployment, compare the old and new policy decisions without allowing the new one to authorize mail. Sample the decision chain for notice-2048 and confirm that an operator can retrieve the source envelope, event ID, and policy version. Roll back by selecting the previous projection version, then replay retained events after the incident. Never “fix” a duplicate by deleting an audit row; preserve the evidence and correct the projection.
There is a catch: fail-closed freshness is not suitable when a message has a hard latency objective, such as a login code. In that case, use a separately approved policy and a different evidence requirement. Stick with the stricter gate for compliance notices, where an explainable block is preferable to an untraceable send.
Top comments (0)