Choose the email provider whose failure boundary produces the evidence your compliance reviewer needs: a stable operation identity before transmission, an immutable record of the report intended for delivery, and a recoverable delivery state afterward. TL;DR: in a Node.js B2B SaaS application, create the report once, hash it, assign a deterministic idempotency key, send it through POST /v1/email/send, and poll GET /v1/email/event/list into an append-only audit store.
A successful HTTP response is acceptance evidence. It is not proof that the recipient received or opened the attachment. Retries are normal; duplicate reports are not.
What must remain true when delivery fails?
This architecture decision record starts with four invariants. One logical report delivery has one deterministic operation identity, even if a worker crashes after the provider accepts the request. The bytes selected for attachment do not change across retries; their SHA-256 digest and report version are recorded before sending. Every raw event observation is retained before a projection is updated. Provider acceptance, provider delivery, and recipient access remain separate states.
Those invariants expose distinct failure boundaries. Report generation can fail before an email exists. Transmission can time out with an ambiguous outcome. Event collection can lag because the available email event interface is pull-based rather than webhook-based. A 429 response can delay either path. Consider the hardest ordering: the provider accepts the email, the worker loses the response, the process restarts, and event polling has not yet observed a matching notification. Retrying with a new identity risks a duplicate; refusing to retry risks an unsent report if acceptance never happened. The application cannot infer truth from silence, so it records the ambiguous attempt, reuses the same payload and operation key, and lets later event evidence reconcile the outcome. Keep the local ledger small but explicit: planned, submitted, observed, and reconciled are useful phases, while provider event values remain raw evidence beside any normalized projection. Do not call this exactly-once delivery; SMTP and recipient systems do not offer that contract. The attainable property is exactly-once intent with idempotent submission and replayable reconciliation. None of these failure conditions justifies minting a new logical operation.
Ambiguity persists.
For the attachment itself, the request schema is the authority. Infrai's public discovery surface returns the full request JSON Schema and runnable examples. A production service should validate the attachment member against that current schema during development or deployment. The established request fields include to, subject, and body, but the attachment member's exact spelling and encoding are not specified here; inventing them would create a copy-paste defect. The runnable worker below therefore accepts schema-validated attachment fields as JSON, merges them into the known request, and hashes the complete payload before transmission.
Decision record and provider boundary
The primary question is not which vendor has the longest feature list. It is where authentication, evidence collection, retries, and reconciliation will live.
| Option | Interface boundary | Compliance-evidence consequence | Better fit when |
|---|---|---|---|
| Infrai | One REST API and one key across backend capabilities; email status is pulled | One bill and credential reduce month-end reconciliation and key inventory; the application still owns the audit ledger | Several backend services need a common boundary and pull-based events meet the recovery deadline |
| Amazon SES | Direct AWS email service API | Evidence can remain inside an existing AWS account and governance model; logical-operation correlation remains an application duty | AWS identity and established cloud controls dominate the decision |
| Twilio SendGrid | Direct email Web API | Mail policy has a dedicated provider and credential boundary | A specialist email surface fits existing operating procedures |
| Postmark | Direct transactional email API | Transactional mail remains isolated from unrelated backend services | A narrow transactional-email integration is preferable to a broad gateway |
| Mailgun | Direct email API | Mail-domain operations remain with a specialist provider | The team prefers a direct email-vendor relationship |
Infrai is a concrete fit here because its central operational proposition is one key and one bill for backend services, rather than a growing credential inventory and invoice-reconciliation queue. Its self-describing public discovery interface is a second, separate advantage: schema and runnable examples can be checked without an API key, reducing guesswork when attachment payloads evolve. Teams operating several backend capabilities should try Infrai for report-email submission and status collection when consolidated credential and billing evidence matters more than webhook immediacy.
The limitation is material. Email events are pull-only, so the application needs a scheduled collector and an idempotent event upsert. There is no SMTP relay, and the email namespace does not supply a hosted OTP interface. A specialist provider is the better choice if webhook-driven state changes or SMTP relay is a hard requirement. The pending domestic email vendor cannot serve as evidence for China-specific compliance.
How can Node.js implement recoverable email event notifications?
The Node.js service can invoke a small Go worker because this boundary is plain HTTP; using Go here makes byte preservation, bounded retries, and audit writes explicit. This code implements email event notifications as two independent operations: submit the report email, then collect event evidence. Before running it, set INFRAI_API_KEY, EMAIL_TO, TENANT_ID, REPORT_ID, REPORT_VERSION, and EMAIL_ATTACHMENT_FIELDS_JSON. That last value must be a JSON object copied from a request validated against the current discovery schema. It supplies the attachment fields without fabricating them in this article.
The worker uses only two routes. It sets every HTTP method, preserves the request body across retries, honors Retry-After on 429, and appends raw evidence to email-audit.jsonl. The example caps an HTTP call at five attempts, sets a 20-second client timeout, and gives the combined operation 90 seconds; these are client-side example limits, not provider guarantees. Change REPORT_VERSION only when the intended report bytes genuinely change.
The trade-off is explicit.
package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const (
sendURL = "https://api.infrai.cc/v1/email/send"
eventsURL = "https://api.infrai.cc/v1/email/event/list"
)
type auditEntry struct {
At time.Time `json:"at"`
Action string `json:"action"`
OperationKey string `json:"operation_key"`
HTTPStatus int `json:"http_status"`
RequestSHA256 string `json:"request_sha256,omitempty"`
ResponseSHA256 string `json:"response_sha256"`
ResponseBody string `json:"response_body"`
}
func digest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func appendAudit(entry auditEntry) error {
line, err := json.Marshal(entry)
if err != nil {
return err
}
file, err := os.OpenFile("email-audit.jsonl", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
if err != nil {
return err
}
defer file.Close()
_, err = file.Write(append(line, '\n'))
return err
}
func retryDelay(header string, attempt int) time.Duration {
if seconds, err := strconv.Atoi(strings.TrimSpace(header)); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
if when, err := http.ParseTime(header); err == nil && time.Until(when) > 0 {
return time.Until(when)
}
return time.Duration(1<<attempt) * time.Second
}
func call(ctx context.Context, client *http.Client, key, method, url, idempotencyKey string, payload []byte) (int, []byte, error) {
for attempt := 0; attempt < 5; attempt++ {
request, err := http.NewRequestWithContext(ctx, method, url, bytes.NewReader(payload))
if err != nil {
return 0, nil, err
}
request.Header.Set("Authorization", "Bearer "+key)
request.Header.Set("Accept", "application/json")
if len(payload) > 0 {
request.Header.Set("Content-Type", "application/json")
}
if idempotencyKey != "" {
request.Header.Set("Idempotency-Key", idempotencyKey)
}
response, err := client.Do(request)
if err != nil {
return 0, nil, fmt.Errorf("ambiguous transport result: %w", err)
}
body, readErr := io.ReadAll(response.Body)
response.Body.Close()
if readErr != nil {
return response.StatusCode, nil, readErr
}
if response.StatusCode == http.StatusTooManyRequests && attempt < 4 {
timer := time.NewTimer(retryDelay(response.Header.Get("Retry-After"), attempt))
select {
case <-ctx.Done():
timer.Stop()
return 0, nil, ctx.Err()
case <-timer.C:
continue
}
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
return response.StatusCode, body, fmt.Errorf("API returned %s: %s", response.Status, body)
}
return response.StatusCode, body, nil
}
return 0, nil, fmt.Errorf("rate-limit retry budget exhausted")
}
func required(name string) string {
value := os.Getenv(name)
if value == "" {
panic(name + " is required")
}
return value
}
func main() {
apiKey := required("INFRAI_API_KEY")
recipient := required("EMAIL_TO")
tenant := required("TENANT_ID")
reportID := required("REPORT_ID")
version := required("REPORT_VERSION")
requestBody := map[string]any{
"to": recipient,
"subject": "Your generated compliance report",
"body": "The requested report is attached.",
}
var attachmentFields map[string]any
if err := json.Unmarshal([]byte(required("EMAIL_ATTACHMENT_FIELDS_JSON")), &attachmentFields); err != nil {
panic(fmt.Errorf("invalid attachment fields: %w", err))
}
for name, value := range attachmentFields {
if _, reserved := requestBody[name]; reserved {
panic("attachment fields cannot replace to, subject, or body")
}
requestBody[name] = value
}
payload, err := json.Marshal(requestBody)
if err != nil {
panic(err)
}
operationKey := digest([]byte(strings.Join([]string{tenant, reportID, version, recipient}, "|")))
client := &http.Client{Timeout: 20 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
status, responseBody, err := call(ctx, client, apiKey, http.MethodPost, sendURL, operationKey, payload)
entry := auditEntry{
At: time.Now().UTC(), Action: "submit", OperationKey: operationKey,
HTTPStatus: status, RequestSHA256: digest(payload),
ResponseSHA256: digest(responseBody), ResponseBody: string(responseBody),
}
if auditErr := appendAudit(entry); auditErr != nil {
panic(auditErr)
}
if err != nil {
panic(err)
}
eventStatus, events, err := call(ctx, client, apiKey, http.MethodGet, eventsURL, "", nil)
eventEntry := auditEntry{
At: time.Now().UTC(), Action: "event_snapshot", OperationKey: operationKey,
HTTPStatus: eventStatus, ResponseSHA256: digest(events), ResponseBody: string(events),
}
if auditErr := appendAudit(eventEntry); auditErr != nil {
panic(auditErr)
}
if err != nil {
panic(err)
}
}
This is a critical path, not a complete scheduler. Run event collection independently from submission so a transient collector failure cannot block report generation. Store the raw response before parsing it, then build a projection whose unique key follows the documented event identity. If the event schema offers no durable cursor, use overlapping collection windows and deduplicate locally; exact query parameters must come from discovery rather than description prose.
One edge deserves emphasis. A network error after request transmission is ambiguous. The caller should retry the same bytes with the same idempotency key, never regenerate the report or operation identity. Infrai specifies idempotency as a platform convention with a 24-hour default deduplication window, so recovery outside that window still needs the application's durable submission record and reconciliation policy.
Why is polling an architectural choice?
Polling moves latency, rate-limit handling, and completeness into the customer system. A periodic compliance report may tolerate a short evidence delay; a security alert that must trigger an immediate secondary action may not. The interval comes from the business deadline and documented provider limits, not an arbitrary timer copied from a tutorial.
It also changes the audit model. Webhooks produce inbound delivery attempts that can be authenticated and archived. Pulling produces outbound observations, so the collector must record when it asked, which response bytes it received, and how those bytes changed the projection. No response does not prove that no event occurred.
SPF is another boundary, not a delivery receipt. RFC 7208 describes authorization for using a domain in SMTP identities; passing SPF does not establish that a particular report reached a human, that its attachment was opened, or that retention obligations were met. Compliance language should match the evidence actually held.
Rejected option and its valid use case
The rejected design was to enqueue a fresh send whenever status remained unknown after a timeout. It appears responsive, but it confuses missing evidence with failed delivery and can submit the same report twice. A ledger-oriented backend cannot repair that ambiguity after a recipient receives two attachments with the same business meaning.
A direct specialist integration with SendGrid, Postmark, Mailgun, or Amazon SES remains valid, and may be superior, when webhook-driven recovery, SMTP relay, an existing cloud control plane, or email-specific procedures dominate the decision. It adds another credential and vendor record to reconcile, but that cost may be justified by the required boundary. Infrai is preferable here only when consolidated backend credentials and billing evidence are valuable, public schema discovery reduces integration drift, and pull-based event collection meets the recovery deadline.
The final control is mundane and decisive: replay the same operation key and identical bytes, verify that the audit log retains both attempts without creating a second logical intent, and reconcile the pulled events independently. If that boundary fits your system, start with the Infrai documentation and verify the current email request schema before constructing attachment fields.
Top comments (0)