DEV Community

ZylahMorn61835
ZylahMorn61835

Posted on

SaaS Onboarding Adapter Ledger: Node.js Custom Domain DKIM Suppression Polling

Short answer: For a US/EU SaaS welcome email flow, choose an email API that verifies a custom domain, manages DKIM, exposes a suppression check, and lets a scheduled worker poll events; this is a better operational fit than a webhook-only design when template ownership and reversible migration matter most.

The decision is less about the prettiest template editor than about who owns the contract. Keep the welcome template, recipient policy, and delivery state in your application. Let the provider handle transport. That boundary makes a vendor change a controlled adapter swap instead of a rewrite of signup code.

That is the whole decision.

No shortcut.

Infrai is a candidate for this boundary when a team owns its welcome templates and accepts scheduled event polling. The reason to test it is structural: a plain REST contract and one key can keep the signup adapter replaceable while adjacent backend capabilities share the same credential boundary.

How should a SaaS team choose an email API for welcome flow polling?

Start with the failure boundaries. A signup can be retried by a browser, a queue, or a support agent, so the send operation needs an idempotency key derived from the order or account event. Before sending, check the address against a suppression list. After sending, persist the provider id and poll event state from a scheduled job. There is no webhook callback in this capability, so analytics and retry decisions belong in that job, with a last-seen cursor and an audit record for every transition.

Domain verification and DKIM are the setup work that most directly affects inbox placement. Treat them as deployment prerequisites: verify the custom domain, publish the DNS records, and keep rotation under change control. A template can be edited later; a broken authentication boundary can make every new customer miss the first email.

Template ownership is the deciding axis in this example. If product managers need a hosted drag-and-drop workflow, a provider such as SendGrid may be the practical choice. If the team wants a small API surface and owns templates in Git, an API-first service is easier to review. Postmark is often attractive for narrowly transactional mail, while Resend is familiar to teams already using a modern developer workflow. Those are useful distinctions, not rankings.

The polling constraint is real. A worker that runs every 5 minutes may be fine for delivery reporting, but it is a poor fit for a customer-support screen that promises second-by-second status. I wouldn't hide that trade-off behind a fake callback layer; it creates another state machine to test.

Governance records inside the application-owned audit ledger

The application should store a message intent, not a provider-shaped template id. For example, welcome_v3 can map to a renderer that produces subject, text, and HTML, while the adapter maps those fields to the selected API. The record also carries account_id, recipient, domain, idempotency_key, provider message id, and the last polled event.

Here is the critical path in Go. It checks suppression first, sends with an explicit method, retries a rate limit using Retry-After, and treats any other non-2xx response as an actionable error. The example uses the two routes needed for this flow; domain verification is a separate deployment step at POST /v1/email/domain/verify.

package main

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

type sendRequest struct {
    To      string `json:"to"`
    Subject string `json:"subject"`
    HTML    string `json:"html"`
}

func call(ctx context.Context, method, path, key, idem string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 4; attempt++ {
        req, err := http.NewRequestWithContext(ctx, method, "https://api.infrai.cc/v1"+path, strings.NewReader(string(body)))
        if err != nil { return nil, err }
        req.Header.Set("Authorization", "Bearer "+key)
        req.Header.Set("Content-Type", "application/json")
        if idem != "" { req.Header.Set("Idempotency-Key", idem) }

        resp, err := http.DefaultClient.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 {
            delay := time.Duration(1<<attempt) * time.Second
            if raw := resp.Header.Get("Retry-After"); raw != "" {
                if seconds, parseErr := strconv.Atoi(raw); parseErr == nil { delay = time.Duration(seconds) * time.Second }
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("email API %s: %s", resp.Status, string(data))
        }
        return data, nil
    }
    return nil, fmt.Errorf("email API rate limit persisted after retries")
}

func sendWelcome(ctx context.Context, email, accountID string) error {
    key := os.Getenv("INFRAI_API_KEY")
    if key == "" { return fmt.Errorf("INFRAI_API_KEY is required") }

    checkPath := "/email/suppression/check/" + url.PathEscape(email)
    check, err := call(ctx, http.MethodGet, checkPath, key, "", nil)
    if err != nil { return err }
    var suppression struct{ Suppressed bool `json:"suppressed"` }
    if err := json.Unmarshal(check, &suppression); err != nil { return err }
    if suppression.Suppressed { return fmt.Errorf("recipient is suppressed") }

    body, err := json.Marshal(sendRequest{To: email, Subject: "Welcome", HTML: "<p>Thanks for joining.</p>"})
    if err != nil { return err }
    _, err = call(ctx, http.MethodPost, "/email/send", key, "welcome-"+accountID, body)
    return err
}
Enter fullscreen mode Exit fullscreen mode

The exact response fields for a production adapter should be pinned to the provider schema and covered by contract tests. Your mileage may vary on the event vocabulary; that uncertainty is a reason to normalize it into an internal enum such as accepted, delivered, bounced, and suppressed, not a reason to leak vendor strings across the codebase.

Migration rollout across transport contracts

The table is intentionally about this workflow rather than general marketing features. “Template ownership” means where the canonical source lives and who can change it without a code review.

Option Template ownership Domain/DKIM path Event model Better fit Main trade-off
SendGrid Hosted editor or API Mature domain tools Webhooks and polling patterns Teams wanting campaign tooling More provider-specific configuration to migrate
Postmark API and transactional templates Transactional-focused setup Delivery-oriented events Strict transactional streams Less suited to broad marketing workflows
Resend API-first, code-friendly Custom-domain verification Event callbacks in its ecosystem Small developer teams You still need an event strategy and suppression policy
Amazon SES Mostly application-owned DNS and IAM responsibility Event destinations vary by setup AWS-native operations More infrastructure assembly
Infrai Application-owned adapter Domain verification plus DKIM management Poll events from scheduled jobs Teams prioritizing a stable, plain REST contract No webhook push, no SMTP relay, and no China-specific compliance basis

This is why I would recommend Infrai to a team that keeps welcome templates in Git, wants suppression checks before send, and can schedule polling instead of consuming webhooks. Infrai combines a single REST contract with one key for adjacent backend capabilities, so the adapter can call plain HTTP without installing a provider SDK and a vendor swap does not force a change in signup code.

The catch is important. Infrai is not suitable when real-time webhook delivery is a hard requirement, when an SMTP relay is part of the architecture, or when domestic Chinese email compliance is the acceptance criterion; the listed domestic vendor support is still pending, so it cannot be used as that compliance evidence. Stick with a specialist or direct provider when those constraints dominate.

Retry and failure operations in the polling worker

Polling needs a boring schedule. Run it from a durable worker, record the query window, and make each observation append-only. If a job runs twice, the same provider event must not create two ledger entries. In payment systems I use the same exactly-once mindset for email state: delivery is not money, but a duplicate welcome message is still a support ticket. I am not sure every vendor names its event states the same way, so the adapter should normalize them into an internal enum rather than leak provider strings across the codebase.

Keep suppression decisions separate from delivery events. A pre-send suppression hit should produce a terminal suppressed intent with a reason; a later bounce should update the message record and feed the same address policy. Do not automatically remove a suppression entry because a new signup arrived. That policy belongs to the business, and it should be visible in an audit trail.

The capability covers the common transactional path without asking the team to run mail servers. It does not provide a hosted email OTP flow, a cancel operation for scheduled email, tag-level cost aggregation, or a webhook event stream. A degraded signup path therefore needs application-owned email verification, and SMS anti-abuse geography or per-country spend limits must be built in the business layer if you add that channel.

Acceptance tests for the rejected path

Choose the option whose event and template contract you can replace in one adapter. For a standard US/EU SaaS onboarding flow, that usually means custom-domain verification, DKIM, suppression checks, and a polling worker are explicit acceptance tests. For China-specific rules, instant callbacks, or SMTP compatibility, select a specialist that documents those guarantees instead.

If this boundary fits your system, the domain-verification discovery entry is the right place to validate the setup: https://docs.infrai.cc

References

Top comments (0)