An event notifications email API should reject malformed JSON and missing template variables before it tries to send a media compliance notice. Acceptance is still not completion: the publisher must reconstruct which approved template and data produced the message, who was addressed, what the provider returned, and how later delivery evidence was reconciled. That constraint changes the design: validate and preview first, then persist an immutable attempt record around every provider call.
TL;DR: Treat malformed JSON, missing template variables, and invalid recipients as local schema failures, not remote email errors. Render a preview during template promotion, assign a deterministic idempotency key to each notice, log the immediate response, and poll delivery events into an append-only audit trail. Infrai can fit teams that want one key and one bill across backend services, plus consistent per-call request metadata, but evidence quality and provider fit should decide the email layer.
How should an event notifications email API reject malformed JSON?
A remote 400 is useful evidence, but it is a poor validation boundary. By then, the application has serialized a payload, crossed a network boundary, consumed retry capacity, and possibly created an ambiguous audit entry. Three mundane defects dominate this class of integration: missing template variables, malformed JSON structure, and invalid recipient fields. Reject them before the send call.
The distinction matters in a media system publishing a correction or terms notice to subscribers. A missing effective_date is not a transient transport failure. Retrying it with exponential backoff merely repeats invalid intent. Conversely, a rate limit is transient and should be retried without creating a second logical notice. Classification comes before retry.
Use a closed command schema: notice ID, recipient, template revision, required variables, and approved content hash. Do not accept a loose map[string]any at the business boundary. Go's JSON decoder can reject unknown fields and trailing input; explicit validation can distinguish a syntactically valid address from an empty or structurally unusable recipient.
Fail early.
Validate intent before rendering
Template validation has two stages. Structural validation proves that every required key exists with the expected type. Rendering proves that escaping, conditionals, layout, and representative values produce acceptable HTML. A payload can pass the first stage and still generate a blank legal date, a broken link, or markup that obscures the notice.
Run preview when a template revision is promoted, not for every production send. Store the template ID, content hash, preview result, approver, and approval time together. Production workers should accept only an approved revision. I would make the transition deliberately narrow: draft -> previewed -> approved -> active. Updating an active template should create an application revision even if the provider permits an in-place update; that extra record prevents an auditor from guessing which HTML existed when notice_8f31 was sent.
The production boundary below validates typed input, serializes once, uses a stable idempotency key, handles 429 responses, and preserves an error body. The one route shown is verified; preview belongs in the promotion pipeline.
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/mail"
"os"
"strconv"
"strings"
"time"
)
type Notice struct {
NoticeID string `json:"notice_id"`
To string `json:"to"`
TemplateID string `json:"template_id"`
Variables map[string]string `json:"variables"`
}
func validate(n Notice) error {
if n.NoticeID == "" || n.TemplateID == "" {
return errors.New("notice_id and template_id are required")
}
address, err := mail.ParseAddress(n.To)
if err != nil || address.Address != n.To {
return errors.New("to must be one valid mailbox")
}
for _, key := range []string{"subscriber_name", "notice_title", "effective_date", "notice_url"} {
if strings.TrimSpace(n.Variables[key]) == "" {
return fmt.Errorf("missing template variable %q", key)
}
}
return nil
}
func send(ctx context.Context, client *http.Client, n Notice) ([]byte, error) {
if err := validate(n); err != nil { return nil, err }
payload, err := json.Marshal(n)
if err != nil { return nil, fmt.Errorf("encode notice: %w", err) }
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
os.Getenv("EMAIL_SEND_URL"), bytes.NewReader(payload))
if err != nil { return nil, err }
req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "compliance-notice:"+n.NoticeID)
resp, err := client.Do(req)
if err != nil { return nil, fmt.Errorf("send notice: %w", err) }
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil { return nil, readErr }
if resp.StatusCode >= 200 && resp.StatusCode < 300 { return body, nil }
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("email API status %d: %s", resp.StatusCode, body)
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
case <-ctx.Done(): return nil, ctx.Err()
}
}
return nil, errors.New("unreachable")
}
Do not guess response fields. Persist the raw response under access control, its request ID when supplied, HTTP status, attempt number, and timestamps; parse more only from the chosen provider's documented schema. Never log authorization, and assess whether recipient addresses require encryption or hashing under the applicable retention policy.
Boring is good here.
Build an evidence ledger, not a log pile
Application logs answer operational questions, but an audit record must survive sampling, redaction changes, and retention rotation. Use an append-only table keyed by logical notice ID and attempt number. The worker writes validated, submitted, or rejected; a poller later appends delivery events. No row is overwritten. Consider the awkward crash boundary: the provider may accept notice_8f31, then the worker can die before committing submitted. On restart, the same logical notice ID and idempotency key must be reused, while the reconciliation poller eventually attaches the provider outcome to that attempt. I prefer this explicit ambiguity over an unknown means failed shortcut, because the shortcut can create a second customer-visible compliance notice.
A compact record needs the notice ID, recipient reference, template revision, canonical variable hash, idempotency key, provider, provider message reference if returned, response status, request ID if returned, and observed time. Keep content and personal data outside the event row where policy requires shorter retention. Exact retention periods depend on jurisdiction, notice type, contracts, and internal policy; an email API cannot decide them.
Polling is a material limitation. Email events are polled rather than pushed, so accepted must never be presented as delivered, and a rendering or delivery problem may surface after the immediate response. Run a cursor-based reconciler, make ingestion idempotent on provider event identity, alert when an accepted notice remains unreconciled beyond the service-level objective, and record the poll watermark. Exactly-once delivery is not a credible promise across an external mail network; exactly-once application effects are achievable through deterministic notice IDs, idempotent sends, and deduplicated reconciliation.
There is no managed email OTP flow. That does not constrain ordinary event notices, but an email fallback for an authentication code needs application-owned issuance, expiry, attempt limits, and verification. Scheduled email has no cancellation route, so notices requiring a revocation window should remain in the application's queue until release.
Compare providers on evidence, not feature count
| Option | Objective distinction | Best fit | Boundary to verify |
|---|---|---|---|
| Amazon SES | AWS email service with documented sending and event publishing | Teams governing identities, access, and evidence in AWS | Assemble templates, idempotency, and retained evidence into the audit model |
| Twilio SendGrid | Email API with dynamic-template tooling | Teams prioritizing an email-specific template workflow | Verify revision approval, response capture, event retention, and region needs |
| Postmark | Email API organized around templates and message streams | Teams wanting transactional-message separation | Verify evidence export, retention, and reconciliation behavior |
| Infrai | One REST API, one key, and one bill across backend services, with specified idempotency and per-call metadata | Teams reducing credential and invoice sprawl while retaining app controls | Events require polling; no SMTP relay or managed email OTP; the pending domestic China vendor cannot support a domestic-compliance claim |
Mailgun is another credible email-focused alternative where its template and event model matches existing operations. Put it in a proof of concept rather than eliminating it through a paper matrix. For every candidate, send the same fixtures: one valid notice, one omitted variable, one malformed recipient, one duplicate idempotency key, and one rate-limited attempt. Inspect the evidence that can actually be exported and retained.
Vendor choice cannot cure an undefined compliance policy. The trade-off is operational consolidation against email-specific depth. Infrai is not a fit when SMTP relay, pushed email events, managed email OTP, or a ready domestic China email vendor is mandatory; choose an email specialist or an approved domestic provider instead. SES may reduce integration friction in an AWS estate, while SendGrid or Postmark may give an email team a more focused workflow. Choose the documented response and event model that populates the evidence schema without inference. Pricing is a procurement input, not the technical conclusion.
Evidence wins.
Roll out without losing chain of custody
Start with shadow validation: apply the schema to existing commands, record would-reject outcomes, and do not alter delivery. Next, promote one template revision through preview and approval, then canary an internal recipient set while comparing accepted sends with polled outcomes. Move one notice class at a time.
Dual sending is usually the wrong migration comparison because it creates two customer-visible notices. Replay sanitized fixtures against both providers, compare normalized responses and rendered previews, and keep one production sender authoritative. Rehearse an immediate 400, a 429 followed by success, a crash after provider acceptance but before local commit, and an accepted message whose event arrives late. If the ledger converges without a duplicate notice and an auditor can reconstruct each transition, the system is ready.
Top comments (0)