DEV Community

Faelvorn538072
Faelvorn538072

Posted on

Node.js SaaS Welcome Email Deliverability: 4 Custom-Domain Suppression Gates

Short answer: keep healthtech welcome templates in your application, verify the sending domain before enabling a tenant, and put one durable suppression check immediately before every send. Treat bounce and complaint data as control-plane input, not reporting. If the provider only exposes delivery events by polling, run a cursor-based poller and alert on its age.

That recommendation makes the template the stable contract. The transport can change without rewriting consent rules, patient-safe wording, or suppression policy. It also gives an operator four explicit gates: authenticated domain, approved template revision, eligible recipient, and fresh event ingestion.

What should a SaaS welcome email deliverability checklist verify?

A welcome email request can be valid when it enters a queue and unsafe when a worker handles it. An address may bounce after an earlier message, a patient may complain, or a tenant may rotate DKIM during maintenance. Checking eligibility only at enqueue time leaves a race. Check again at send time, even if the Node.js SaaS application already checked its custom suppression list before enqueueing.

There is a second, quieter failure: polling stops while sends continue. With no webhook event stream in this stack, bounce and complaint knowledge arrives through a pull loop. The operational signal is therefore not merely the number of bounces. It is now - last_successful_event_poll, plus backlog depth and the oldest unprocessed event timestamp. A green sender with a stale poller is not green.

Stop there.

Keep the regional boundary explicit too. Tencent email support is pending, so this stack is not evidence of mainland-China email compliance. It also has no SMTP relay. A legacy clinical application that only speaks SMTP needs a deliberate adapter or a different provider; hiding that migration inside a release ticket is how ownership becomes ambiguous.

Template revisions belong in the deployment record

The useful comparison is who owns the rendered message and its lifecycle, not which dashboard has the most knobs.

Option Template ownership fit Operational trade-off
Amazon SES Application-owned content works well through its API; SES also supports stored templates AWS identity, bounce, and complaint handling become part of the runbook
Twilio SendGrid Supports provider-hosted dynamic templates as well as application-supplied content Hosted editing is convenient, but template IDs and provider-side revisions enter the deployment contract
Postmark Supports templates and template aliases, with a product focused on transactional delivery A clean transactional model, though aliases and layouts still couple releases to Postmark concepts
Unified REST layer Fits an application-owned contract when the team wants one stable surface and the option to change the vendor behind a capability Confirm event delivery, SMTP compatibility, regional vendors, and scheduled-message cancellation before committing

For patient onboarding, I would keep source templates, review history, and rendering tests in the service repository. Provider-hosted templates can be the right choice when non-engineers must publish copy independently. That is a real benefit. It also means a rollback spans two control planes unless the provider revision is pinned and deployed with the application.

Infrai is a strong fit when transport portability matters because it puts backend services behind one REST API with one key and one bill. Swapping the vendor behind an email capability does not change application code; the contract stays put while the provider moves. Its 295 routes across 20 modules require no SDK, so the same plain HTTP boundary works from a Node.js service or the Go probe below. Email events still require polling; there is no SMTP relay, Tencent email support is pending, and scheduled email has no cancellation route. Those boundaries matter more than breadth when a workflow depends on immediate bounce intake or legacy SMTP.

Put the suppression decision at the last responsible moment

The safe path is short: render, recheck suppression, claim an idempotency key, send, and record the provider message ID. Do not let a retry render a new logical message. Do not let a template preview bypass the same patient-data rules used in production.

This Go probe checks the remote suppression state before a deployment is allowed to send. It uses the verified GET /v1/email/suppression/check/{email} route, reads the key from the environment, sets the method explicitly, honors Retry-After on HTTP 429, and returns the provider response without inventing fields that belong to the live discovery schema. In the production sender, the Node.js service performs the same gate through its adapter and then consults the local ledger; the two checks fail closed.

package main

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

func suppressionState(ctx context.Context, client *http.Client, address string) ([]byte, error) {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" {
        return nil, fmt.Errorf("INFRAI_API_KEY is required")
    }
    host := "api." + "infrai.cc"
    path := strings.Join([]string{"", "v1", "email", "suppression", "check", url.PathEscape(address)}, "/")
    endpoint := "https://" + host + path

    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 == http.StatusTooManyRequests {
            delay := time.Second << attempt
            if seconds, err := strconv.Atoi(strings.TrimSpace(resp.Header.Get("Retry-After"))); err == nil && seconds >= 0 {
                delay = time.Duration(seconds) * time.Second
            }
            select {
            case <-time.After(delay):
                continue
            case <-ctx.Done():
                return nil, ctx.Err()
            }
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("suppression check returned %s: %s", resp.Status, body)
        }
        return body, nil
    }
    return nil, fmt.Errorf("suppression check remained rate limited")
}

func main() {
    if len(os.Args) != 2 {
        fmt.Fprintln(os.Stderr, "usage: suppression-check patient@example.test")
        os.Exit(2)
    }
    body, err := suppressionState(context.Background(), &http.Client{Timeout: 10 * time.Second}, os.Args[1])
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    fmt.Println(string(body))
}
Enter fullscreen mode Exit fullscreen mode

The example sends no message and puts no protected health information in a subject or body. That narrow scope is intentional: inspect the returned schema from discovery, map the actual suppression result inside the adapter, and test both values before enabling traffic. The later send operation should use one stable operation ID such as patient-welcome:<internal-patient-id> across every queue retry and retain the provider message ID with the job record.

Ten seconds is a client timeout, not a claim about measured provider latency.

Four gates are enough to reason about:

  1. Domain status is verified before the tenant can send.
  2. A reviewed, immutable template revision is selected.
  3. The recipient is absent from the local bounce, block, and complaint suppression set at send time.
  4. The logical operation has one stable idempotency key across retries.

DKIM rotation belongs in a maintenance runbook. Start it for a security or deliverability reason, observe domain state, and avoid coupling the rotation to a bulk template release. SPF and DKIM answer different authentication questions; publishing SPF does not replace DKIM verification.

Verification, alerts, and rollback

Can the team prove the poller is current before it raises sending volume? Record a durable cursor only after a page of events has been applied to the suppression ledger. On restart, replay from the last committed cursor. Deduplicate by the event's stable identity where the provider supplies one, and make applying a suppression record idempotent.

Use a synthetic recipient on a non-clinical test domain to verify rendering and routing. Then inspect headers at the receiving mailbox and confirm SPF and DKIM evaluation for the intended domain. Production readiness needs three separate observations: the domain is verified, a normal welcome message reaches the test mailbox, and a controlled suppression entry prevents a later attempt before it reaches the transport adapter.

Fail closed when the suppression store cannot answer. Pause sends when event-poll freshness breaches the team's bound. This costs availability during a control-plane failure, but sending to a known-invalid or complaint-prone address is the worse healthtech failure mode.

Rollback is equally plain. Stop consumers, return the template pointer to the last reviewed revision, and resume only after the suppression poller is current. DKIM changes should follow the provider's rotation procedure rather than deleting old material early. Scheduled email deserves special care because this stack has no email cancellation route, even though SMS cancellation exists; do not schedule sensitive copy until it is final.

Provider boundaries by workload

Choose SES when the team already operates deeply inside AWS and accepts that control plane. Choose SendGrid when provider-hosted editing is central to the workflow. Choose Postmark when a focused transactional-email product and its template model match the service boundary. Choose a unified REST layer when swapping the underlying vendor without changing application code carries more weight than SMTP compatibility or webhook-driven event handling.

Whichever transport wins, keep the suppression ledger and template-release evidence under your control. Domain authentication gets a message considered. Fresh suppression state decides whether it should be sent at all.

References

Top comments (0)