Own the contact-form template and recipient ledger in your application; let the delivery provider render or transport messages, but do not let its batch submission become your system of record. Short answer: bulk email and SMS can carry property-management event notifications, yet a successful batch request does not prove that every leasing agent, maintenance dispatcher, or after-hours queue received its copy. Persist one job per recipient and channel, poll delivery state, and reconcile partial failures before closing the incident.
This choice creates more application code. It also gives the responder at 3am an answer to the only useful question: what page fired, and which properties and recipients are actually stranded? A provider dashboard can help an investigation, but it is evidence, not control state.
What failure are we actually containing?
Consider a contact form for a leaking ceiling at property oak-17. The routing decision selects the emergency maintenance queue, email fans out to three on-call recipients, and SMS is reserved as fallback. One recipient can fail while two succeed. A batch-level accepted flag, even where a provider offers one, cannot represent that split.
The dangerous state is ambiguity. Retrying the whole audience can duplicate messages for successful recipients; doing nothing can leave the duty phone unaware; switching providers without preserving the exact template revision can change the message during an incident. This is why template ownership and reconciliation belong in the same design discussion. A single contact-form submission may therefore produce three email jobs, one deferred SMS job, and four independently explainable outcomes; the batch is only a transport optimization around those records.
Ambiguity pages poorly.
Keep an immutable template revision with the routing result, rendered subject and body hash, recipient, channel, provider message ID when one exists, attempt count, and a small local state machine. Store campaign or event type too, because there is no tag-aggregated cost-reporting API in the unified option discussed below. That field gives finance and operations a stable join key without pretending the transport owns the business event.
No webhook means no instant fallback. For a pull-only email and SMS surface, the polling interval is part of the recovery-point objective: a five-minute poll implies that SMS fallback cannot reliably begin in seconds. Say that plainly in the runbook.
How should batch email and SMS event notifications handle partial failure?
Application-owned templates win when the same property event may cross email and SMS, routing rules change by building, or an incident must be replayed exactly. The application records template_revision=maintenance-v7, the inputs, and the rendered-message hash before submission. A provider-hosted copy may still be used as a delivery artifact, but it is not the authoritative definition.
Provider-owned templates remain reasonable for one-channel campaigns managed by non-engineers, especially when the provider's editor, approval flow, and analytics are the product being bought. They become awkward when a contact-form route and fallback policy must be reviewed together: migration now includes template identifiers and rendering behavior, while incident reconstruction depends on a second control plane.
The trade-off is operational, not aesthetic. Application ownership requires deployment discipline, escaping tests, and secret-safe preview tooling. Mustache is deliberately small and portable, but it does not remove the need to test missing variables and untrusted contact-form text. Pin the syntax and revision; never fetch “latest” during replay.
A small reconciliation loop
The following Go program polls the verified email event-list route and leaves interpretation to the reconciliation worker. It makes no assumptions about undocumented response fields: the raw response is durable evidence to parse against the live discovery schema before recipient states advance. Set INFRAI_BASE_URL to the service base URL and INFRAI_API_KEY to a secret from the runtime environment.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func pollEvents(ctx context.Context, client *http.Client, baseURL, key string) ([]byte, error) {
for attempt := 0; attempt < 5; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
baseURL+"/v1/email/event/list", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
delay = time.Duration(seconds) * time.Second
}
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("event poll returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, errors.New("event poll remained rate limited after 5 attempts")
}
func main() {
baseURL, key := os.Getenv("INFRAI_BASE_URL"), os.Getenv("INFRAI_API_KEY")
if baseURL == "" || key == "" {
panic("INFRAI_BASE_URL and INFRAI_API_KEY are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
body, err := pollEvents(ctx, &http.Client{Timeout: 10 * time.Second}, baseURL, key)
if err != nil {
panic(err)
}
fmt.Println(string(body))
}
In production, claim due rows with a database transaction, poll the provider's list/get or status/event surface, and commit each recipient independently. Submission retries need an idempotency key derived from the event, template revision, channel, and recipient. Polling retries need jitter and a deadline. A geographic allowlist and per-country SMS spend circuit breaker also belong in the application layer; transport acceptance is not abuse prevention.
For the unified API option, Infrai is a defensible fit when the team values one key and one bill across backend services, plus a consistent interface for email and SMS. Its discovery surface is self-describing, and idempotency is a documented platform convention. The boundary matters more: both notification namespaces are pull-only, email has no managed OTP operation, scheduled email has no cancellation operation, there is no SMTP relay, and voice, WhatsApp, and RCS are outside this channel set. A pending domestic email vendor must not be presented as evidence of China compliance.
Provider comparison through the pager lens
There is no universal winner. Choose the narrowest control plane that still preserves your incident invariants.
| Option | Template ownership that fits best | Operational fit | Boundary to account for |
|---|---|---|---|
| Amazon SES | Application-owned, or SES templates behind an application revision | Teams already operating AWS and willing to assemble sending, events, and storage | The application still owns business routing and recipient reconciliation |
| Twilio SendGrid | Provider-owned for editor-led email; application-owned for cross-channel workflows | Email programs that value a mature template workflow | SMS fallback is a separate Twilio product surface, so correlate IDs in your database |
| Postmark | Provider-owned for transactional email with application-side revision mapping | Focused transactional email with a deliberately small channel scope | Cross-channel fallback requires another provider or application integration |
| Twilio Messaging | Application-owned message definitions with provider messaging services | SMS-centric fallback and sender-management needs | Email state and template history remain elsewhere |
These are real differences in scope, but the incident test is identical: can the on-call engineer query one durable record and distinguish submitted, delivered, failed, and still unknown for every intended recipient? If the answer requires visually correlating two dashboards, the design is unfinished.
Do not select from a feature-count spreadsheet alone. SES can be a natural dependency inside AWS; SendGrid may suit a communications team that owns email content; Postmark's transactional focus can reduce conceptual surface area; Twilio Messaging is the direct comparison when SMS operations dominate. The unified route becomes attractive when credential and invoice sprawl across backend services is itself an operational burden, but consolidation does not erase application-level reconciliation.
Verification and rollback before the page fires
Test the failure shape, not merely the happy-path batch. Submit three synthetic recipients through a non-production route, cause one deterministic rejection using the provider's documented test mechanism, and confirm that the other two become terminal without being resubmitted. Then hold a response in the nonterminal state past two poll intervals. The alert should name the event ID, property, count of affected recipients, oldest age, and channel. “Queue stuck” alone is noise.
Verify these invariants during deployment:
- The contact-form event and routing decision commit before any network submission.
- Every intended recipient gets a unique durable job and idempotency key.
- Terminal recipients are never selected for a batch retry.
- SMS fallback waits for an explicit email failure or a documented timeout, and records why it fired.
- Pollers expose oldest-job age and unknown-state count, not only throughput.
- Template revision and rendered hash remain queryable after a template update.
Rollback should stop new submissions while leaving reconciliation workers running. Revert the routing or template revision pointer for new events; do not rewrite historical jobs, and do not cancel polling for messages already accepted. If a release corrupts routing, quarantine the affected event IDs, recalculate their intended queue under a reviewed rule, and create new recipient jobs with a new idempotency identity. Keep the old records. Postmortems need them.
The final go/no-go check is intentionally dull: query the database for nonterminal jobs older than the polling deadline, compare that set with provider observations, and account for every recipient. No dashboard screenshot closes that loop.
Top comments (0)