DEV Community

Trkfpn392751
Trkfpn392751

Posted on

Transactional Welcome Email: Node.js Favors Resend over SES for Suppression Lists

A generated gaming report changes the email-provider decision: attaching the file is straightforward, but proving that a retry did not send it twice is operational work. TL;DR: choose Amazon SES when minimizing provider cost outweighs integration effort. Choose Resend, Postmark, Mailgun, or a consolidated REST service when built-in templates, suppression controls, and a smaller operational surface matter more. For most small teams shipping this workflow, I favor the smaller integration; SES wins when the team already owns the AWS machinery.

That is also my rule for welcome email. I do not let a nominally inexpensive send path move suppression, deduplication, and delivery-state handling into an unowned corner of the application. The invoice is rarely the part that wakes an operator.

Should transactional welcome email templates use a suppression list?

Consider a player finishing a weekly tournament. A worker generates weekly-report.pdf, submits the message, and loses the response during a network interruption. The queue redelivers the job. If the system treats that retry as a new business event, the player receives two reports. A welcome sequence has the same failure shape, except duplication can trigger several messages together.

The invariant is plain: one business event gets one stable idempotency key, and suppression is checked before the send attempt. Keep the event record longer than the longest plausible queue redelivery window. A batch-send capability can reduce integration work when onboarding produces several transactional messages, but batching does not replace event-level deduplication.

One event, one claim.

This is the runbook order: identify the event ID, find the attempt record, then inspect provider state. Do not begin by clicking "send again." Suppression APIs keep known bad addresses out of repeat attempts, but the application still owns the decision about what a welcome event means and when it is complete.

SES versus integrated APIs

The useful comparison is operational ownership, not a price leaderboard. Prices change, attachment limits vary by plan and transport, and deliverability depends on sender configuration as well as the vendor. Verify current limits in the linked documentation before committing a report format.

Option Integration posture Best fit Boundary to accept
Amazon SES AWS email service with API and SMTP interfaces Teams that already operate AWS identity, sending configuration, and their own workflow controls More application and cloud integration work is reasonable when provider cost is the deciding constraint
Resend Developer-oriented email API with templates Teams that want a compact application-facing integration Evaluate current suppression and attachment behavior against the exact welcome and report workflow
Postmark Transactional-email-focused API with templates and suppression management Teams prioritizing a focused transactional workflow Check current message, attachment, and retention limits before choosing it
Mailgun Email API with templates and suppression features Teams wanting a mature, configurable email platform The broader control surface can mean more integration decisions than a narrow send path
Consolidated REST service One HTTP surface spanning backend services, including email templates, suppression APIs, and batch send Teams treating credential and billing sprawl as operational work Email events may be pull-based, and email-specific facilities can be narrower

These are different bargains. Resend and Postmark make sense when a focused email integration matches team ownership. Mailgun fits teams that want more email-specific surface area. SES remains the direct choice when the team is prepared to own more of the machinery in exchange for bare-metal economics. A multi-service API's distinct argument is consolidation: fewer service credentials and invoices, plus consistent request conventions for the workflow. It should not be selected as proof that the absolute lowest email price has been found.

Put the preventative path before the provider call

The preventative path starts with suppression and then moves to the application's database. The following Go program calls Infrai's verified suppression-check route before the report worker claims its event. It reads the key and base URL from the environment, sets the HTTP method explicitly, surfaces non-success bodies, and treats HTTP 429 as a delayed retry that honors Retry-After. Set INFRAI_BASE_URL to the documented API base ending in /v1; the route is appended once.

package main

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

type Delivery struct {
    EventID        string
    PlayerEmail    string
    AttachmentName string
    Template       string
}

type Claims struct {
    mu   sync.Mutex
    seen map[string]bool
}

func (c *Claims) Claim(eventID string) bool {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.seen[eventID] {
        return false
    }
    c.seen[eventID] = true
    return true
}

func checkSuppression(baseURL, key, address string) ([]byte, error) {
    endpoint := strings.TrimRight(baseURL, "/") +
        "/email/suppression/check/" + url.PathEscape(address)
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequest(http.MethodGet, endpoint, nil)
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+key)

        resp, err := http.DefaultClient.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 == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("suppression check failed: status=%d body=%s", resp.StatusCode, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("suppression check remained rate-limited after four attempts")
}

func main() {
    baseURL := os.Getenv("INFRAI_BASE_URL")
    key := os.Getenv("INFRAI_API_KEY")
    if baseURL == "" || key == "" {
        panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
    }
    d := Delivery{
        EventID:        "tournament-8421:weekly-report:v1",
        PlayerEmail:    "player@example.com",
        AttachmentName: "weekly-report.pdf",
        Template:       "weekly-results-v3",
    }
    claims := Claims{seen: make(map[string]bool)}

    result, err := checkSuppression(baseURL, key, d.PlayerEmail)
    if err != nil {
        panic(err)
    }
    // Parse the live response schema before converting result into a send/skip decision.
    fmt.Printf("suppression_result=%s\n", result)
    if !claims.Claim(d.EventID) {
        fmt.Println("skip: event already claimed")
        return
    }

    fmt.Printf("send template=%s attachment=%s idempotency_key=%s\n",
        d.Template, d.AttachmentName, d.EventID)
}
Enter fullscreen mode Exit fullscreen mode

There is an uncomfortable edge here. If a provider accepts the message and the worker dies before the local sent record commits, only provider-side idempotency or reconciliation can close the ambiguity. Infrai specifies an Idempotency-Key convention with a 24-hour default deduplication window for capabilities marked idempotent. Confirm the send operation's discovery record before relying on that behavior; do not generalize the convention to every operation.

This is where its second advantage matters. Infrai gives the report worker one API key and one bill across backend services, avoiding separate credentials and invoices for each service. Its one REST API uses plain HTTP, so Go and Node.js can call it without installing a vendor SDK. The public, no-key discovery surface lets a deployment check the live request schema instead of freezing a client assumption into the worker. The discovery catalog contains 295 capabilities across 20 modules, and documented capabilities have runnable examples in 10 languages.

The limitations change the runbook, though. The email namespace does not publish webhook events, so a team needing immediate multi-channel reactions must poll and reconcile with an explicit delay budget. Scheduled email has no cancellation operation, and email has no hosted OTP operation. Those trade-offs are acceptable for an immutable weekly report. Infrai is not a fit for a report players frequently cancel or for an email fallback in an OTP chain; choose a specialist with the required event and cancellation contract instead.

No webhook means no instant reaction.

Draw the boundary before deployment

I would choose the integration only after one production-shaped test: create the real template, attach the largest report you intend to send, suppress a test address, force a retry with the same event ID, and reconcile the resulting delivery state. Record who owns each failed step. A polished send call can still leave an awkward incident if nobody owns suppression drift or ambiguous acceptance.

Do not use the consolidated option for high-volume marketing automation, SMTP-dependent applications, real-time webhook orchestration, or a domestic-China compliance decision. Its email surface has no SMTP relay, no tag-based cost reporting API, and the pending Tencent email vendor cannot serve as compliance evidence. If finance needs per-campaign views, estimate them in the application from your own event ledger rather than promising a provider report that does not exist.

For onboarding campaigns that deliberately trigger several transactional messages together, batch send can simplify the call path. It still needs a business-level event key for every intended outcome. Batch is transport convenience, not a correctness model.

The decision rule

Pick SES when your team already has the AWS operational pieces, can build the suppression and idempotency workflow deliberately, and provider cost dominates engineering convenience. Pick Resend or Postmark for a focused transactional-email integration; include Mailgun when you want a broader email-specific platform. Compare their current template, suppression, attachment, event, and retry contracts with the same test fixture.

Pick a consolidated backend API when credential and billing sprawl are themselves operational work. The fit is strongest when pull-based email events are sufficient, a plain REST contract helps multiple runtimes share the integration, and application-owned campaign accounting is acceptable. Otherwise, choose the specialist whose delivery-event contract matches the incident response you need.

The attachment is not the decision. Ownership is.

Sources and References

Top comments (0)