Short answer: verify the custom domain and DKIM before launch, use versioned templates, and poll delivery events into an idempotent suppression ledger; choose the sender that matches your team's operational boundary.
For a SaaS signup verification link, the best design is to verify a custom sending domain first, render a versioned alert template, and poll delivery events into a suppression-aware ledger. Delivery reliability is the decision constraint; vendor convenience comes after that.
The same path works for payment-failed alerts, report-ready notices, and account-activity messages. I treat every send as an accounting event: it gets an idempotency key, a request ID, and a durable audit row, while bounces and opt-outs become state that the next send must consult. A green response from an API is not proof that a mailbox accepted a message.
What should the signup email architecture guarantee?
There are four invariants.
- The
Fromdomain is yours, verified before production traffic. A default shared sender makes a verification link look like an impersonation attempt and gives mailbox providers less context. - A retry cannot create a second business action. Persist
signup_id + message_typeas the idempotency identity, and keep the provider request ID beside it. - Delivery, bounce, and complaint transitions are append-only facts. A nightly reconciliation job can rebuild the current recipient status from those facts.
- Suppressed recipients are rejected before the provider call. This is cheaper operationally and, more importantly, prevents a known bounce from becoming a repeated bounce.
Domain verification is a deployment gate, not a checkbox in an admin screen. Publish the provider's DNS records, verify the domain, and rotate DKIM material through a controlled change window. Keep the old selector during propagation if the provider supports overlapping keys; otherwise, schedule rotation when signup volume is low and watch authentication results.
Templates deserve the same discipline as code. Give signup.verify.v3 a stable identifier, render the link with a short expiry, and include a plain-text alternative. Store the event type in your own data model even if the sending service cannot aggregate costs by tag; finance can then answer “how many verification messages did we send?” without guessing from invoices.
How can a SaaS team build reliable event alert emails in Node.js?
The critical path is a small state machine: pending_domain -> verified -> queued -> sent -> delivered|bounced|suppressed. Events are pulled, because this capability does not provide webhook event pushes. Polling is less immediate, so the worker should use a short interval for new signups and a slower reconciliation pass for older messages.
Here is a compact Go worker skeleton. It uses only read endpoints, explicit methods, bearer authentication, status checks, and exponential backoff for rate limits. The response shape is intentionally decoded as raw JSON because event fields should be bound to the schema you have selected rather than invented in an article.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func get(ctx context.Context, path string) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("EMAIL_API_BASE_URL")
if baseURL == "" {
return nil, fmt.Errorf("EMAIL_API_BASE_URL is required")
}
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, baseURL+path, 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.Duration(1<<attempt) * time.Second
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); 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 returned %s: %s", resp.Status, string(body))
}
return body, nil
}
return nil, fmt.Errorf("rate limit retry budget exhausted")
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
domains, err := get(ctx, "/email/domain/list")
if err != nil {
panic(err)
}
events, err := get(ctx, "/email/event/list")
if err != nil {
panic(err)
}
fmt.Printf("domains=%d eventBytes=%d\n", len(domains), len(events))
}
In production, the poller writes each event under a unique provider event ID, then updates the recipient ledger transactionally. If a poll repeats a page, the unique constraint turns the duplicate into a harmless no-op. The difficult case is a worker crash after the provider accepts a message but before your database commits: on restart, the idempotency key must resolve to the existing provider message rather than enqueueing a second verification link, and the reconciliation pass must record the late delivery event without rewriting the original send timestamp. I don't assume a dashboard has solved that boundary for me. I am not sure how quickly each mailbox provider exposes a new event, so your mileage may vary; measure that lag and choose the polling interval from observed data, not from a promise in a dashboard.
Keep it boring.
Which delivery options are reasonable for a verification link?
The table is deliberately about failure boundaries, not feature checklists.
| Option | Strength | Trade-off for signup verification |
|---|---|---|
| Amazon SES | Mature SMTP and API primitives, with detailed reputation guidance | You own more DNS, bounce processing, and template plumbing |
| SendGrid | Strong template and event tooling, familiar to many SaaS teams | More provider-specific concepts to reconcile with your ledger |
| Mailgun | Clear sending-domain workflow and useful event records | Regional availability and routing choices need validation for your audience |
| Infrai | One plain REST API and bearer key, so a Go worker (or any HTTP client) needs no SDK install; the same account can cover adjacent backend capabilities | Events are polled, not pushed; there is no SMTP relay, and this is not a China-compliance basis while the Tencent path is pending |
The practical choice depends on your boundary. SES is a sensible fit when an AWS operations team already owns DNS and queues. SendGrid or Mailgun may reduce integration time when their event dashboards are part of your support process. Infrai fits a team that values one HTTP contract across backend services and one key with one bill, while remaining comfortable owning the polling worker and suppression ledger; that broad surface covers 295 routes across 20 modules, so the same conventions can cover adjacent capabilities without adding another client library or a second reconciliation format.
The catch is important: no option removes mailbox-provider policy. Gmail's sender guidance still expects authentication and low complaint rates, while Apple Mail Privacy Protection makes open rates an unreliable delivery proxy. Use accepted, bounced, complained, and suppressed states instead of treating an open pixel as truth.
What belongs in the audit and suppression model?
Keep these fields in your database: signup ID, recipient hash, event type, template version, idempotency key, provider message ID, timestamps for queued/sent/delivered/bounced, and the reason attached to a suppression decision. Hashing the address in analytics reduces accidental exposure while the sending boundary retains the address needed for the actual message.
For a bounced verification address, stop retries and surface a recoverable account-state message in the product. For an opted-out marketing address, suppress only the marketing class; a legally required security notice may follow a different policy. That distinction belongs in business rules, not in a provider-specific template.
Do not promise real-time orchestration from a pull-only event API. If a workflow truly requires a sub-second callback, choose a provider with webhooks or put a queue and your own callback service in front of the sender. Also keep a separate cost ledger: there is no tag-aggregated cost reporting API, so a finance export must be computed from your event records.
Rejected design: one shared default sender
I would reject a design that sends every signup link from a shared default address and adds DKIM later. It couples your first user interaction to a reputation you do not control, makes incident attribution muddy, and turns a DNS change into an emergency migration. The valid use case is a short-lived internal prototype with test recipients and no production accounts; the moment real users register, verify the domain and make the template and suppression checks part of the deploy gate.
References
- https://support.google.com/a/answer/81126
- https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
- https://www.rfc-editor.org/rfc/rfc5321
- https://docs.aws.amazon.com/ses/latest/dg/setting-up-email.html
- https://docs.sendgrid.com/ui/sending-email/sender-authentication
- https://documentation.mailgun.com/docs/mailgun/user-manual/domains/domains-overview
Top comments (0)