TL;DR: Treat a compliance-notice template as a versioned contract owned by one team, not as an HTML string passed between services. Validate the event envelope and every required variable before rendering, render the exact approved template in a preview path, and persist the template version, content digest, recipient, event ID, and delivery state as separate evidence. A provider's 400 response then becomes the last validation boundary, rather than the first place malformed JSON or a missing variable is discovered.
The production scenario is deliberately narrow: a fintech platform must send a policy-change notice by email and retain an auditable delivery record. I would set the SLO around accepted, contract-valid notice events and traceable delivery transitions, not around a provider call returning 2xx, because acceptance is neither rendering correctness nor proof of delivery. The invariant is stricter: the bytes previewed under a named template version must be the bytes submitted for that notice, and the evidence record must survive retries.
How should an email API handle malformed JSON in event notifications?
There are three different validity boundaries, and teams often collapse them into one. JSON syntax asks whether the payload can be decoded. Schema validation asks whether fields have the expected names and types. Template validation asks whether all placeholders required by the selected template version have values. A payload can pass the first two checks and still fail the third; it can also render locally and be rejected later because the provider-facing request has a different contract.
These are not interchangeable failures.
Start at the boundary closest to the event producer. Decode with unknown-field rejection, validate identifiers and recipient shape, then compare the supplied variables with the template manifest. Do not infer that an empty string is a valid value merely because it is present. For a legal effective date, for example, absence and emptiness are both contract failures, while an optional support telephone number may legitimately be omitted. Those rules belong beside the template version, under the same owner.
A 400 response is useful evidence that the submitted request was unacceptable, but its body is not a durable specification. Record a bounded, redacted error classification and request correlation data; do not place the full recipient payload or rendered compliance notice in a general-purpose log. Then return the event to a terminal validation state or a retryable transport state according to the error class. Retrying unchanged malformed input only spends capacity and obscures the original failure.
Short failures matter. Stop early.
Make template ownership explicit
Template ownership decides where correctness can actually be enforced. If application teams own arbitrary HTML while a platform team owns transport, neither side can prove that a variable rename and a template deployment were coordinated. If the platform owns every word, legal and product changes queue behind infrastructure work. The practical boundary is a versioned template package: the compliance owner approves content, the platform defines the manifest and release controls, and the sending application supplies only typed business data.
| Model | Change authority | Validation point | On-call consequence | Best fit |
|---|---|---|---|---|
| Application-owned markup | Each producer | Usually at send time | Wide blast radius; many runbooks | Highly specialized transactional content |
| Platform-owned templates | Platform team | Central release pipeline | Clear paging owner; platform bottleneck | Repeated notices with stable fields |
| Versioned shared contract | Content approver plus platform controls | Build, preview, and ingest | More release machinery; narrower incidents | Regulated notices requiring evidence |
That is a buy-versus-build decision too, but it is not mainly a price comparison. A managed template store reduces infrastructure ownership while putting template lifecycle and preview semantics behind an external boundary. A self-hosted renderer gives deterministic inputs and retention control while adding patching, capacity planning, and an on-call surface. The capacity estimate must include preview traffic, retry bursts, and dual rendering during migrations, not just the steady-state send rate.
Ownership comes first.
Choose the ownership model that can enforce one release transaction across the manifest, approved content, and version identifier. Everything else is an organizational promise with weak failure isolation.
Reject incomplete notices before transport
The preventative path below uses Go because the important mechanism is visible without framework behavior: strict decoding, explicit field checks, an allowlist derived from the template manifest, and deterministic rendering. The renderer uses Go's contextual html/template package, which escapes data according to its HTML context. Templates are trusted release artifacts; event values are untrusted data.
package notice
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"html/template"
"io"
"sort"
"strings"
)
type Event struct {
EventID string `json:"event_id"`
Recipient string `json:"recipient"`
TemplateVersion string `json:"template_version"`
Variables map[string]string `json:"variables"`
}
type RenderedNotice struct {
EventID string
Recipient string
TemplateVersion string
HTML []byte
SHA256 string
}
func DecodeAndRender(r io.Reader, source string, required []string) (RenderedNotice, error) {
dec := json.NewDecoder(r)
dec.DisallowUnknownFields()
var event Event
if err := dec.Decode(&event); err != nil {
return RenderedNotice{}, fmt.Errorf("decode notice event: %w", err)
}
if err := ensureEOF(dec); err != nil {
return RenderedNotice{}, err
}
if strings.TrimSpace(event.EventID) == "" || strings.TrimSpace(event.Recipient) == "" {
return RenderedNotice{}, fmt.Errorf("event_id and recipient are required")
}
if strings.TrimSpace(event.TemplateVersion) == "" {
return RenderedNotice{}, fmt.Errorf("template_version is required")
}
missing := missingVariables(event.Variables, required)
if len(missing) != 0 {
return RenderedNotice{}, fmt.Errorf("missing required template variables: %s", strings.Join(missing, ", "))
}
tmpl, err := template.New(event.TemplateVersion).Option("missingkey=error").Parse(source)
if err != nil {
return RenderedNotice{}, fmt.Errorf("parse approved template: %w", err)
}
var output bytes.Buffer
if err := tmpl.Execute(&output, event.Variables); err != nil {
return RenderedNotice{}, fmt.Errorf("render approved template: %w", err)
}
body := output.Bytes()
digest := sha256.Sum256(body)
return RenderedNotice{
EventID: event.EventID, Recipient: event.Recipient,
TemplateVersion: event.TemplateVersion, HTML: body,
SHA256: hex.EncodeToString(digest[:]),
}, nil
}
func ensureEOF(dec *json.Decoder) error {
var extra any
if err := dec.Decode(&extra); err != io.EOF {
if err == nil {
return fmt.Errorf("decode notice event: multiple JSON values")
}
return fmt.Errorf("decode notice event trailer: %w", err)
}
return nil
}
func missingVariables(values map[string]string, required []string) []string {
var missing []string
for _, name := range required {
if strings.TrimSpace(values[name]) == "" {
missing = append(missing, name)
}
}
sort.Strings(missing)
return missing
}
This code intentionally does not send mail. Transport belongs after the immutable evidence record is created, and it should receive the rendered bytes rather than independently selecting or rendering a template. That separation also makes malformed JSON a producer-facing contract error instead of a provider-facing mystery.
No retry can repair that input.
The manifest needs its own tests: every required variable appears in the approved fixture, unknown event fields fail, trailing JSON fails, empty required values fail, and output is stable for a fixed template version and fixture. Add golden-file review for the rendered HTML, but compare the exact bytes as well as a browser screenshot; visual similarity does not establish evidence identity.
Preview the artifact that will be sent
A preview endpoint should execute the same decoder, manifest lookup, and renderer as production, then return a non-deliverable artifact with the template version and digest. It must not call the transport adapter. If preview quietly uses a draft template while delivery resolves a published alias, the preview is theater.
Preview must be exact.
HTML email adds an awkward constraint: browser rendering is useful but incomplete because mail clients apply different HTML and CSS support. Preview in a browser for fast feedback, test representative mail clients before releasing a template version, and keep a plain-text alternative as part of the approved package. Never mark event values as trusted HTML to make a preview look right. Fix the template boundary instead.
For SMS fallback, the artifact and evidence model remain useful, but the content contract changes. GSM-7 messages have a 160-character single-segment limit, while UCS-2 messages have a 70-character single-segment limit; concatenated messages use smaller per-segment limits. A single character outside GSM-7 can therefore change segmentation. Calculate encoding and segment count after rendering, store them with the artifact, and make the channel decision explicit rather than assuming that an email template can be flattened into a predictable text message.
Operate the delivery as a state machine
An auditable delivery record is not one mutable status column overwritten by callbacks. Keep append-only transitions keyed by the internal notice ID: validated, rendered, submitted, accepted, delivered, deferred, bounced, or failed, according to the events your transport can substantiate. Provider acceptance and recipient delivery are different observations. Duplicate callbacks and retries must be idempotent, and state transitions that arrive out of order must retain their source timestamps and ingestion timestamps.
Measure contract rejection rate by producer and template version, render failures, queue age, submission latency, and the age of notices without a terminal observation. Page on an SLO symptom with user or compliance impact, not on every 400. A sudden cluster of schema rejections after a producer deployment is actionable; one rejected test event is usually telemetry. Capacity plans should cover the retry amplification created by a downstream slowdown and the storage growth of append-only evidence for the required retention period.
The evidence record should contain identifiers and hashes sufficient to establish what happened: internal notice ID, deduplication key, recipient reference, approved template version, rendered-content digest, transition type, source timestamp, ingestion timestamp, and transport correlation ID. Access to rendered bodies and addresses needs tighter controls than operational metrics. Retention duration and legal sufficiency are policy decisions for compliance counsel, not defaults to borrow from a mail API.
The useful audit claim is reproducible and narrow: this event passed versioned validation, produced this content digest, was submitted once under this idempotency key, and later received these transport observations. It does not claim that an inbox was read.
This pattern is excessive for disposable marketing content or internal notifications with no audit requirement; a simpler application-owned template may be the better operational trade-off there. For regulated notices, however, deterministic rendering and explicit ownership reduce both ambiguity and paging time, which is usually worth more than shaving a step from the send path.
Top comments (0)