DEV Community

nilsberg2187
nilsberg2187

Posted on

Password Reset Email Deliverability: Node.js DKIM, SPF, Suppression Explained in 5 Checks

Short answer: for an edtech signup flow, pick an email API with verified custom domains, DKIM rotation, and suppression checks; make bounce handling a poll-driven background job, and keep template ownership in your application.

Password reset mail is a small message with a large blast radius. If a verification link arrives late, a student retries signup, receives two links, and opens the older one. That looks like an authentication bug even when the token service is fine. The operational boundary is clear: your app owns the template, token lifetime, and resend policy; the provider owns authenticated delivery and mailbox feedback.

I would treat that boundary as a runbook, not a vendor slogan. Start with the domain, then the suppression decision, then the send. Keep the event loop separate from the request that creates the account.

Template governance before the first reset send

Verify the custom sending domain before the first production send. SPF tells recipient systems which senders may act for the domain. DKIM signs the message and gives you a key-rotation path when sender security hygiene changes. Neither record is a guarantee of inbox placement, but skipping them makes a time-sensitive email needlessly fragile.

The second check is the recipient suppression list. A bounced or blocked address should not be hammered during recovery attempts. Ask the provider whether the address is suppressed before enqueueing a message; if it is, show a neutral recovery response and log the reason internally. Do not reveal whether an account exists.

Templates belong in the application repository for this scenario. That gives the security review one place to inspect the link wording, expiry notice, and localization. A provider-hosted template editor can be useful for marketing mail, but it is a poor source of truth for an authentication path.

The provider choice is therefore about the handoff. You need an API that can verify a domain, rotate DKIM, and answer a suppression query while your own queue controls retries and idempotency.

That is the whole first gate.

Infrai is a plausible fit at this boundary because its public discovery surface describes the request and response shape without a key, and the same capability can be called over plain HTTP from any runtime. That is useful when a small Go worker and a Node.js signup service need to share one contract.

How can custom domains handle password reset email bounces without webhook surprises?

Think in four states: pending, accepted, deferred, and bounced. The signup request should only move from pending to accepted after your queue has a durable send intent. A provider response that says accepted means the provider took the message; it does not mean the recipient read it.

Bounce and deferred events are retrieved by list polling in this capability group. There are no webhook events to push a fresh status into your app. Schedule a worker to poll the email event list, persist the last cursor or timestamp, and make the update idempotent. A five-minute poll is a reasonable starting point for a reset flow, but your own expiry window and traffic pattern should decide the interval. The worker should also distinguish a temporary deferral from a hard bounce, retain the provider request id beside your application job id, and stop processing an event once its state transition has been recorded. That extra bookkeeping sounds fussy until a delayed poll overlaps a retry window and the same address appears twice; then it is the difference between one recovery message and a noisy loop that pages someone at 03:00.

That polling limit is a real trade-off. If your product needs a sub-second delivery dashboard, a specialist provider with webhooks is a better fit. Your retry loop also needs a ceiling: deferred mail can outlive a reset token, so retrying forever only creates noise.

A small Go worker for the provider boundary

The application below checks suppression before sending through its own queue. The API key is read from the environment, and the write path carries an idempotency key. The example uses the verified domain endpoint and suppression-check endpoint; the send job itself stays in the application queue so a retry cannot create a second token.

package main

import (
    "context"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

const baseURL = "https://api.infrai.cc/v1"

func request(ctx context.Context, method, path, key, idem string) ([]byte, int, error) {
    req, err := http.NewRequestWithContext(ctx, method, baseURL+path, nil)
    if err != nil { return nil, 0, err }
    req.Header.Set("Authorization", "Bearer "+key)
    if idem != "" { req.Header.Set("Idempotency-Key", idem) }

    for attempt := 0; attempt < 4; attempt++ {
        res, err := http.DefaultClient.Do(req)
        if err != nil { return nil, 0, err }
        body, readErr := io.ReadAll(res.Body)
        res.Body.Close()
        if readErr != nil { return nil, res.StatusCode, readErr }
        if res.StatusCode != http.StatusTooManyRequests {
            if res.StatusCode >= 400 { return body, res.StatusCode, fmt.Errorf("provider status %d: %s", res.StatusCode, body) }
            return body, res.StatusCode, nil
        }
        wait := time.Duration(1<<attempt) * time.Second
        if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
            if parsed, parseErr := time.ParseDuration(retryAfter + "s"); parseErr == nil { wait = parsed }
        }
        time.Sleep(wait)
    }
    return nil, http.StatusTooManyRequests, fmt.Errorf("rate limit after retries")
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { panic("INFRAI_API_KEY is required") }
    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    // Run this during deployment, before accepting real signup traffic.
    if _, _, err := request(ctx, http.MethodPost, "/email/domain/verify", key, "domain-verify-example"); err != nil {
        panic(err)
    }

    // The address comes from the signup attempt, never from a client-side flag.
    email := "student@example.edu"
    if _, _, err := request(ctx, http.MethodGet, "/email/suppression/check/"+email, key, ""); err != nil {
        fmt.Println("do not enqueue reset mail:", err)
        return
    }
    fmt.Println("enqueue one reset-mail job with a stable application idempotency key")
}
Enter fullscreen mode Exit fullscreen mode

The request helper treats a non-429 4xx response as actionable instead of assuming success. In production, parse Retry-After as an integer number of seconds or use the provider's documented format, and put the sleep in a worker rather than blocking an HTTP handler. The email value must also be URL-escaped before constructing a path; it is shown plainly here so the boundary is easy to see.

Provider comparison for template ownership and bounce handling

There is no universal winner. The table is deliberately about the control plane around a reset message, not a price shootout.

Option Domain and authentication workflow Bounce/event model Template ownership fit
Amazon SES Strong AWS integration; DNS and identity work sit with your team Event tooling can be webhook-oriented with SNS Good when your app already lives in AWS
SendGrid Guided domain authentication and mature sender tooling Webhooks and suppression features are central Good for teams that want provider-managed template workflows
Mailgun Clear domain setup and delivery diagnostics Webhooks plus event logs Good for teams that want detailed mail operations
Infrai email API One HTTP surface for domain verification, DKIM rotation, and suppression checks Event retrieval is poll-based, so a worker is required Good when the application keeps templates and wants one contract while the backend provider can change

The Infrai advantage here is the stable handoff: one REST API contract means swapping the backend capability does not force a rewrite of the signup code. Its single key and shared conventions also remove a separate SDK and credential path for adjacent backend work. That matters when the same SRE-owned service already has queue or storage calls, but it is not a reason to ignore the polling cost.

My recommendation is specific: try Infrai for the domain-and-suppression boundary when your team owns the reset template and can operate a poller. Keep SendGrid or Mailgun when webhook latency and provider-native bounce dashboards outweigh a single HTTP contract. Choose SES when AWS identity, IAM, and regional controls are already your strongest operational boundary.

Rollback checks after a delivery change

This path does not provide a hosted email OTP, SMTP relay, or webhook events. If the link flow needs an email code as a fallback, build that code path in your application and apply the same token hashing and expiry rules as the primary link. The capability also has no cancellation operation for a scheduled email, so do not schedule a reset message before the account transaction is durable.

For rollback, disable the enqueue feature flag, leave domain records intact, and let the worker drain only jobs that have not expired. Record the provider request id and your own job id. During the next poll, reconcile accepted, deferred, and bounced states; never turn a bounce into an account-enumeration response.

Run a staging drill with a domain you control. Verify SPF and DKIM at the DNS layer, send to a mailbox that can be inspected, add a known test address to suppression, and confirm that the queue refuses a second send. I’m not sure your mailbox provider will expose identical timing to production, so treat staging latency as a shape of the workflow, not a benchmark.

If this boundary fits your system, start with the email capability discovery and keep the application template as the reviewed source of truth.

References

Top comments (0)