<?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: KillianBerg5391</title>
    <description>The latest articles on DEV Community by KillianBerg5391 (@killianberg5391).</description>
    <link>https://dev.to/killianberg5391</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%2F4086087%2F6e66e834-6d29-4095-80bf-94871de72ef8.png</url>
      <title>DEV Community: KillianBerg5391</title>
      <link>https://dev.to/killianberg5391</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/killianberg5391"/>
    <language>en</language>
    <item>
      <title>Staged Profile State and Session Revocation over Immediate FastAPI Account Deletion</title>
      <dc:creator>KillianBerg5391</dc:creator>
      <pubDate>Wed, 26 Aug 2026 12:38:21 +0000</pubDate>
      <link>https://dev.to/killianberg5391/staged-profile-state-and-session-revocation-over-immediate-fastapi-account-deletion-58cl</link>
      <guid>https://dev.to/killianberg5391/staged-profile-state-and-session-revocation-over-immediate-fastapi-account-deletion-58cl</guid>
      <description>&lt;p&gt;A healthtech account shutdown strategy has to separate profile state and session revocation from the later deletion of clinical, billing, or audit data. Treating those moments as one database operation makes a managed-auth migration look easy in a notebook and dangerous in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; choose staged account shutdown over immediate deletion: freeze profile changes, revoke every application session, block Google and GitHub sign-in, retain only the records required by policy, and let a separate, retryable process perform eventual deletion. Immediate purge is suitable only when no retention, recovery, or audit obligation can apply.&lt;/p&gt;

&lt;p&gt;The evaluation constraint is simple: after shutdown begins, no old session or social login may restore access, while an authorized worker must still be able to explain what remains and why. That is the result to test. Deletion latency is secondary.&lt;/p&gt;

&lt;h2&gt;
  
  
  How should account shutdown coordinate profile state, session revocation, and eventual deletion?
&lt;/h2&gt;

&lt;p&gt;Use one authoritative lifecycle state for the local account, then make authentication and deletion obey it. A practical model has &lt;code&gt;active&lt;/code&gt;, &lt;code&gt;shutdown_pending&lt;/code&gt;, &lt;code&gt;retained&lt;/code&gt;, and &lt;code&gt;deleted&lt;/code&gt; states. The exact labels don't matter; the invariants do.&lt;/p&gt;

&lt;p&gt;In &lt;code&gt;active&lt;/code&gt;, profile edits and new sessions are allowed. Moving to &lt;code&gt;shutdown_pending&lt;/code&gt; records the decision, freezes mutable profile fields, increments a session epoch, and denies every new login. A retention decision then moves the account to &lt;code&gt;retained&lt;/code&gt; with a deletion eligibility time, or directly queues deletion when policy permits. &lt;code&gt;deleted&lt;/code&gt; is terminal for the local account, although independently governed records may remain under their own retention rules.&lt;/p&gt;

&lt;p&gt;That separation prevents a subtle failure during migration off a managed provider. Google and GitHub identify an external principal, but the local account decides whether that principal may enter the application. If the callback handler creates or reactivates a profile merely because a provider returned a valid identity, shutdown can be undone by the next social sign-in. The callback must resolve the existing provider link, load the local lifecycle state, and refuse session issuance unless that state is &lt;code&gt;active&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One gate. Everywhere.&lt;/p&gt;

&lt;p&gt;Keep the lifecycle check below individual login handlers so password, Google, GitHub, refresh-token, and support-assisted flows cannot drift. During the migration, the old and new authentication paths should consult the same local decision point. This makes dual-running testable without allowing either provider stack to become the source of truth for account status.&lt;/p&gt;

&lt;h2&gt;
  
  
  The simple delete-first model fails the migration test
&lt;/h2&gt;

&lt;p&gt;The tempting design is a &lt;code&gt;DELETE&lt;/code&gt; handler that removes the profile row, clears the browser cookie, and schedules whatever cleanup remains. It passes a happy-path demo. It also combines four decisions that fail differently: whether access ends, whether sessions remain valid, whether data must be retained, and whether physical erasure has completed.&lt;/p&gt;

&lt;p&gt;Consider the awkward sequence. A user has two browser sessions and one mobile session. They request shutdown from a browser while a refresh request is already in flight. Their Google identity and GitHub identity both map to the same local account. A delete-first implementation may clear one cookie yet leave two server-side credentials usable; if it removes the provider links first, a later callback can look like a new person and create a fresh account. If cleanup then retries without a stable account identifier, workers may no longer know which derived records belong to the deletion request. None of these are exotic provider behaviors. They are consequences of collapsing access control and erasure into one moment.&lt;/p&gt;

&lt;p&gt;The staged design makes the first transaction small and decisive. It changes the lifecycle state, advances a monotonic session epoch, stores the shutdown request time and policy decision, and emits an outbox event in the same local transaction. Session validation compares the credential's epoch with the account's current epoch and also requires &lt;code&gt;active&lt;/code&gt;; a mismatch ends access even if a cache or token has time left. The browser can receive a generic signed-out response, but clearing client state is cleanup, not the security boundary.&lt;/p&gt;

&lt;p&gt;Don't depend on timing.&lt;/p&gt;

&lt;p&gt;OWASP recommends invalidating sessions after reauthentication and other high-risk events, and it warns that session identifiers must be protected throughout their lifecycle. Account shutdown is at least as consequential as a credential change. Apply revocation centrally, require recent authentication before accepting the request, and avoid revealing through the response whether a particular social identity exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  A focused FastAPI domain example
&lt;/h2&gt;

&lt;p&gt;The useful code is the transition rule, not a vendor-specific callback. This Python example produces the atomic changes an application service should persist. Storage, queues, and token formats stay behind interfaces, which keeps the rule identical while the managed provider is being replaced.&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timedelta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&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;AccountState&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;ACTIVE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;SHUTDOWN_PENDING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shutdown_pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;RETAINED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retained&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;DELETED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;deleted&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;Account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;account_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;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;AccountState&lt;/span&gt;
    &lt;span class="n"&gt;session_epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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;ShutdownPlan&lt;/span&gt;&lt;span class="p"&gt;:&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;AccountState&lt;/span&gt;
    &lt;span class="n"&gt;session_epoch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;requested_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;delete_after&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;event_name&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account.shutdown.requested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;plan_shutdown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;requested_at&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="n"&gt;retention&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timedelta&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="n"&gt;ShutdownPlan&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;requested_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tzinfo&lt;/span&gt; &lt;span class="ow"&gt;is&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;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested_at must be timezone-aware&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;retention&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;timedelta&lt;/span&gt;&lt;span class="p"&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;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;retention cannot be negative&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;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;AccountState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&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;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;account is not active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;ShutdownPlan&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;AccountState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SHUTDOWN_PENDING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;session_epoch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_epoch&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;requested_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;requested_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;delete_after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;requested_at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;astimezone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;retention&lt;/span&gt;&lt;span class="p"&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;can_issue_session&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;credential_epoch&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;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="n"&gt;AccountState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;
        &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;credential_epoch&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;session_epoch&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service layer should write the returned plan and its outbox record atomically. A worker can then process deletion idempotently: lock the account, verify that &lt;code&gt;delete_after&lt;/code&gt; has passed, check for a legal hold or changed retention decision, erase eligible profile fields and provider links, and mark the account &lt;code&gt;deleted&lt;/code&gt;. Repeating the worker must produce the same final state. For a healthtech system, the deletion inventory should distinguish authentication profile data from medical, claims, consent, and audit records; “delete the user” is too vague to be an executable policy.&lt;/p&gt;

&lt;p&gt;Keep provider unlinking late enough that shutdown retries retain a stable mapping, but never treat an external unlink as session revocation inside the application. Those are different controls. A provider can stop authorizing future grants while an application session created earlier still exists, so the local epoch and lifecycle gate remain necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to measure before adopting the staged choice
&lt;/h2&gt;

&lt;p&gt;Turn the migration into an eval harness. Feed the same fixtures through the legacy and replacement authentication paths, then assert lifecycle outcomes rather than comparing implementation details. At minimum, test one account linked to both Google and GitHub, three concurrent sessions, an in-flight refresh, duplicate shutdown requests, an early deletion-worker run, and a later retry. A callback after shutdown must never issue a session or create a replacement profile. A stale credential should receive the same unauthenticated treatment regardless of which login path originally issued it.&lt;/p&gt;

&lt;p&gt;Measure revocation propagation time, the count of session-issuance attempts blocked by non-active state, deletion jobs by age and terminal outcome, records retained by policy category, and unexplained differences between the two auth paths. Don't put email addresses, provider tokens, or health data into those metrics. Use opaque account and request identifiers so the observability layer doesn't become another deletion surface.&lt;/p&gt;

&lt;p&gt;I'm not sure a universal retention interval is defensible across healthtech products; jurisdiction, record type, contracts, and legal holds can change the answer. Resolve that uncertainty with counsel and data owners, then encode the approved policy as versioned input to the shutdown plan. The architecture should support a policy decision without pretending engineers can invent one.&lt;/p&gt;

&lt;p&gt;The catch is operational weight. Staged shutdown needs a lifecycle column, centralized session checks, an outbox or equivalent durable handoff, an idempotent worker, and reconciliation. Immediate deletion is the better choice for a genuinely disposable account with no server-side sessions, linked records, recovery window, or retention duty. Likewise, stick with the current managed provider during migration when it is still the only component capable of enumerating and revoking all live sessions; switch authority only after the replacement passes that inventory test.&lt;/p&gt;

&lt;p&gt;The decisive preproduction metric is the number of successful authentications after the shutdown transaction commits. It must be zero. Once that invariant holds across both social providers and every legacy session format, eventual deletion can proceed at the pace policy allows without leaving access in limbo.&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>authentication</category>
      <category>shutdown</category>
      <category>python</category>
    </item>
    <item>
      <title>Transactional Password Reset Email in Node.js: Custom-Domain Trust Controls</title>
      <dc:creator>KillianBerg5391</dc:creator>
      <pubDate>Mon, 24 Aug 2026 01:08:42 +0000</pubDate>
      <link>https://dev.to/killianberg5391/transactional-password-reset-email-in-nodejs-custom-domain-trust-controls-3i6n</link>
      <guid>https://dev.to/killianberg5391/transactional-password-reset-email-in-nodejs-custom-domain-trust-controls-3i6n</guid>
      <description>&lt;p&gt;Short answer: password reset email deliverability for a US or EU app is a trust-boundary problem. Own the custom sending domain, publish DKIM, SPF, and DMARC before production traffic, keep reset state in your application, and make suppression decisions before the send. Sender warming and event polling then become policies you can evaluate rather than emergency fixes.&lt;/p&gt;

&lt;p&gt;For the concrete e-commerce case, the message is an order receipt after payment settles. A password reset has the same shape: the application owns the sensitive decision, while a processor handles delivery. My experiment starts with a boundary diagram, not a provider dashboard. It asks who may retain an address, who can delete it, and who is allowed to send.&lt;/p&gt;

&lt;p&gt;Infrai belongs in the candidate set when a team wants that mail worker beside other backend capabilities behind one plain REST contract. Its public discovery surface makes the contract inspectable before a key is used, which is useful for a Python eval harness even when the production service is Node.js. I've found that this early contract check catches naming mistakes before they reach a production sender.&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment: reject a send before it becomes a delivery incident
&lt;/h2&gt;

&lt;p&gt;Use a fixed test matrix: a valid and invalid DNS configuration, a small sender-warming ramp, an already-suppressed address, an expired reset token, and a poll that receives HTTP 429. The expected result is a generic application response, no send to the suppressed address, bounded exponential backoff, and an event joined to a correlation ID. The raw reset token never enters the event log. For a receipt, the fixture carries an order reference and a settled-payment flag instead.&lt;/p&gt;

&lt;p&gt;This is intentionally unglamorous. It reveals more than an inbox screenshot because it exercises region, retention, deletion, authentication, and retry decisions in one run. Measure failed-delivery rate, complaint-like outcomes, event-poll lag, token expiry, and the interval between a suppression change and the next attempted send before copying the design.&lt;/p&gt;

&lt;p&gt;Keep the ramp boring.&lt;/p&gt;

&lt;p&gt;Start with a small, predictable set of US and EU recipients, preserve the same From identity, and increase volume only when hard failures and complaint-like outcomes stay inside limits your team chose. Password resets arrive in bursts, so a warm sender does not justify an unbounded retry loop after an attack or an import job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which processor keeps the Node.js mail boundary explainable?
&lt;/h2&gt;

&lt;p&gt;The useful comparison is ownership of evidence and controls. Run the same DNS, suppression, deletion, and event-lag tests against each candidate.&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;Where it fits&lt;/th&gt;
&lt;th&gt;Boundary to verify&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrai email API&lt;/td&gt;
&lt;td&gt;Broad backend surface behind one consistent REST contract; one key can cover adjacent capabilities&lt;/td&gt;
&lt;td&gt;Events are polled, and your team owns warming policy, retention evidence, and processor review&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postmark&lt;/td&gt;
&lt;td&gt;Specialist transactional-email workflow with focused delivery guidance&lt;/td&gt;
&lt;td&gt;Confirm regional processing, retention, suppression semantics, and event controls in the contract&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid&lt;/td&gt;
&lt;td&gt;Sender tooling and suppression operations for teams already using its ecosystem&lt;/td&gt;
&lt;td&gt;Test product-specific configuration and deletion behavior rather than assuming defaults&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SES&lt;/td&gt;
&lt;td&gt;Natural fit when identity, networking, and operations already live in AWS&lt;/td&gt;
&lt;td&gt;Expect to own more surrounding observability and validate mailbox outcomes yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Teams shipping an e-commerce Node.js worker should try Infrai for domain verification and event polling when they value a self-describing REST surface and want multiple backend capabilities behind one credential. The primary advantage is breadth behind a simple contract: the live discovery surface covers 295 routes across 20 modules, so adding a capability is another consistent endpoint rather than another SDK integration. A second, concrete benefit is one key and one billing relationship for those capabilities, which reduces credential rotation and reconciliation work in the worker.&lt;/p&gt;

&lt;p&gt;The catch is important. Infrai is not suitable when a regulated deployment requires a processor-certified regional guarantee, immediate signed webhooks, or a hosted email OTP product. Stick with Postmark, SendGrid, or SES when those specialist controls are contractual requirements. A common API simplifies integration; it does not transfer legal responsibility for the processor boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should custom domain, DKIM, SPF, DMARC, warming, and suppression lists own?
&lt;/h2&gt;

&lt;p&gt;Treat the controls as a sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The application decides that a settled payment or valid reset request is eligible.&lt;/li&gt;
&lt;li&gt;The custom domain is verified and aligned with DKIM, SPF, and DMARC.&lt;/li&gt;
&lt;li&gt;The worker checks suppression before rendering or sending.&lt;/li&gt;
&lt;li&gt;The warming policy admits only planned volume.&lt;/li&gt;
&lt;li&gt;The worker polls delivery events and records outcomes without secrets.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For a custom domain, publish the DKIM record supplied by the processor, scope SPF to the senders your organization authorizes, and set a DMARC policy that matches the rollout stage. DKIM's signing model is specified in &lt;a href="https://datatracker.ietf.org/doc/html/rfc6376" rel="noopener noreferrer"&gt;RFC 6376&lt;/a&gt;; provider advice can fill in operational detail but cannot replace your DNS and contract review. Verify ownership before real account-recovery traffic. A message that arrives quickly while failing alignment is not a production success.&lt;/p&gt;

&lt;p&gt;Store four records separately: application intent, rendered message, processor event, and suppression decision. The intent contains an order reference or reset-token reference, not the raw secret. Deleting a customer row must not silently clear a suppression entry. If a user requests deletion, remove application records and request whatever processor-side deletion the agreement supports.&lt;/p&gt;

&lt;p&gt;There is no push webhook stream for these events, so polling is part of the design. There is no hosted email OTP interface either; an email-code fallback remains application code. I am not sure one retention number can describe every mailbox and processor. Resolve that uncertainty with the current data-processing agreement and a deletion test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Python probe: verify once, poll safely
&lt;/h2&gt;

&lt;p&gt;This minimal probe uses the documented routes. The same two calls can be expressed with a Node.js HTTP client, but Python keeps the notebook-to-prod fixture short. The helper explicitly handles status codes and rate limits; it does not assume a 200 response.&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;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;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;


&lt;span class="n"&gt;BASE_URL&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://api.infrai.cc/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_domain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;domain&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;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="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;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&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="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/email/domain/verify&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;json&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;domain&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;domain&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;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="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="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;:&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;response&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;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="nf"&gt;int&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;retry_after&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isdigit&lt;/span&gt;&lt;span class="p"&gt;()&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="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&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;verification rejected: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;verification remained rate-limited after four attempts&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;list_events&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;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;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE_URL&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/email/event/list&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;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="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="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&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;event poll rejected: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="nf"&gt;verify_domain&lt;/span&gt;&lt;span class="p"&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;SENDING_DOMAIN&lt;/span&gt;&lt;span class="sh"&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;event&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;list_events&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;data&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;event_id&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;id&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;type&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Persist a cursor and correlation IDs in the worker's own store. Any production send or other write should carry a client-generated idempotency key so a retry cannot duplicate a receipt. Keep the API key server-side; never put it in a reset URL or forward it to a presigned or recipient-facing URL.&lt;/p&gt;

&lt;p&gt;If this boundary fits your deployment, use the &lt;a href="https://docs.infrai.cc/en/guides/email/answers/password-reset-email-deliverability-setup-custom-domain/" rel="noopener noreferrer"&gt;custom-domain deliverability guide&lt;/a&gt; to verify the domain step before running the matrix.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc6376" rel="noopener noreferrer"&gt;https://datatracker.ietf.org/doc/html/rfc6376&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://postmarkapp.com/guides/transactional-email-best-practices" rel="noopener noreferrer"&gt;https://postmarkapp.com/guides/transactional-email-best-practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.sendgrid.com/ui/sending-email/sender-authentication" rel="noopener noreferrer"&gt;https://docs.sendgrid.com/ui/sending-email/sender-authentication&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html" rel="noopener noreferrer"&gt;https://docs.aws.amazon.com/ses/latest/dg/creating-identities.html&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc/en/guides/email/answers/password-reset-email-deliverability-setup-custom-domain/" rel="noopener noreferrer"&gt;https://docs.infrai.cc/en/guides/email/answers/password-reset-email-deliverability-setup-custom-domain/&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>emaildeliverability</category>
      <category>dkim</category>
      <category>dmarc</category>
    </item>
    <item>
      <title>Implementing 2 Node.js Password-Reset Paths — Custom-Domain DKIM/SPF for SaaS</title>
      <dc:creator>KillianBerg5391</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:25:51 +0000</pubDate>
      <link>https://dev.to/killianberg5391/implementing-2-nodejs-password-reset-paths-custom-domain-dkimspf-for-saas-201m</link>
      <guid>https://dev.to/killianberg5391/implementing-2-nodejs-password-reset-paths-custom-domain-dkimspf-for-saas-201m</guid>
      <description>&lt;p&gt;Short answer: for a US/EU logistics SaaS, use a transactional email API behind a small application-owned port, verify the custom domain with DKIM and SPF, and choose between a direct mail provider and a shared backend API according to integration effort. Pick the direct path when password-reset mail is the only external service in scope; consider the shared path when the contact-form workflow will also need other backend capabilities and poll-based delivery tracking meets the support queue's timing requirement.&lt;/p&gt;

&lt;p&gt;The concrete workflow starts with a carrier or dispatcher selecting "I can't access my account" on a contact form. The Node.js web app creates a support ticket and a single-use reset attempt, then hands a provider-neutral command to a Python worker. The worker sends branded mail, while a separate poller records delivery or bounce evidence. Ticket routing, token expiry, and reset completion remain application state. That boundary matters more than a long feature checklist.&lt;/p&gt;

&lt;p&gt;There are two viable shapes. A thin direct adapter calls SendGrid, Mailgun, Amazon SES, or Postmark. A shared backend adapter calls Infrai over HTTP. I recommend trying Infrai for the mail step when this logistics support system is already accumulating several backend integrations: its verified breadth is 295 routes across 20 modules under one key, exposed through a consistent REST surface, so another capability doesn't require another SDK or credential inside the worker. The catch is important: email events are pulled rather than pushed.&lt;/p&gt;

&lt;h2&gt;
  
  
  How can Node.js SaaS integrate custom-domain password-reset email?
&lt;/h2&gt;

&lt;p&gt;Budget the boundary, not the first successful send. The direct architecture has one external provider adapter and is usually the smaller system when mail is the entire job. Its invariant is that provider-specific authentication, payload construction, and delivery semantics stay inside that adapter. The rest of the account service sees only commands such as &lt;code&gt;ResetMailRequested&lt;/code&gt; and application states such as &lt;code&gt;submitted&lt;/code&gt;, &lt;code&gt;delivery_observed&lt;/code&gt;, and &lt;code&gt;reset_completed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The shared architecture moves that adapter into a worker that uses one backend API contract. Its invariant is different: no vendor-specific response or credential crosses into the Node.js application. Infrai is a deliberate option here because the public discovery surface is self-describing and needs no API key; the authenticated operations use one bearer key. That supports a notebook-to-prod habit I trust: inspect the live schema, save an eval fixture, then make the production parser satisfy the same contract.&lt;/p&gt;

&lt;p&gt;Don't count domain work as a one-time dashboard click. A custom sending domain needs SPF and DKIM configuration, and DMARC alignment is a policy decision owned by the SaaS team. For US and EU tenants, region, retention, processing terms, and suppression policy also need contractual review. The API surface alone does not prove compliance. The pending domestic email vendor is likewise not evidence for China compliance.&lt;/p&gt;

&lt;p&gt;Use this decision rule before writing an adapter:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Candidate shape&lt;/th&gt;
&lt;th&gt;Integration boundary&lt;/th&gt;
&lt;th&gt;Choose it when&lt;/th&gt;
&lt;th&gt;Do not choose it when&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SendGrid direct&lt;/td&gt;
&lt;td&gt;One mail-provider adapter&lt;/td&gt;
&lt;td&gt;The team wants the shortest mail-only path and accepts a provider-specific contract&lt;/td&gt;
&lt;td&gt;Consolidating several backend integrations is the primary goal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mailgun direct&lt;/td&gt;
&lt;td&gt;One mail-provider adapter&lt;/td&gt;
&lt;td&gt;The team's own acceptance test selects its documented mail contract&lt;/td&gt;
&lt;td&gt;The architecture requires one credential across backend categories&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon SES direct&lt;/td&gt;
&lt;td&gt;One AWS-specific adapter&lt;/td&gt;
&lt;td&gt;The application already places this boundary inside its AWS governance&lt;/td&gt;
&lt;td&gt;Avoiding provider-specific integration is the main requirement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postmark direct&lt;/td&gt;
&lt;td&gt;One mail-provider adapter&lt;/td&gt;
&lt;td&gt;Its documented transactional-mail behavior wins the team's eval&lt;/td&gt;
&lt;td&gt;The support worker must share one API convention with non-mail work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrai shared&lt;/td&gt;
&lt;td&gt;One cross-capability HTTP adapter&lt;/td&gt;
&lt;td&gt;A consistent API and one key remove meaningful integration work&lt;/td&gt;
&lt;td&gt;Webhook delivery events, SMTP relay, or managed email OTP are mandatory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is intentionally not a feature-score winner. Each direct candidate still needs a current documentation and contract review. The table answers a system-shape question: where does coupling live, and how many external contracts must the team operate?&lt;/p&gt;

&lt;h2&gt;
  
  
  Implement the live API contract probe first
&lt;/h2&gt;

&lt;p&gt;Start with a runnable probe that tests the part most likely to reshape the architecture: schema discovery and delivery observation. The script below performs two explicit &lt;code&gt;GET&lt;/code&gt; calls, keeps the bearer credential off the public discovery request, handles &lt;code&gt;429&lt;/code&gt; with &lt;code&gt;Retry-After&lt;/code&gt; or exponential backoff, and exposes every other HTTP error. It doesn't guess undocumented event fields.&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;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;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_send_contract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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;https://api.infrai.cc/v1/discovery/email.send&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;Accept&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&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;20&lt;/span&gt;&lt;span class="p"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&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;discovery failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;get_email_events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&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="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;Any&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="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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;https://api.infrai.cc/v1/email/event/list&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;Accept&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;application/json&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;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="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_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;and&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;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&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;response&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;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;400&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;event list failed (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;text&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;return&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;event list exceeded &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempts&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; attempts&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;main&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;capability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_send_contract&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;events&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_email_events&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="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;send_contract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;capability&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;events&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install &lt;code&gt;requests&lt;/code&gt;, set &lt;code&gt;INFRAI_API_KEY&lt;/code&gt; to an &lt;code&gt;ifr_...&lt;/code&gt; key in the environment, and run &lt;code&gt;python contract_probe.py&lt;/code&gt;. The key never belongs in the file.&lt;/p&gt;

&lt;p&gt;The discovery URL describes the &lt;code&gt;email.send&lt;/code&gt; capability; the operational URL is the verified &lt;code&gt;GET /v1/email/event/list&lt;/code&gt; route. Save the discovery response beside the adapter's contract tests. Then derive the eventual &lt;code&gt;POST /v1/email/send&lt;/code&gt; request body from its current JSON Schema rather than from a copied article. That is the honest limit of this example: the available material verifies the route but does not reproduce its request fields, so printing a made-up send payload would create a more dangerous tutorial, not a more complete one.&lt;/p&gt;

&lt;p&gt;The probe also gives an early go/no-go signal. If a support queue requires an event push within seconds, stop evaluating this shared path; Infrai has no email webhook event push. If a bounded polling window is acceptable, continue and define that window from the queue's service objective. I'm not sure a universal interval exists here, and the evidence does not establish one. Your mileage may vary.&lt;/p&gt;

&lt;p&gt;One sharp edge is enough to invalidate a demo. A &lt;code&gt;200&lt;/code&gt; from the event-list call proves that the list request succeeded; it does not prove a particular reset email was delivered, opened, or used. Keep the raw fixture, map only documented fields, and let unknown stay unknown.&lt;/p&gt;

&lt;p&gt;Good. Now build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Operate retries without corrupting reset state
&lt;/h2&gt;

&lt;p&gt;The contact form should route intent before email enters the picture. An explicit account-access selection can deterministically enter the access queue. If an AI classifier handles free text, evaluate it against labeled logistics tickets and retain a deterministic rule for obvious reset requests. Prompt cost belongs in that classifier evaluation, not in the security state machine. Never put a reset token, API key, or mailbox secret into a prompt.&lt;/p&gt;

&lt;p&gt;The application record needs separate facts for ticket creation, reset-token issuance, mail submission, observed mail status, and token redemption. Do not compress those into a &lt;code&gt;success&lt;/code&gt; boolean. Consider a duplicate form submission at 14:03, followed by a worker retry after an HTTP &lt;code&gt;429&lt;/code&gt;: the correct outcome is still one usable reset attempt and one stable ticket transition, even though transport work ran more than once. The platform specifies &lt;code&gt;Idempotency-Key&lt;/code&gt; as an idempotency convention with a 24-hour default deduplication window, but the application database must still enforce single use and expiry for the reset token. Transport deduplication and account security solve different problems.&lt;/p&gt;

&lt;p&gt;Keep the user-facing language equally precise. After the send API accepts a request, say that the reset email was submitted. Do not tell an agent it was delivered until the polling record supports that state. Do not reveal whether an account exists in the public response to the contact form. These choices are dull on purpose — they prevent the support UI from turning delivery guesses into security claims.&lt;/p&gt;

&lt;p&gt;Domain authentication has the same separation of concerns. SPF identifies permitted senders, DKIM signs the message, and DMARC defines policy around authenticated alignment. Configure the custom domain through the provider's verified process, inspect the DNS result, and test alignment before moving production traffic. Apple Mail Privacy Protection is another reason not to use an open signal as proof that a person acted on a reset message.&lt;/p&gt;

&lt;p&gt;No managed email OTP endpoint is available in this path. Password-reset links and an application-built email code flow remain possible, but the latter means the application owns code generation, storage, expiry, attempt limits, and verification. There is also no SMTP relay, so the adapter must call the HTTP API directly. Those are capability boundaries, not implementation surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Govern US and EU mail with an acceptance eval
&lt;/h2&gt;

&lt;p&gt;Create the same acceptance suite for both architectures. Feed it a valid account-access ticket, a nonexistent account, two identical submissions, an expired token, a &lt;code&gt;429&lt;/code&gt; response with and without &lt;code&gt;Retry-After&lt;/code&gt;, repeated event data, and a message whose status remains unknown through the observation window. The expected outputs should be application states and allowed transitions. Provider marketing terms are not assertions.&lt;/p&gt;

&lt;p&gt;This is where the options separate cleanly. Stick with SendGrid, Mailgun, Amazon SES, Postmark, or another direct specialist when its mail-specific contract wins that suite and future backend consolidation is speculative. Choose the shared boundary when several real integrations are already on the roadmap, a plain HTTP contract materially reduces SDK and credential work, and polling satisfies the queue. Infrai's one-key breadth is useful in the second case; it isn't a reason to accept the wrong event model in the first.&lt;/p&gt;

&lt;p&gt;The operational review should read like prose because operators experience a sequence, not a checklist. Confirm that the contact form creates one opaque ticket response, the router selects account access, the account service mints one expiring token, and the worker submits mail through the verified domain. Confirm that a bounded poller can replay events without corrupting state, that &lt;code&gt;429&lt;/code&gt; pauses rather than spins, and that an unresolved message remains unknown. Finally, verify that only redemption or an authorized agent action resolves the ticket.&lt;/p&gt;

&lt;p&gt;Short paths win sometimes.&lt;/p&gt;

&lt;p&gt;For a small SaaS with one reset template, the direct adapter is a sensible stopping point. For a logistics platform whose support worker already needs multiple backend categories, the shared adapter can be the cleaner long-term shape. The invariant in either design is stable: the application owns identity, queue routing, reset security, and truth about completion.&lt;/p&gt;

&lt;p&gt;If that shared boundary fits your system, use the &lt;a href="https://docs.infrai.cc/en/guides/email/answers/best-transactional-email-api-for-password-reset-flow-no/" rel="noopener noreferrer"&gt;Infrai password-reset email guide&lt;/a&gt; to inspect the current contract before implementing the sender.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/html/rfc7489" rel="noopener noreferrer"&gt;RFC 7489: Domain-based Message Authentication, Reporting, and Conformance&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios" rel="noopener noreferrer"&gt;Apple Mail Privacy Protection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.infrai.cc" rel="noopener noreferrer"&gt;Infrai documentation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>node</category>
      <category>email</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
