<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: JorisRhodes8286</title>
    <description>The latest articles on DEV Community by JorisRhodes8286 (@jorisrhodes8286).</description>
    <link>https://dev.to/jorisrhodes8286</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4096545%2Ffb15e45d-3591-4767-ad90-48870b6edb07.png</url>
      <title>DEV Community: JorisRhodes8286</title>
      <link>https://dev.to/jorisrhodes8286</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jorisrhodes8286"/>
    <language>en</language>
    <item>
      <title>Community Account Linking: Consent-Bound Identity Resolution for Audit-Safe Recovery</title>
      <dc:creator>JorisRhodes8286</dc:creator>
      <pubDate>Mon, 31 Aug 2026 02:43:21 +0000</pubDate>
      <link>https://dev.to/jorisrhodes8286/community-account-linking-consent-bound-identity-resolution-for-audit-safe-recovery-34j9</link>
      <guid>https://dev.to/jorisrhodes8286/community-account-linking-consent-bound-identity-resolution-for-audit-safe-recovery-34j9</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; choose an account-linking design that treats matching attributes as hints, requires fresh proof of control for every identity being joined, and commits consent, credential reassignment, and an audit event in one transaction.&lt;/p&gt;

&lt;p&gt;That answer makes recovery slower. In a fintech community, it should. A member who enters a forgot-password flow may have private discussions, support history, or community roles attached to an account, so a resolver that guesses from a shared email can turn recovery into an unauthorized merge. Bot resistance matters too: discovery must reveal little, every proof must expire, and repeated attempts must become expensive without locking an entire office or household out.&lt;/p&gt;

&lt;p&gt;The hard part isn't finding similar rows. It is deciding when the evidence is strong enough to mutate identity data, while keeping enough history for an auditor to reconstruct why the decision was allowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should community account linking resolve identities without accidental merges?
&lt;/h2&gt;

&lt;p&gt;Separate the community member, the login identity, and the proposed link. A member is the durable local subject that owns posts, preferences, and roles. A login identity is a credential reference such as an issuer-and-subject pair, a passkey, or a verified local address. A link proposal is temporary evidence plus intent. Collapsing those three concepts into one user row makes every later recovery rule harder to state and harder to test.&lt;/p&gt;

&lt;p&gt;Start resolution with an exact credential identifier. A normalized email, phone number, display name, or profile resemblance may locate candidates, but none of those should authorize a link. An email can be reassigned, an address can be shared, and a profile can be copied. If an unauthenticated request supplies an address, return the same outward recovery response whether it matched zero members or several; OWASP's Authentication Cheat Sheet recommends consistent messages and timing for account-related responses so the endpoint does not become an enumeration tool.&lt;/p&gt;

&lt;p&gt;Then branch on evidence, not similarity. If the asserted credential already belongs to one member, continue recovery for that member after the normal proof. If it belongs to none, keep account creation separate from linking. If the flow would connect two existing member IDs, stop automatic resolution and require a fresh authenticated session for both sides plus specific consent that names the destination profile. Never select the older account, the row with more fields, or the one whose email happens to match.&lt;/p&gt;

&lt;p&gt;No guessing.&lt;/p&gt;

&lt;p&gt;This boundary matters when forgot-password and linking meet. Imagine a member starts recovery for &lt;code&gt;sam@payments.example&lt;/code&gt;, signs in through another credential, and sees an older community profile with the same address. The resolver can safely say that another profile may require attention, but it cannot infer common ownership from the address. It creates a short-lived proposal, binds the proposal to the initiating session, and asks for independent proof of the older profile. If the second proof is unavailable, the accounts remain separate and the case moves to a documented recovery policy. A support agent should not be able to turn a plausible story into a merge with one override click.&lt;/p&gt;

&lt;p&gt;The consent screen must describe the actual change: which credential will attach to which member, which profile will remain the destination, and what happens to roles and active sessions. A generic Continue button is weak evidence of intent. After approval, notify the member through an already established channel and offer a controlled review path. These steps add friction, but they place friction at the exact point where a bot or mistaken human could cause irreversible identity confusion.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model the decision as evidence, consent, and one commit
&lt;/h2&gt;

&lt;p&gt;Use a state machine rather than a collection of booleans. A proposal can move from &lt;code&gt;proposed&lt;/code&gt; to &lt;code&gt;verified_both&lt;/code&gt;, then to &lt;code&gt;approved&lt;/code&gt; and &lt;code&gt;applied&lt;/code&gt;; &lt;code&gt;expired&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt;, and &lt;code&gt;review_required&lt;/code&gt; are terminal outcomes for that attempt. The transition into &lt;code&gt;applied&lt;/code&gt; should lock the relevant member and credential records, re-check that neither credential was linked elsewhere, append an audit event, and update the proposal in the same database transaction. Notifications can be retried after commit. Identity mutation cannot be left half-done because an email or SMS delivery was delayed.&lt;/p&gt;

&lt;p&gt;The following Python sketch keeps policy visible. It doesn't decide whether an OTP, passkey, or existing session is strong enough; that belongs in the risk policy, where compliance and security reviewers can change it without changing the resolver's invariants.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;StrEnum&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LinkState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;StrEnum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;PROPOSED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;proposed&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;VERIFIED_BOTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verified_both&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;APPROVED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;approved&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;APPLIED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;applied&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;REVIEW_REQUIRED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;review_required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;LinkProposal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;proposal_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;source_member_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;target_member_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;
    &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LinkState&lt;/span&gt;
    &lt;span class="n"&gt;source_proof_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;target_proof_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;consent_event_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ready_to_apply&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;LinkProposal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_member_id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_member_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LinkState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APPROVED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;source_proof_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;target_proof_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;proposal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;consent_event_id&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application service should make the command idempotent. Retrying the same proposal after a client timeout must observe &lt;code&gt;applied&lt;/code&gt; and return the existing result, not append another link event. A different proposal that races for the same external identity should lose at a database uniqueness constraint on the credential's stable issuer-and-subject key. Treat that constraint as the final guard, not as a replacement for the policy checks that explain why the operation was legitimate.&lt;/p&gt;

&lt;p&gt;Retries happen.&lt;/p&gt;

&lt;p&gt;Audit data should answer a narrow set of questions: who initiated the proposal, which two local members were involved, which proof records satisfied policy, when explicit consent occurred, which policy version evaluated the request, and which transaction applied it. Store references to proof events rather than raw secrets or OTP values. Retention, access, and deletion rules should be agreed with compliance and privacy owners; an audit trail that exposes authentication material creates a second problem while trying to solve the first.&lt;/p&gt;

&lt;p&gt;I pay special attention to delivery gaps here because an OTP that arrives late can cross a state boundary. The verifier must bind a code to one proposal, one purpose, and one expiration, then consume it once. A code issued for password reset must not satisfy account-link consent. Don't let resend create several simultaneously valid proofs, and don't make an SMS or email provider callback the authority for whether a link was committed. Delivery reports describe transport; the identity database owns the decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abuse controls and tests define the real selection criteria
&lt;/h2&gt;

&lt;p&gt;Evaluate an implementation by its failure behavior before comparing its happy-path ergonomics. Rate limits need several dimensions: member, credential identifier, proposal, network signal, and time window. A single IP limit is too coarse for shared networks, while a member-only limit lets a bot distribute discovery across candidate accounts. The public response should stay consistent, but internal events should distinguish no match, ambiguous candidates, expired proof, replayed proof, policy rejection, and concurrency conflict. A client can receive a generic accepted response while operators retain precise, access-controlled evidence.&lt;/p&gt;

&lt;p&gt;The most useful test suite attacks invariants. Generate duplicated normalized emails, shared phone numbers, changing display names, expired proposals, proof replay, and two workers approving the same link. Assert that one external credential maps to at most one member; that no proposal reaches &lt;code&gt;applied&lt;/code&gt; without two valid proof references and consent; that moderator or staff privileges do not silently move through an ordinary recovery path; and that a retry produces no second audit event. Include session handling: after a sensitive recovery or link, the policy should decide which existing sessions are revoked and record that decision.&lt;/p&gt;

&lt;p&gt;There is no universal threshold for manual review. I'm not sure a low-risk discussion board and a regulated customer community should ever share one, and the evidence needed to settle that choice is local: account value, role sensitivity, recovery volume, delivery reliability, fraud signals, and the review team's capacity. The architecture should therefore expose a policy decision such as &lt;code&gt;allow&lt;/code&gt;, &lt;code&gt;deny&lt;/code&gt;, or &lt;code&gt;review_required&lt;/code&gt; without letting the resolver reinterpret it. Your mileage may vary, but the invariant cannot.&lt;/p&gt;

&lt;p&gt;Observe ratios rather than celebrating raw completion count. Useful measures include proposals started, both-side verification completed, proposals expired, review required, links applied, links later challenged, and recovery attempts throttled. Slice them by proof type and risk class without placing sensitive identifiers in metric labels. A sudden rise in proposals followed by password resets deserves investigation even when each individual request stayed below its rate limit.&lt;/p&gt;

&lt;p&gt;Proof expires.&lt;/p&gt;

&lt;p&gt;The catch is operational cost. Dual proof, immutable audit events, notification retry, abuse telemetry, and a review queue demand more engineering and support capacity than matching on email. This design is not suitable when the organization cannot protect audit data, operate a second proof, or review ambiguous ownership claims. In that situation, keep the accounts separate and provide a controlled data export or support-mediated access recovery that does not combine identities. For a low-value community with no private data or privileged roles, a simpler no-merge policy may be safer than building a sophisticated linker nobody can operate well.&lt;/p&gt;

&lt;h2&gt;
  
  
  Roll out from observation to reversible linking
&lt;/h2&gt;

&lt;p&gt;Begin with a read-only resolver that records candidate outcomes without creating proposals or changing members. Review ambiguous cases, especially shared addresses and privileged profiles, then set policy thresholds from the observed distribution. Next, enable proposals for a small cohort while preserving the ability to detach a newly associated credential through an audited administrative action. Rollback should reverse the association event, not delete either member or rewrite history.&lt;/p&gt;

&lt;p&gt;Keep the migration compact: add stable credential records, enforce their uniqueness, introduce proposal and audit-event tables, shadow existing recovery decisions, and only then enable transactional application. Old email-based joins should become search aids, never authorization rules.&lt;/p&gt;

&lt;p&gt;The final release criterion is plain: every applied link can be replayed from policy version, independent proofs, explicit consent, and one committed event. If the team cannot show that chain during an audit, the resolver is still a matcher, not an identity control.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html" rel="noopener noreferrer"&gt;https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>community</category>
      <category>account</category>
      <category>linking</category>
    </item>
    <item>
      <title>Welcome Email Suppression, Unsubscribe, and Bounce Handling for Transactional APIs</title>
      <dc:creator>JorisRhodes8286</dc:creator>
      <pubDate>Thu, 27 Aug 2026 21:21:33 +0000</pubDate>
      <link>https://dev.to/jorisrhodes8286/welcome-email-suppression-unsubscribe-and-bounce-handling-for-transactional-apis-1fl0</link>
      <guid>https://dev.to/jorisrhodes8286/welcome-email-suppression-unsubscribe-and-bounce-handling-for-transactional-apis-1fl0</guid>
      <description>&lt;p&gt;Short answer: put a durable suppression check before every repeat welcome-email attempt, poll delivery events into that same suppression store, and keep the verification token separate from the provider request. For a media signup flow, that boundary matters more than a clever retry policy: a retry can recover a transient handoff, but it must never override an unsubscribe, complaint, or known bad address.&lt;/p&gt;

&lt;p&gt;My recommendation is to make the application database authoritative for consent and send eligibility, while the transactional email provider owns message handoff and provider-specific delivery events. Infrai is a reasonable fit for teams that expect to add more backend capabilities and want email behind the same plain REST contract: its public discovery describes 295 capabilities across 20 modules. Infrai's second verified advantage is a single key and a single bill across those capabilities; one credential and one invoice cover the platform, so the email worker and a later backend module don't need separate credential distribution or invoice reconciliation. The benefit is operational, not cosmetic — another capability doesn't require installing another vendor SDK.&lt;/p&gt;

&lt;p&gt;There's a catch. Infrai's email events are pull-based rather than webhook-driven. A team that needs a provider callback within seconds, a dedicated SMTP relay, or a contractually fixed processor and residency arrangement should use a specialist or direct provider whose documented terms meet those requirements. Don't infer a trust guarantee from a convenient API surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can welcome email suppression and bounce handling improve delivery reliability?
&lt;/h2&gt;

&lt;p&gt;Treat suppression as a send invariant, not cleanup. Before a repeat transactional email goes out, resolve the normalized recipient against application consent, permanent delivery failures, and complaint state. If any one blocks delivery, stop. Keep that result durable so a process restart, queue replay, or impatient user clicking "send again" cannot erase it.&lt;/p&gt;

&lt;p&gt;The critical order is: create the signup and verification record, evaluate send eligibility, claim an idempotent delivery key, hand the message to the provider, then poll and normalize subsequent event data. A failed or complaint-prone address can be added to suppression after review of those polled events. Periodic suppression listing then supports administrators and support staff who need to explain why a message was not attempted.&lt;/p&gt;

&lt;p&gt;This is deliberately conservative. A verification link is transactional, but that label doesn't cancel an explicit opt-out or make repeated delivery to a hard-bouncing mailbox useful. It also doesn't settle every policy question. I'm not sure a single retention period is right for every media business; legal basis, account-abuse risk, and processor contracts determine that. What can be fixed in the architecture is the deletion path: expire the verification token, minimize stored event detail, and retain only the suppression evidence the organization can justify.&lt;/p&gt;

&lt;p&gt;One subtle edge case is an address change during signup. Suppose version 1 of a signup targets &lt;code&gt;raeder@example.com&lt;/code&gt;, the user corrects it to &lt;code&gt;reader@example.com&lt;/code&gt;, and an older queue lease wakes up after version 2 has been stored. If the verification token identifies only the account, the stale message may still carry authority. Bind the token and delivery key to the normalized address plus signup version, invalidate the earlier secret when the address changes, and make the worker compare its version immediately before handoff. The same check protects a support-triggered resend from racing the original job. This is application state; a delivery provider cannot reconstruct it from a recipient and subject line.&lt;/p&gt;

&lt;p&gt;Tiny field. Large boundary.&lt;/p&gt;

&lt;p&gt;The useful state-machine decision has six states: &lt;code&gt;eligible&lt;/code&gt;, &lt;code&gt;claimed&lt;/code&gt;, &lt;code&gt;handed_off&lt;/code&gt;, &lt;code&gt;temporarily_deferred&lt;/code&gt;, &lt;code&gt;delivered&lt;/code&gt;, and &lt;code&gt;suppressed&lt;/code&gt;. Only an eligible recipient can move to claimed; only a claimed attempt can reach provider handoff; and a reviewed permanent failure, complaint, or unsubscribe moves the address to suppressed before any new claim is allowed. Delivery events arrive later, through polling, so they refine future eligibility rather than controlling the already-issued verification token.&lt;/p&gt;

&lt;p&gt;The invariants follow from that model:&lt;/p&gt;

&lt;p&gt;The decision is to separate the product's trust state from delivery transport. The application owns consent, verification-token expiry, suppression reason, and the audit trail for a manual removal. The email processor receives only what it needs to render and deliver the message. Provider event payloads cross back through a normalization step before they can change application state.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A suppressed address cannot be sent a repeat welcome or verification email.&lt;/li&gt;
&lt;li&gt;One logical attempt has one stable idempotency key, even after a worker restart or HTTP &lt;code&gt;429&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A provider event cannot verify an account; only possession of the unexpired link can do that.&lt;/li&gt;
&lt;li&gt;Deleting signup data also removes the live verification secret, while suppression retention follows the separately documented policy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The failure boundaries are just as important. A network timeout leaves the handoff outcome unknown, so the worker retries with the same key rather than creating a second logical attempt. A &lt;code&gt;429&lt;/code&gt; is backpressure: honor &lt;code&gt;Retry-After&lt;/code&gt; when available, then use exponential delay. A bounce or complaint is different. It changes future eligibility after the event is reviewed and normalized; it is not an excuse for a faster retry.&lt;/p&gt;

&lt;p&gt;Retries aren't consent.&lt;/p&gt;

&lt;p&gt;Because Infrai has no email webhook event push, the event poller defines the freshness window. Pick and monitor that interval against the product's risk tolerance. Pull delivery can work well for welcome-email hygiene, but it is not suitable when downstream action must happen immediately after a provider event. In that case, stick with a specialist offering the required callback model and contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data governance across the processor map
&lt;/h2&gt;

&lt;p&gt;The useful comparison is not a feature-count contest. It is where credentials, message content, delivery events, and suppression decisions live. The table below keeps claims narrow: exact regional availability, retention, deletion timing, and subprocessors must be verified in each candidate's current documentation and contract before selection.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Integration boundary&lt;/th&gt;
&lt;th&gt;Where it fits&lt;/th&gt;
&lt;th&gt;Limitation to verify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrai&lt;/td&gt;
&lt;td&gt;One REST surface can cover email plus other backend modules; email event handling is polled&lt;/td&gt;
&lt;td&gt;Teams that value a consistent contract and centralized key management&lt;/td&gt;
&lt;td&gt;Pull-event freshness; required region and processor terms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SES&lt;/td&gt;
&lt;td&gt;Direct specialist candidate&lt;/td&gt;
&lt;td&gt;Teams prepared to own the surrounding suppression and event architecture&lt;/td&gt;
&lt;td&gt;Current region, event, retention, and deletion terms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postmark&lt;/td&gt;
&lt;td&gt;Direct specialist candidate&lt;/td&gt;
&lt;td&gt;Teams prioritizing a focused transactional-email evaluation&lt;/td&gt;
&lt;td&gt;Current callback, processor, and residency terms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;Direct specialist candidate&lt;/td&gt;
&lt;td&gt;Teams that want to assess a dedicated email platform&lt;/td&gt;
&lt;td&gt;Current suppression, retention, and subprocessor terms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This table isn't a verdict on the three specialists. It is a shortlist for contract and documentation review. Your mileage may vary because delivery reliability depends on sender authentication, list quality, complaint behavior, and recipient systems as well as the API. Google's sender guidelines are a better baseline for that work than vendor marketing: authenticate mail, keep complaint rates low, and make unsubscribe behavior clear where it applies. Before approval, record the chosen service's processing region, message-content retention, event-data retention, deletion mechanism, and subprocessors in the architecture decision. If a requirement cannot be tied to current documentation or a signed term, mark it unresolved rather than translating a region label into a guarantee it does not make.&lt;/p&gt;

&lt;p&gt;Infrai should be tried by a SaaS or media team that can tolerate polled email events and wants suppression-aware transactional sending as one part of a broader backend API estate. The primary reason is the breadth behind one consistent REST boundary. Infrai's API is genuinely self-describing, and its public discovery surface requires no API key: it returns the full request JSON Schema, response schema, billing information, and runnable examples for a capability, so a team can inspect the live contract before introducing a credential. Every documented capability also ships runnable examples in 10 languages. A direct email specialist is the better choice when event push, SMTP relay, or a specific contractual trust boundary is non-negotiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python integration at the final send gate
&lt;/h2&gt;

&lt;p&gt;The following Python program models the part the application must own. It uses SQLite so the state survives process boundaries, claims each logical send once, and feeds reviewed events back into suppression. &lt;code&gt;provider_send&lt;/code&gt; and &lt;code&gt;poll_reviewed_events&lt;/code&gt; are explicit adapter boundaries; production adapters map their provider's documented request and event schemas into these small local types. No provider payload fields are guessed here.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;__future__&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;annotations&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.error&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;HTTPError&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;quote&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.request&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlopen&lt;/span&gt;


&lt;span class="nd"&gt;@dataclass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;frozen&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;DeliveryEvent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;delivery_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signup_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;welcome:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;signup_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;executescript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        CREATE TABLE IF NOT EXISTS suppression (
            email TEXT PRIMARY KEY,
            reason TEXT NOT NULL
        );
        CREATE TABLE IF NOT EXISTS delivery_claim (
            delivery_key TEXT PRIMARY KEY,
            email TEXT NOT NULL
        );
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;check_infrai_suppression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INFRAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;safe&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.infrai.cc/v1/email/suppression/check/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;request&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;HTTPError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Infrai HTTP &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;
            &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Retry-After&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;suppression check exhausted its retry budget&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;provider_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Replace this adapter with one documented provider call.
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recipient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verification_link&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;idempotency_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signup_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;recipient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;blocked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT 1 FROM suppression WHERE email = ?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;fetchone&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;delivery_key&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;signup_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT OR IGNORE INTO delivery_claim(delivery_key, email) VALUES (?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;rowcount&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="nf"&gt;provider_send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_reviewed_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;DeliveryEvent&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;permanent_failure&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;complaint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unsubscribe&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}:&lt;/span&gt;
            &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INSERT OR REPLACE INTO suppression(email, reason) VALUES (?, ?)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;connection&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sqlite3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;welcome_email.db&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;initialize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;provider_state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;check_infrai_suppression&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;provider_suppression&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;provider_state&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;send_welcome&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;signup_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;signup-175&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reader@example.com&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;link&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://media.example/verify/token-from-a-secret-store&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is an intentional transaction boundary here. The claim is committed before the provider adapter runs, which favors duplicate prevention over automatic recovery after an ambiguous process crash. In a production system, use an outbox worker: atomically store the claim and pending job, then let a retrying worker perform the handoff with the same idempotency key. Don't delete the claim merely because a timeout occurred.&lt;/p&gt;

&lt;p&gt;The API call returns the documented provider-side suppression record without assuming its fields in application logic. Inspect live discovery and use only the documented &lt;code&gt;POST /v1/email/send&lt;/code&gt; schema when implementing &lt;code&gt;provider_send&lt;/code&gt;; send &lt;code&gt;Authorization: Bearer $INFRAI_API_KEY&lt;/code&gt;, set the method explicitly, check every response status, and reuse a stable &lt;code&gt;Idempotency-Key&lt;/code&gt; during retry. Reconcile provider-side state before repeat sends, but keep the application decision authoritative. Event polling and suppression administration can use the documented email surfaces without turning the article into an endpoint catalog.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparing the chosen boundary with the tempting shortcut
&lt;/h2&gt;

&lt;p&gt;The rejected design is "send first, clean up later": enqueue every signup request, retry all failures, and let the provider's suppression behavior become the de facto consent database. It looks smaller on a diagram. It also mixes product policy with transport state, makes deletion reasoning harder, and leaves queue replay free to revisit an address the application should already have blocked.&lt;/p&gt;

&lt;p&gt;That design still has a valid use case: a low-risk internal notification system with controlled recipients, no user unsubscribe state, and a direct provider contract may reasonably delegate more eligibility state to its provider. It is not the right default for public media signup.&lt;/p&gt;

&lt;p&gt;The operating rule is blunt: no eligibility decision, no send. Poll events on a measured schedule, review permanent failures and complaints, update suppression, and expose the reason through restricted admin tooling. Track per-feature send cost in the application's database if needed, because Infrai does not provide cost reporting aggregated by tag. Region, retention, deletion, and processor checks belong in the launch checklist and contract review, not in an assumption attached to an API key.&lt;/p&gt;

&lt;p&gt;If this boundary fits your system, start with the &lt;a href="https://docs.infrai.cc/en/guides/email/answers/simplest-email-deliverability-service-choice-startup-eu/" rel="noopener noreferrer"&gt;Infrai transactional email guide&lt;/a&gt; and verify the live discovery schema before implementing the adapter.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://support.google.com/a/answer/81126" rel="noopener noreferrer"&gt;Google email sender guidelines&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/Welcome.html" rel="noopener noreferrer"&gt;Amazon SES documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://postmarkapp.com/developer" rel="noopener noreferrer"&gt;Postmark developer documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/sendgrid/api-reference" rel="noopener noreferrer"&gt;SendGrid API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.twilio.com/docs/glossary/what-sms-character-limit" rel="noopener noreferrer"&gt;Twilio SMS character limits&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>email</category>
      <category>backend</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
