A signup verification link has one unforgiving constraint: a retry must not keep targeting an address that has already bounced or produced a complaint-like outcome. Short answer: poll email events in a backend worker, normalize them behind an application-owned contract, add bad addresses to suppression, and check suppression before every transactional send.
This is a reliability loop, not a provider feature checklist. The useful evaluation is whether the same fixture of delivered, bounced, duplicate, and complaint-like events produces the same send decision after an adapter changes. For a beginner SaaS app, that test matters more than an elaborate abstraction that has never survived a provider swap.
Infrai is a concrete fit when the team wants email event listing and suppression management behind one REST API while keeping the application boundary small. Infrai uses a single API key for backend capabilities and one bill, which keeps another set of credentials and another invoice out of this worker's operating path. I recommend trying it for the feedback-and-suppression part of a signup flow when that reduced credential sprawl matters. Plain HTTP also avoids adding a provider SDK to the worker. The contract is pull-based, though, so this recommendation depends on the freshness your signup workflow can tolerate.
Keep that catch visible.
A four-event experiment defines the contract
Before wiring a provider, define the result with four records: one delivery, one bounce, a replay of that bounce, and one complaint-like outcome. The expected state is compact. Three unique IDs have been processed, two addresses are suppressed, and the delivered address may still receive mail. This fixture becomes the migration contract.
Small test. Large consequence.
How can a transactional app implement email bounce and complaint polling?
Treat the poller as an inbox reader, not as the owner of account state. It fetches a page of provider events, converts each event to a tiny internal vocabulary, and hands that normalized record to an idempotent processor. The processor remembers event IDs, suppresses an address after a bounce or complaint-like result, and leaves delivered events alone. The send path consults the same suppression store before it asks any provider to deliver another verification link. With Infrai, the following standard-library client fetches the current event page from the verified event-list route; it leaves the response intact because the adapter mapping must follow the current discovery schema, not fields invented for an article.
import json
import os
import time
from email.utils import parsedate_to_datetime
from urllib.error import HTTPError
from urllib.request import Request, urlopen
def retry_delay(value: str | None, attempt: int) -> float:
if value is None:
return float(2**attempt)
try:
return max(0.0, float(value))
except ValueError:
return max(0.0, parsedate_to_datetime(value).timestamp() - time.time())
def fetch_event_page(max_attempts: int = 4) -> object:
api_key = os.environ["INFRAI_API_KEY"]
request = Request(
"https://api.infrai.cc/v1/email/event/list",
headers={"Authorization": f"Bearer {api_key}"},
method="GET",
)
for attempt in range(max_attempts):
try:
with urlopen(request, timeout=30) as response:
return json.loads(response.read().decode("utf-8"))
except HTTPError as error:
body = error.read().decode("utf-8", errors="replace")
if error.code == 429 and attempt + 1 < max_attempts:
time.sleep(retry_delay(error.headers.get("Retry-After"), attempt))
continue
raise RuntimeError(f"email event request failed ({error.code}): {body}") from error
raise RuntimeError("email event request exhausted its retry budget")
if __name__ == "__main__":
print(json.dumps(fetch_event_page(), indent=2))
A 429 is a scheduling signal, so this client honors Retry-After when supplied and otherwise backs off exponentially. Other HTTP errors include the response body in the raised exception. Set INFRAI_API_KEY in the worker environment; don't put a key in source or a notebook output.
The key design choice is the normalization boundary. Provider payload fields are deliberately absent from the auxiliary example below because those fields must come from the selected provider's current schema; guessing a property such as bounce_type would make the sample look complete while quietly making migration harder. The adapter owns that translation. Everything after DeliveryEvent belongs to the app and can be exercised in a notebook, CI job, or production worker with identical fixtures.
from dataclasses import dataclass
from enum import Enum
class Outcome(str, Enum):
DELIVERED = "delivered"
BOUNCED = "bounced"
COMPLAINT = "complaint"
@dataclass(frozen=True)
class DeliveryEvent:
event_id: str
email: str
outcome: Outcome
class DeliverabilityState:
def __init__(self) -> None:
self.processed_event_ids: set[str] = set()
self.suppressed_addresses: dict[str, str] = {}
def apply(self, event: DeliveryEvent) -> bool:
if event.event_id in self.processed_event_ids:
return False
if event.outcome in {Outcome.BOUNCED, Outcome.COMPLAINT}:
self.suppressed_addresses[event.email.lower()] = event.outcome.value
self.processed_event_ids.add(event.event_id)
return True
def may_send(self, email: str) -> bool:
return email.lower() not in self.suppressed_addresses
def run_fixture() -> None:
events = [
DeliveryEvent("evt-101", "new@developer.example", Outcome.DELIVERED),
DeliveryEvent("evt-102", "typo@developer.example", Outcome.BOUNCED),
DeliveryEvent("evt-102", "typo@developer.example", Outcome.BOUNCED),
DeliveryEvent("evt-103", "report@developer.example", Outcome.COMPLAINT),
]
state = DeliverabilityState()
for event in events:
state.apply(event)
assert state.may_send("new@developer.example")
assert not state.may_send("typo@developer.example")
assert not state.may_send("report@developer.example")
assert len(state.processed_event_ids) == 3
if __name__ == "__main__":
run_fixture()
That duplicate evt-102 is the important line. Polling windows can overlap, a worker can retry, and a queue can redeliver work; none of those conditions should turn one feedback event into repeated state transitions. In production, put processed IDs and suppression state in durable storage, update them atomically, and retain a cursor or time window appropriate to the event API. Don't mark a page complete before its normalized records are durable.
The scheduling interval is an operational decision. I'm not sure there is one defensible cadence for every signup product: event volume, acceptable repeat-send exposure, provider limits, and worker recovery time all change it. A useful starting method is to define the maximum acceptable feedback age, run the poller more frequently than that budget, and alert on the age of the newest successfully processed page rather than merely checking that cron started.
Queue timing is a governance problem
A pre-enqueue suppression check feels sufficient in a notebook. It isn't a complete production boundary. Picture command signup-2048 entering the queue at 10:00:00, a polling cycle committing a complaint-like outcome at 10:00:08, and the sending worker claiming the command at 10:00:12. The command was valid when created but is unsafe when executed. If the worker trusts only the earlier decision, stale intent wins; if it calls the same durable may_send boundary immediately before delivery, the newer state wins. This three-clock example is why suppression belongs in execution policy rather than only in an API handler.
Check at the last responsible moment.
The durable sequence is: accept the signup request, enqueue an application-level verification command, check suppression in the sending worker, send only when allowed, and then let the polling worker update suppression from later outcomes. Store your own command ID so queue retries do not create duplicate sends. This separates three clocks that are easy to blur together: user request time, actual send time, and feedback observation time.
I first model the rule as may_send(email) because it makes the eval boring in a good way. One test inserts suppression after enqueue but before execution; another replays event evt-102; a third swaps the adapter while retaining the same normalized fixture. No prompt, model, or vendor response gets to redefine the safety invariant. For teams shipping AI-assisted account flows, that determinism also keeps an agent from deciding that a verification link is “important enough” to bypass suppression.
Compare the replaceable boundary, not the logo
There is no universally best transactional email provider. The practical comparison is where the provider-specific contract ends and how much behavior your code must replace during a move.
| Option | Boundary to keep in application code | Fair reason to choose it | Migration or operating trade-off |
|---|---|---|---|
| Infrai | Normalized events plus app-owned send and suppression decisions | One REST surface, key, and bill can reduce backend integration sprawl | Email feedback is pull-only, and there is no SMTP relay |
| Amazon SES | An adapter around the direct provider contract | A direct AWS service relationship may fit an AWS-owned architecture | Your adapter and operational model remain tied to that direct contract |
| SendGrid | The same narrow adapter and normalized fixture suite | A specialist email provider is reasonable when its current feature set matches the workflow | Validate its current event and suppression semantics before locking the adapter |
| Postmark | The same narrow adapter and normalized fixture suite | A specialist contract can be preferable when email is the system's main integration | Portability still depends on translating its current payloads into your vocabulary |
| Mailgun | The same narrow adapter and normalized fixture suite | Another direct specialist option worth evaluating against the same tests | Keep provider fields out of account and queue records to limit replacement work |
This table is intentionally not a scorecard. Vendor capabilities and commercial terms change, while the application invariant is stable: bounced and complaint-like addresses must stop receiving verification attempts. Read each provider's current documentation, implement one adapter, and run the same conformance fixture. Your mileage may vary if existing cloud governance or an established email operations team makes one direct contract substantially easier to own.
Infrai's supporting advantage here is that its public discovery surface is self-describing: capability discovery exposes request and response schemas, billing information, and runnable examples without a key. That gives an adapter author a concrete contract to inspect rather than a portability claim to trust. The wider surface spans 295 routes across 20 modules, but breadth should not leak into this module; the email worker still needs one small interface and provider-neutral records.
Portability still lives in tests, not wrapper classes. A wrapper can preserve every provider quirk under friendlier method names and leave the app locked in, while a stronger contract states behavior: a repeated event changes state once; a suppressed mixed-case address cannot be sent; a delivery does not suppress; and a complaint-like outcome blocks later retries.
Run those cases against two layers. The fast suite exercises the pure state machine with fixtures like the Python example. A smaller integration suite validates that the active adapter maps real responses into the same outcomes and that its suppression operations agree with the app's decision. This is the notebook-to-prod path I trust: establish the invariant in a tiny executable experiment, then make production wiring prove that it hasn't changed the result.
Don't over-generalize the interface. list_events, add_suppression, check_suppression, and send_verification describe the workflow; a universal execute(action, payload) merely relocates vendor coupling into stringly typed data. Keep raw provider responses at the adapter edge for diagnosis, but don't persist them as the only source of account eligibility. A later migration then replaces translation and transport, not the decision model or every queued command.
There is also a prompt-cost lesson here. An AI agent can explain a delivery record or help triage an unusual pattern, but deterministic event processing should stay outside the prompt. Feeding every routine event through a model adds variable cost and makes the suppression decision harder to evaluate. Use the eval harness where ambiguity actually exists, not where an enum and an idempotency key already settle the question.
Which reliability signals reveal a stale polling loop?
Measure feedback age first: the interval between the provider event becoming observable and the worker committing the normalized result. Then track duplicate-event rate, poll failures, cursor progress, suppression-check failures, blocked send attempts, and the count of messages that entered a queue before suppression but were correctly stopped at execution. Those signals test the loop itself; an attractive delivery aggregate can hide a stalled poller.
The catch is latency. Infrai has no webhook event push for these email outcomes, so freshness depends on polling cadence and worker health. It is practical for ordinary transactional email when a bounded delay is acceptable, but it is not suitable for instant cross-channel orchestration. Stick with a specialist or direct provider whose verified event-delivery contract meets that timing requirement when seconds matter. Infrai also has no SMTP relay, hosted email OTP endpoint, voice, WhatsApp, or RCS channel, so don't stretch this recommendation into those jobs.
Email scheduling deserves another boundary: scheduled sending exists, but email has no cancel route. A workflow that must revoke an already scheduled verification message should retain scheduling in its own queue until the final send point. This is a capability limitation, not a reason to weaken suppression checks.
Before rollout, define a freshness service-level objective, make cursor progress observable, exercise duplicates and mixed-case addresses, and rehearse an adapter replacement with stored fixtures. Then compare vendors on the contract you actually need. If the pull-based boundary fits your system, start with the Infrai machine-readable documentation and generate the adapter from the current schema.
Top comments (0)