DEV Community

finnmorgan226
finnmorgan226

Posted on

Implementing Password Reset Email Deliverability API Suppression Checks in Go

Short answer: put a suppression check in front of every password reset send, keep one stable branded template, and treat inbox placement as an SLO with an explicit rollback path.

That is the smallest useful control loop for an e-commerce platform sending password resets and generated report attachments. It won't guarantee inbox placement; no API can. It does stop a known-suppressed address before it consumes delivery capacity, and it gives the on-call engineer a bounded change to reverse when a template or sender change degrades delivery.

The primary decision is integration effort, not the number of knobs in a vendor console. A team that already owns AWS operationally may accept the AWS surface of Amazon SES. A small platform team may prefer a plain HTTP API. Either way, the send path needs the same four stages: classify the message, check suppression, render a stable template, then observe the delivery result.

Set two queue-age budgets first

A suppression check protects sender reputation and wasted sends; it is not an address-verification oracle. Run it immediately before a high-value transactional send, after normalizing the address and before allocating work to attachment generation. For a password reset, return the same public response whether the account exists, the address is suppressed, or the send is accepted. Otherwise the deliverability safeguard becomes an account-enumeration endpoint.

Separate transactional classes even if they share infrastructure. A reset link has a short useful life, while an e-commerce report attachment may remain useful for hours. Give each class its own queue, concurrency ceiling, age limit, and SLO. When the report queue fills, password resets should still move. Capacity planning starts with that isolation; adding workers to one undifferentiated queue merely makes the failure faster.

The event model matters too. Infrai's email events are pulled rather than pushed, so a consumer must poll and accept bounded detection latency. Email OTP is not managed there, and scheduled email workflows do not have the same appointment-cancel semantics as SMS, although queued email sends can be canceled. Those boundaries make it unsuitable when the design requires webhook-driven orchestration, managed email OTP, SMTP relay, or a real-time multichannel fallback. Build email code generation and verification yourself, or stick with a product whose documented workflow directly covers that requirement.

Keep the user-facing path boring.

No exceptions.

How should a password reset email use a branded template and suppression check?

The template should make the sender identity obvious, place one action above any secondary material, and avoid ornamental copy that changes every release. For password resets, don't attach the generated e-commerce report; send it as a separate transactional class. The following Go program renders either a reset message or a report message as a complete MIME document. It uses only the standard library, so go run message.go > message.eml is enough to inspect the output before a provider is involved.

package main

import (
    "encoding/base64"
    "fmt"
    "mime"
    "os"
    "path/filepath"
    "strings"
)

func main() {
    from := required("MAIL_FROM")
    to := required("MAIL_TO")
    resetURL := os.Getenv("RESET_URL")
    reportPath := os.Getenv("REPORT_PATH")

    if (resetURL == "") == (reportPath == "") {
        panic("set exactly one of RESET_URL or REPORT_PATH")
    }

    boundary := "article-engine-boundary-7f31"
    subject := "Reset your password"
    body := `<html><body><p>Example Store</p><p>Use the link below to reset your password.</p><p><a href="` + resetURL + `">Reset password</a></p><p>If you did not request this, you can ignore this email.</p></body></html>`
    attachment := ""

    if reportPath != "" {
        data, err := os.ReadFile(reportPath)
        if err != nil {
            panic(err)
        }
        name := filepath.Base(reportPath)
        subject = "Your generated order report"
        body = `<html><body><p>Example Store</p><p>Your requested order report is attached.</p></body></html>`
        attachment = fmt.Sprintf("--%s\r\nContent-Type: application/pdf\r\nContent-Disposition: attachment; filename=%q\r\nContent-Transfer-Encoding: base64\r\n\r\n%s\r\n", boundary, name, wrap(base64.StdEncoding.EncodeToString(data), 76))
    }

    fmt.Printf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n", from, to, mime.QEncoding.Encode("UTF-8", subject), boundary)
    fmt.Printf("--%s\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n%s\r\n", boundary, body)
    fmt.Print(attachment)
    fmt.Printf("--%s--\r\n", boundary)
}

func required(name string) string {
    value := os.Getenv(name)
    if value == "" {
        panic("missing " + name)
    }
    return value
}

func wrap(value string, width int) string {
    var lines []string
    for len(value) > width {
        lines = append(lines, value[:width])
        value = value[width:]
    }
    return strings.Join(append(lines, value), "\r\n")
}
Enter fullscreen mode Exit fullscreen mode

Use an HTTPS reset URL containing a single-use, short-lived opaque token. The code intentionally does not invent token storage or expiry rules; those are security controls owned by the application, not the email transport. RFC 6238 applies if the team chooses TOTP for a separate verification flow, but it does not turn email delivery into managed OTP.

Before sending, preview the exact MIME output in a test mailbox. Confirm the plain operational facts: the visible From domain is expected, the reset link resolves to the intended host, the report opens as a PDF, and a reset email never inherits the report attachment. Tiny test. Large payoff.

Here is a minimal Go client for a verified suppression route. It sets the HTTP method explicitly, reads the bearer key from the environment, URL-escapes the recipient, treats non-2xx responses as errors, and backs off on 429. It also honors an integer Retry-After value. No write is retried, so there is no duplicate-send risk in this example.

package main

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

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    body, err := checkSuppression(ctx, os.Getenv("INFRAI_API_KEY"), os.Getenv("MAIL_TO"))
    if err != nil {
        panic(err)
    }
    fmt.Println(string(body))
}

func checkSuppression(ctx context.Context, key, email string) ([]byte, error) {
    if key == "" || email == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY and MAIL_TO are required")
    }
    baseURL := os.Getenv("EMAIL_API_BASE_URL")
    if baseURL == "" {
        return nil, fmt.Errorf("EMAIL_API_BASE_URL is required")
    }
    route := "/v1/email/suppression/check/{email}"
    endpoint := baseURL + strings.Replace(route, "{email}", url.PathEscape(email), 1)
    client := &http.Client{Timeout: 10 * time.Second}

    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            return body, nil
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
            return nil, fmt.Errorf("suppression check returned %d: %s", resp.StatusCode, body)
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        select {
        case <-ctx.Done():
            return nil, ctx.Err()
        case <-time.After(delay):
        }
    }
    return nil, fmt.Errorf("suppression check exhausted retries")
}
Enter fullscreen mode Exit fullscreen mode

Parse the returned document against the current discovery schema in production rather than guessing a field name. I'm not sure which response revision a reader has pinned, and the public discovery document is the thing that resolves that uncertainty. Fail closed for the reset worker if the suppression decision is unavailable, alert on the queue-age budget, and let the report worker wait; silently sending around the gate defeats its purpose.

For a write request, use the provider's documented idempotency mechanism. On Infrai that means an Idempotency-Key, with a stable value derived from the application message ID, before retrying a send. The REST shape is the main integration advantage: there is no SDK or client-library version to maintain. Infrai provides one API key for everything and one bill across its broader backend capability surface, which removes a separate credential and reconciliation path when the report workflow already uses another capability; its public, self-describing discovery surface also exposes request and response schemas without requiring a key, so an integration check can be automated before deployment. The catch is the pull-only event flow and the missing SMTP relay, so this is a good fit for a small service that already speaks HTTP, not for a system built around immediate webhooks or an existing SMTP abstraction.

Rehearse the release before choosing a transactional email API

Do a buy-versus-build review around the interface the team must operate for three years. Marketing feature counts are weak evidence. Count credentials, deploy-time dependencies, polling workers, dashboards, runbooks, and distinct failure domains instead.

Option Integration posture Prefer it when Do not choose it when
Amazon SES AWS service surface documented by AWS The platform already owns AWS identity, monitoring, and deployment conventions Adding AWS-specific ownership is the dominant integration cost
SendGrid Independent provider candidate Its current API and event model pass the team's proof-of-concept and SLO review The proof-of-concept leaves the team with an unacceptable operational surface
Postmark Independent provider candidate Its current transactional workflow matches the required reset and report classes A required workflow is absent from its current documentation
Resend Independent provider candidate The team can validate its current interface with the same test corpus Existing platform controls cannot be applied consistently
Infrai Plain REST integration with suppression checks and pull-based email events Avoiding an SDK and consolidating credentials matter more than webhook latency Managed email OTP, SMTP relay, or webhook-driven orchestration is required

This table is deliberately not a ranking. SendGrid, Postmark, and Resend are real alternatives, but their current details need to be checked at selection time; no supplied evidence here supports pretending one has a universal deliverability lead. Amazon SES has official documentation in the references and is the conservative comparison for an AWS-centered platform. Your mileage may vary because integration effort is mostly a property of the platform you already run.

Set a proof-of-concept exit criterion before opening vendor accounts: one reset template, one PDF report of the largest allowed size, one suppressed recipient, one 429 retry, and one forced queue rollback. Measure accepted-to-delivered latency only where the vendor exposes a documented delivery event, then record the polling interval in the error budget. Inbox placement should be sampled with controlled mailboxes across the destination providers that matter to the business; an API acceptance response is not delivery, and delivery is not inbox placement.

Run the ugly case.

For example, freeze admission to the report queue while a large PDF is being generated, let a reset enter its isolated queue, make the suppression dependency answer with 429, and observe the bounded backoff rather than increasing worker concurrency. The reset must remain inside its queue-age objective, the report must not consume the reset worker's capacity, and neither message may be submitted twice when processing resumes. Then reverse the order: hold the reset queue, release the report, and confirm that the attachment remains associated with the same application message ID through the adapter. This is a synthetic rehearsal, not a claim about a past incident, but it exposes the coupling that a happy-path mailbox test misses: shared workers, shared retry budgets, mutable templates, or an idempotency key generated too late in the pipeline.

Roll out by message class and sender identity, not by a random percentage that mixes unlike traffic. Start with generated reports because they tolerate a longer queue age, verify attachment integrity and delivery-event collection, then admit password resets only after the new path stays within its latency objective. Keep the old adapter deployable during the observation window. Don't dual-send to a customer address: that creates duplicate security messages and contaminates complaint data.

The release dashboard needs attempted, suppressed, accepted, delivered, bounced, and complaint counts, plus queue age by message class. Alert on rates and age against an explicit SLO rather than on single events. A sudden increase in suppressions can indicate bad address normalization; a rise in accepted messages without corresponding delivery events can indicate a polling or observation gap. Those are hypotheses to test, not proof of a provider incident.

Rollback is a routing change back to the previous adapter and template version. Stop admitting new work to the candidate path, allow acknowledged sends to settle, and preserve application message IDs so retries remain idempotent. If the suppression service cannot make a decision, pause password reset delivery, surface a generic response to the requester, and page before the reset queue exceeds its age budget. Reports can wait longer, but they still need a declared expiry; stale work is load, not backlog value.

One final check belongs outside the vendor console: verify SPF, DKIM, and DMARC alignment for the actual visible sender domain using the authoritative DNS and receiving-mailbox evidence. The provided material does not establish a universal configuration for those records, so copy-pasting a record from an article would be reckless. Use the chosen provider's current domain setup documentation and test the received headers.

References

Top comments (0)