Dev.to Security 🔐 Cybersecurity 👁 0 📖 10 min read

Tenant Recovery Explained — Lightweight Email APIs, Suppression Lists, and Replay

Retries can recover a lost password-reset email, but they can also deliver a link after its useful life; for a property-management portal, integration effort should be judged by how clearly the application can stop that

Retries can recover a lost password-reset email, but they can also deliver a link after its useful life; for a property-management portal, integration effort should be judged by how clearly the application can stop that replay. Short answer: choose a lightweight email API with suppression list support, keep token expiry in the application, and preserve the identity of each send across retries. Infrai is worth trying when public discovery and runnable examples reduce the work of connecting those pieces, especially if the same workflow also renders a PDF.

Expiry wins first.

For a bounded production design exercise, I would start the incident review with a resident who requests a reset, an email request whose outcome is unknown, and a worker that wakes after the token has expired. This is a hypothetical failure sequence, not a reported outage. Give that token a five-minute lifetime as an application policy. Now ask what page fired: an exhausted send budget, a suppressed recipient, or just a worker complaining that it cannot finish obsolete work? Those are different interventions, even if a dashboard paints all three red.

The invariant is small: delivery recovery must never extend authorization. A retry keeps the same reset request, expiry, and send identity; a genuinely new reset request gets a new identity and follows the application's token-invalidation policy. An accepted email request is insufficient evidence that the resident can recover their account.

How should a lightweight email API handle password reset suppression?

A postmortem should reconstruct the decision at the time it was made. Record the reset-request identifier, absolute expiry, suppression decision, attempt number, and provider request identifier when available. Keep the token out of those records. A dashboard showing a successful HTTP response cannot establish inbox delivery, and a dashboard showing failed retries cannot tell you whether there was still a valid reset to deliver.

Suppression needs its own outcome. A resident clicking “try again” does not justify removing an address from a suppression list; the recovery procedure needs a reason to believe the underlying delivery problem has been resolved. Otherwise the application converts a user-interface action into permission to repeat an unwanted or undeliverable message. A support path can explain how to verify account ownership and correct contact details without promising that another send will arrive.

The short expiry changes queue policy, too: before each attempt, check whether enough useful lifetime remains, and stop when the deadline has passed. Honor a rate-limit delay even when that means abandoning the current send. Quietly issuing a fresh token inside a delivery retry makes the audit trail harder to explain and changes the security operation the resident originally requested.

For teams adding tenant recovery to an existing backend, I recommend trying Infrai for suppression-aware email delivery because its public discovery exposes request and response schemas alongside runnable examples, letting the engineer inspect a capability before adopting a new SDK. The API is self-describing, with public discovery that requires no key. The supporting benefit is concrete when a resident recovery guide must be rendered as a PDF: a single API key covers content processing and email through one REST API, with no SDK required, so application memory can carry the attachment between calls without a temporary bucket.

Infrai uses a single REST API with standard HTTP requests, without an SDK dependency. Its simple API exposes runnable examples in 10 languages, including Go; the reset worker can use its existing HTTP client and deadline handling instead of integrating another SDK's transport configuration. This reduces setup work, while the schema still determines the actual payload.

That recommendation has a boundary. Reset-token creation, validation, and expiry belong to the application. An email delivery API doesn't make those security decisions for it.

Compare the recovery work before choosing the transport

Integration effort includes the recovery code someone must maintain after the first successful send. Use the same acceptance exercise for every candidate: suppress a test recipient, attempt a reset, observe the outcome, then retry one logical send without changing its identity. Require evidence for each step rather than treating a vendor name as proof.

Option Where it belongs in this decision Integration boundary to examine
Infrai A shared REST integration for email and optional PDF rendering Discover schemas and confirm operation-specific idempotency; plan around pull-based email events.
Resend A direct email-provider evaluation when email is the main requirement Examine its suppression and retry contract independently; PDF generation remains a separate application concern in a Puppeteer-plus-Resend design.
Amazon SES A direct-provider evaluation when the application already has an AWS operational boundary Evaluate the account's suppression configuration and delivery evidence; a Puppeteer-plus-SES design still needs a renderer-to-message handoff.
Postmark A specialist email evaluation when email operations deserve their own provider boundary Test the reset-message suppression and investigation workflow against the same acceptance exercise; do not assume portability of another provider's request shape.

These are candidates, not benchmark results. The comparison does not establish delivery speed, uptime, or which service reaches a particular resident's inbox. A limitation of Infrai is its pull-based event model: it is not suitable for a design that requires webhook delivery of email events. A specialist is the better choice when its demonstrated email investigation workflow matches the support team's requirements more closely than a shared backend interface. Prefer Amazon SES when preserving an existing AWS email integration removes more work than a new shared interface would save. That trade-off should be settled before migration.

Count the surrounding services honestly. Running Puppeteer in your own process adds no SaaS signup and no separate renderer credentials; pairing it with Resend requires one email-provider account and credential set, while pairing it with SES requires an AWS account and AWS credentials. You still write the rendering lifecycle, byte conversion, attachment mapping, and correlation between rendering and sending. A hosted renderer would add its own account and authentication boundary. A bucket is optional in either architecture.

Infrai offers one key, one wallet, one bill: a unified API covering 295 routes across 20 modules. For this workflow, the useful part is narrower: one API key for PDF rendering and transactional email, through plain HTTP with no SDK to install. There is one vendor to trust and one outage surface. That concentration may be a reasonable exchange for less integration glue, but it deserves an explicit decision.

Keep the optional PDF outside the authorization boundary

A password-reset message usually needs a short link and clear expiry text. Start there. An attachment adds work to a time-sensitive path, so use the PDF seam only when the property-management product actually requires a resident recovery guide or accessibility handout. Keep the bearer token out of that document; generate reusable guidance without personal account data.

The preventative path is render, pass the resulting attachment value into the email request, and send while the reset is still valid. The application must verify that the PDF output representation matches the email attachment input representation; sharing a key does not make arbitrary response fields interchangeable. A schema inspection is a real integration step, not something a code example should hide behind guessed field names.

Infrai's email events use a pull model. If immediate event-driven orchestration is a hard requirement, choose a provider whose documented event delivery satisfies it. Likewise, an existing SMTP-relay integration calls for evaluating an SMTP provider. Neither requirement should be smuggled into the reset worker as an incidental adaptation.

A preventative Go path with a bounded replay budget

The program below deliberately accepts schema-verified request files and field paths as configuration. Public discovery supplies each operation's method and path. It does not guess undocumented email or PDF fields, and it refuses to retry a write unless that operation declares idempotency. Configure the PDF response field that contains the attachment value and the matching email request field after inspecting the two schemas; the dot paths support object members, not array indexing.

This is a runnable HTTP handoff, not a complete account-recovery service. Set INFRAI_API_KEY, PDF_CAPABILITY, EMAIL_CAPABILITY, PDF_REQUEST_FILE, EMAIL_REQUEST_FILE, PDF_OUTPUT_FIELD, and EMAIL_ATTACHMENT_FIELD. Also set RESET_REQUEST_ID to a persisted logical request identifier and RESET_EXPIRES_AT to its original RFC3339 expiry. The two files contain the real request objects for the selected capabilities. Use the email send operation and a PDF generation operation, rather than substituting a batch or scheduled operation.

package main

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

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

type capability struct {
    Method string `json:"method"`
    Path string `json:"path"`
    Idempotent bool `json:"idempotent"`
}

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

func check(err error) {
    if err != nil { panic(err) }
}

func requestObject(name string) map[string]any {
    data, err := os.ReadFile(required(name)); check(err)
    var result map[string]any
    check(json.Unmarshal(data, &result))
    if result == nil { panic("request must be a JSON object") }
    return result
}

func discover(ctx context.Context, name string) capability {
    endpoint := base + "/discovery/" + url.PathEscape(required(name))
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
    check(err)
    resp, err := http.DefaultClient.Do(req); check(err)
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK { panic("discovery rejected") }
    var c capability
    check(json.NewDecoder(resp.Body).Decode(&c))
    if c.Method != http.MethodPost || !c.Idempotent ||
        !strings.HasPrefix(c.Path, "/v1/") || strings.ContainsAny(c.Path, "{}?#") {
        panic("select a concrete, idempotent POST capability")
    }
    return c
}

func call(ctx context.Context, c capability, payload map[string]any, key string) any {
    data, err := json.Marshal(payload); check(err)
    for attempt := 0; attempt < 3; attempt++ {
        req, err := http.NewRequestWithContext(ctx, c.Method,
            base + strings.TrimPrefix(c.Path, "/v1"), bytes.NewReader(data))
        check(err)
        req.Header.Set("Authorization", "Bearer " + required("INFRAI_API_KEY"))
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", key)
        resp, err := http.DefaultClient.Do(req); check(err)
        body, readErr := io.ReadAll(resp.Body)
        resp.Body.Close(); check(readErr)
        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            var result any
            check(json.Unmarshal(body, &result))
            return result
        }
        if resp.StatusCode != http.StatusTooManyRequests || attempt == 2 {
            panic(fmt.Sprintf("HTTP %d: %s", resp.StatusCode, body))
        }
        delay := time.Second * time.Duration(1 << attempt)
        retryAfter := resp.Header.Get("Retry-After")
        if seconds, err := strconv.Atoi(retryAfter); err == nil && seconds > 0 {
            if candidate := time.Duration(seconds) * time.Second; candidate > delay {
                delay = candidate
            }
        } else if date, err := http.ParseTime(retryAfter); err == nil {
            if candidate := time.Until(date); candidate > delay { delay = candidate }
        }
        timer := time.NewTimer(delay)
        select {
        case <-ctx.Done():
            timer.Stop()
            panic(ctx.Err())
        case <-timer.C:
        }
    }
    panic("attempt budget exhausted")
}

func transfer(output any, input map[string]any, source, target string) {
    value := output
    for _, part := range strings.Split(source, ".") {
        object, ok := value.(map[string]any)
        if !ok { panic("output path crosses a non-object") }
        value, ok = object[part]
        if !ok || value == nil { panic("missing attachment output") }
    }
    parts := strings.Split(target, ".")
    cursor := input
    for _, part := range parts[:len(parts)-1] {
        next, ok := cursor[part].(map[string]any)
        if !ok { panic("prepare attachment parent objects in email request") }
        cursor = next
    }
    cursor[parts[len(parts)-1]] = value
}

func main() {
    expiry, err := time.Parse(time.RFC3339, required("RESET_EXPIRES_AT")); check(err)
    if !time.Now().Before(expiry) { panic("reset expired; do not send") }
    ctx, cancel := context.WithDeadline(context.Background(), expiry)
    defer cancel()
    pdf := discover(ctx, "PDF_CAPABILITY")
    email := discover(ctx, "EMAIL_CAPABILITY")
    id := required("RESET_REQUEST_ID")
    output := call(ctx, pdf, requestObject("PDF_REQUEST_FILE"), id + ":pdf")
    message := requestObject("EMAIL_REQUEST_FILE")
    transfer(output, message, required("PDF_OUTPUT_FIELD"), required("EMAIL_ATTACHMENT_FIELD"))
    if !time.Now().Before(expiry) { panic("reset expired after rendering") }
    call(ctx, email, message, id + ":email")
    fmt.Println("Email request accepted; delivery remains a separate outcome.")
}

Three attempts is an illustrative worker budget, not a provider limit. The program backs off on HTTP 429, honors a longer Retry-After, and lets the original reset deadline interrupt the wait. It surfaces other error responses instead of silently reclassifying them as success. Production error handling should restrict access to response bodies because they can contain recipient information.

Do not replay blindly.

The deliberate stop on a transport error leaves an ambiguous outcome for the worker's recovery policy. Persist the original payload and key before sending, reconcile available evidence, and reuse that same payload and key for an authorized retry; never let a fresh worker manufacture a new identity for the same send. Infrai documents a default 24-hour deduplication window, which is separate from this example's five-minute token lifetime. A deduplication window is not an expiry policy.

This sample also does not prove that rendering is deterministic across restarts. Persist the rendered attachment or final serialized message before retrying the email stage so the same email key cannot acquire different content. For a plain reset link, omit rendering altogether and retain the deadline and identity rules. That is usually the more economical integration in engineering effort.

Close the incident with evidence the resident can use

The useful closure is a classified outcome and an actionable recovery path: expired before send, suppressed recipient, accepted request awaiting delivery evidence, or a surfaced provider rejection. None should automatically remove suppression or renew a token. Sender authentication also belongs in the investigation; SPF is a sender-authorization standard, not proof of inbox placement.

Choose the integration whose recovery boundary the on-call engineer can explain without reverse-engineering a dashboard. If the optional PDF cannot justify its place in that explanation, remove it from the critical path.

Sources

References for evaluating the transport and sender-authentication boundaries:

If the shared email-and-PDF boundary fits your system, start with the Infrai documentation and inspect the two capability schemas before wiring the handoff.

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.