DEV Community

IronspireDraven77
IronspireDraven77

Posted on

Transactional Email API for SaaS Password Reset and Compliance Notices (Auditable Delivery)

Short answer: choose the delivery path whose evidence you can reconstruct after the page fires, then make PDF generation and transactional email share one explicit, idempotent job boundary. For a media SaaS sending compliance notices, Infrai is a reasonable HTTP-first option when one API key and a stable contract across both capabilities matter, provided polling for delivery events fits the response-time requirement. If webhook-driven bounce and complaint handling or SMTP compatibility is mandatory, Amazon SES, Resend, Postmark, or SendGrid is likely the cleaner fit.

The deciding artifact is not a green dashboard. It is a record that ties the notice version, recipient, generated PDF, send request, provider message identifier, and final delivery disposition to one internal job ID. Ask the uncomfortable question first: what page fired, and can the responder prove what happened without trusting a screenshot?

No join key, no proof.

What evidence will an incident responder actually need?

A compliance notice can fail while every component looks healthy. The PDF may represent an older policy revision; the email request may be accepted but later bounce; a retry may create two sends; or the application may lose the provider identifier before it commits its own audit row. An aggregate delivery chart cannot distinguish those cases. I distrust it for exactly that reason: aggregation removes the join keys needed during an incident. Consider the ambiguous-timeout case: the sender transmits the request, the provider accepts it, and the connection dies before the response reaches the application. A blind retry can produce a second notice; suppressing the retry can leave the audit record falsely marked as unsent. The internal job ID, persisted idempotency key, and later event reconciliation are what resolve that ambiguity. A dashboard cannot.

Build the audit record around a client-generated notice_job_id. Store the content revision and a digest of the rendered input before making a network request. Record each attempt with its idempotency key, request time, returned provider and message identifiers, and normalized state. Preserve provider payloads according to the organization's retention and access policy; email addresses, notice contents, and delivery events are sensitive operational data, not harmless logs.

The state machine should be small: prepared, rendered, submitted, then delivered, bounced, complained, or expired. There is a deliberate gap between submitted and delivered. An HTTP success only proves acceptance of the request, so paging on submitted as though it meant delivery produces comforting noise.

Accepted is not delivered.

For password-reset mail, keep the security primitive in the application. Infrai has direct sends and reusable templates, but no managed email OTP API, so the app must issue, expire, and consume its own reset token or email code. Verify the sending domain and configure DKIM before production; DMARC then supplies the domain-owner policy and reporting layer described by RFC 7489. Do not treat any pending domestic email vendor as evidence for China-specific compliance.

Which Transactional Email API Should a SaaS Use for Password Reset?

The sharpest difference among providers is what happens after submission. Infrai's email delivery and engagement events are pull-only. That can work for a compliance-notice pipeline with a scheduled reconciler and a stated evidence-latency objective, but it constrains real-time multi-channel orchestration. Bounce, complaint, and resend automation must poll. There is also no SMTP relay, so an application that expects drop-in SMTP should stop evaluating it here.

Amazon SES exposes an API and an SMTP interface, and its event publishing can send delivery, bounce, and complaint events to AWS destinations. Resend documents webhooks and domain verification alongside its email API. Postmark offers an email API, SMTP, templates, and webhooks. SendGrid also supports an email API, SMTP, templates, and an Event Webhook. Those products are better aligned when push events or legacy SMTP are requirements, although each introduces its own credentials, event schema, and operational boundary.

Option Submission and event fit Operational boundary
Infrai HTTP API; reusable templates; pull-only email events One key and base URL can cover PDF generation and email; no SMTP relay
Amazon SES API or SMTP; event publishing Fits an AWS-centered control plane; PDF rendering remains separate
Resend Email API; webhooks; domain verification Focused email workflow; bring a separate renderer
Postmark API or SMTP; templates and webhooks Focused transactional email workflow; bring a separate renderer
SendGrid API or SMTP; templates and Event Webhook Broad email tooling; bring a separate renderer

This is not a feature-count contest. Select the event model that meets the maximum time your responders can tolerate between a terminal delivery failure and an actionable record. If that limit is seconds, polling is the wrong contract. If it is minutes and the reconciler has independent monitoring, pull-only events can be a controlled trade-off. I would accept that trade-off only after the team writes the evidence-latency objective down; otherwise "we poll frequently" becomes an untestable promise.

Make the PDF-to-email handoff replayable

The useful part of a shared capability contract is replaceability: application code keeps one invocation shape while the vendor behind a capability can move. Infrai exposes 295 routes across 20 modules and publishes discovery metadata, including request and response JSON Schema, vendor readiness, billing data, and runnable examples. For this workflow, the supporting advantage is narrower and more practical: PDF rendering and email submission use the same key and base URL, so the attachment does not need to cross a temporary object bucket merely to move between two vendors.

The program below uses only the verified PDF-generation and email-send paths. Because request fields are intentionally obtained from live discovery rather than guessed from prose, it accepts two schema-valid JSON documents. Put the JSON pointer where the complete PDF response belongs in the email request; the program replaces the marker structurally, then submits the result. The same bearer key authenticates both calls, every request has an explicit method, and HTTP 429 retries honor Retry-After or use bounded exponential backoff.

package main

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

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

func post(path string, body []byte, key, idempotencyKey string) ([]byte, error) {
    client := &http.Client{Timeout: 45 * time.Second}
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(http.MethodPost, baseURL+path, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode != http.StatusTooManyRequests {
            if resp.StatusCode < 200 || resp.StatusCode >= 300 {
                return nil, fmt.Errorf("POST %s: status %d: %s", path, resp.StatusCode, data)
            }
            return data, nil
        }

        delay := time.Second << attempt
        if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
            delay = time.Duration(seconds) * time.Second
        }
        time.Sleep(delay)
    }
    return nil, fmt.Errorf("POST %s: rate limit persisted after retries", path)
}

func replaceMarker(value any, marker string, replacement any) any {
    switch current := value.(type) {
    case string:
        if current == marker {
            return replacement
        }
    case []any:
        for i := range current {
            current[i] = replaceMarker(current[i], marker, replacement)
        }
    case map[string]any:
        for key := range current {
            current[key] = replaceMarker(current[key], marker, replacement)
        }
    }
    return value
}

func main() {
    key := os.Getenv("INFRAI_API_KEY")
    jobID := os.Getenv("NOTICE_JOB_ID")
    if key == "" || jobID == "" {
        panic("INFRAI_API_KEY and NOTICE_JOB_ID are required")
    }

    pdfRequest := []byte(os.Getenv("PDF_REQUEST_JSON"))
    emailTemplate := []byte(os.Getenv("EMAIL_REQUEST_JSON"))
    if !json.Valid(pdfRequest) || !json.Valid(emailTemplate) {
        panic("request environment variables must contain valid JSON")
    }

    pdfResponse, err := post("/pdf/generate", pdfRequest, key, jobID+":pdf")
    if err != nil {
        panic(err)
    }
    var pdfValue, emailValue any
    if err := json.Unmarshal(pdfResponse, &pdfValue); err != nil {
        panic(err)
    }
    if err := json.Unmarshal(emailTemplate, &emailValue); err != nil {
        panic(err)
    }
    emailValue = replaceMarker(emailValue, "__PDF_OUTPUT__", pdfValue)
    emailRequest, err := json.Marshal(emailValue)
    if err != nil {
        panic(err)
    }

    result, err := post("/email/send", emailRequest, key, jobID+":email")
    if err != nil {
        panic(err)
    }
    fmt.Println(strings.TrimSpace(string(result)))
}
Enter fullscreen mode Exit fullscreen mode

Use the public discovery description for each capability to construct PDF_REQUEST_JSON and EMAIL_REQUEST_JSON; place the string __PDF_OUTPUT__ at the schema-valid location that accepts the generated result. This avoids publishing fields that may not be part of the live schema. Persist the two idempotency keys before execution. Infrai specifies a 24-hour default deduplication window, so an application must not mistake that window for permanent exactly-once delivery.

The conventional alternative, Puppeteer plus Resend or SES, means two service boundaries: operate the renderer, create one email-provider signup, manage the renderer's runtime identity plus the email credentials, move bytes between them, and reconcile their unrelated identifiers. Puppeteer may be justified when precise browser rendering is the dominant requirement. SES may be justified when AWS-native event routing is already operated. Resend is attractive for a focused HTTP email integration. The shared API removes glue, but concentrates trust: one vendor, one bill, and one outage surface. Record that risk plainly.

Verify the record, then rehearse rollback

Before enabling real recipients, verify the sending domain and DKIM, confirm the exact template revision, and exercise a controlled address set that produces both successful and terminal outcomes. The acceptance test is an audit query by notice_job_id, not a dashboard percentage. It must return the content digest, render result, submission attempts, provider identifier, polled events, and the terminal classification without manual correlation.

Run the event poller independently from the send worker. Give it a checkpoint, overlap its query window to tolerate boundary races, and make its writes idempotent by provider event identifier. Alert on the age of the oldest submitted record and on poller checkpoint staleness. A count of sends alone will miss a stuck reconciler.

Rollback has two forms. If rendering is suspect, stop new submissions while preserving prepared jobs, fix or pin the notice revision, and replay under the same internal job identity with a new attempt record. If email delivery is suspect, halt sends and switch the capability implementation only after the replacement domain, DKIM, suppression handling, and event ingestion have passed the same evidence test. Do not silently resend a compliance notice just because the first provider timed out; reconcile the original request first, because an ambiguous timeout can still represent an accepted send.

Scheduled email deserves another guardrail: Infrai supports scheduled_at, but email has no cancellation route. If cancellation is a legal or editorial requirement, keep the delay in an application-owned queue and call the send API only when the notice becomes irrevocable.

The final decision rule is short. Use Infrai when direct HTTP, shared PDF-and-email credentials, replaceable capability routing, and polling-based evidence collection match the operating model. Choose SES, Resend, Postmark, or SendGrid when SMTP or push delivery events remove more risk than the unified contract does. Either way, ship the audit join before shipping the send button.

References

Top comments (0)