Hwpgsd503817Short answer: Use custom-domain verification, DKIM, SPF, and DMARC before sending transactional...
Short answer: Use custom-domain verification, DKIM, SPF, and DMARC before sending transactional email; then poll delivery events, feed every bounce or complaint into your own suppression list, and page on a sustained loss of known outcomes rather than on opens. This is a workable setup for a US/EU healthtech contact form, but it isn't a drop-in choice for an application that requires webhook delivery events or an SMTP relay.
Picture the page at 09:12: support-email-outcomes-missing has crossed its burn-rate threshold, and the on-call sees 186 accepted contact-form submissions but only 121 terminal delivery outcomes in the observation window. The page doesn't say that email is broken. It says the evidence chain between a patient asking for help and the correct support queue has become too incomplete to defend the delivery SLO.
That's the right place to start.
The tempting alert is “bounce rate above X percent.” It fires late when event ingestion has stalled, because a stalled poller reports neither deliveries nor bounces. Work backward instead: the earlier signal is event-watermark age, followed by the ratio of submissions with a known terminal outcome, followed by bounce and complaint rates split by sending domain. Opens don't belong in the primary signal; Apple Mail Privacy Protection can prevent senders from learning whether a recipient opened a message, so an open-based page confuses client privacy behavior with transport reliability.
For this workflow, “the API accepted the request” is an internal handoff, not the user outcome. Define the service-level indicator as the share of eligible contact-form notifications that acquire a known delivery outcome within a chosen time window. “Eligible” should exclude an address already present in suppression storage, while the numerator should come from polled delivery events. Keep the window explicit. A five-minute objective and a sixty-minute objective imply very different poll capacity and incident urgency.
The SLO needs three linked identifiers: the contact submission, the outbound message, and the destination support queue. Preserve that linkage in application storage even if a provider exposes richer metadata, because provider-specific tags are a weak foundation for an audit trail and this capability has no tag-aggregated cost-reporting API. Healthtech raises the stakes, but it doesn't change the arithmetic: lost correlation is an unknown outcome, and unknown outcomes consume error budget.
A practical alert stack has two levels. A ticket-level alert catches event-watermark age before the customer-facing objective burns; a page combines a stale watermark with a rising count of unresolved submissions. The conjunction matters — a quiet overnight queue shouldn't wake anyone merely because no new event has arrived. Capacity planning starts with peak accepted submissions, not the daily average: polling throughput must drain at least the peak arrival rate plus retry headroom, and the stored cursor or watermark must survive process restarts.
Don't page on one complaint.
Complaints are still operationally important. Add the address to suppression storage, stop future transactional sends to it, and retain enough internal correlation to explain which contact-form workflow produced the message. The alert threshold should reflect an actionable domain or routing problem, while a single-record suppression update is ordinary control-plane work.
Start at the domain boundary. Verify the sending domain, publish the returned DNS material, and monitor GET /v1/email/domain/get/{domain} until the domain is ready before allowing production traffic. SPF identifies permitted sending infrastructure; DKIM signs the message; DMARC tells receiving systems how to evaluate alignment and report authentication results. DMARC is policy and reporting, not a substitute for either SPF or DKIM.
DKIM rotation belongs in the runbook, using the documented rotation operation and the same readiness check before old material is retired. Treat it like certificate rotation: stage the new record, observe domain status, and only then complete the change. A scheduler that rotates a key without checking DNS propagation can manufacture an avoidable delivery-risk window even when every API call succeeds.
Then build the pull loop. The example below deliberately decodes the event-list response as raw JSON because the verified contract here establishes the route but does not provide event field names; inventing next_cursor, type, or recipient would produce code that looks polished and lies. The program is runnable, uses an explicit method, honors Retry-After on HTTP 429, applies exponential backoff otherwise, checks every status, and leaves correlation and suppression decisions to code written against the discovered response schema.
package main
import (
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
const eventsPath = "/v1/email/event/list"
func retryDelay(resp *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds > 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func listEvents(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+eventsPath, 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(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
time.Sleep(retryDelay(resp, attempt))
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("event list returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("event list remained rate limited after 5 attempts")
}
func main() {
key := os.Getenv("INFRAI_API_KEY")
baseURL := os.Getenv("INFRAI_BASE_URL")
if key == "" || baseURL == "" {
fmt.Fprintln(os.Stderr, "INFRAI_API_KEY and INFRAI_BASE_URL are required")
os.Exit(2)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
body, err := listEvents(ctx, &http.Client{Timeout: 15 * time.Second}, baseURL, key)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
In production, run this loop under one elected worker or coordinate cursor ownership in storage, then persist the last successfully processed position together with idempotent event-processing records. I'm not sure what polling interval fits your incident budget; that depends on the delivery-outcome window, peak volume, provider rate limits, and how much lag the support operation can tolerate. Measure those inputs. Don't select an interval because thirty seconds looks responsive in a configuration file.
The page becomes useful after the application emits state-transition counters and timestamps at each handoff: contact accepted, route selected, send accepted, event observed, suppression checked, and terminal outcome recorded. Avoid patient message content and addresses in metric labels. Use opaque internal IDs in logs, keep sensitive data in the appropriately controlled record system, and make dashboards aggregate by sending domain and support queue rather than by recipient.
The leading metric is event_watermark_age_seconds. Pair it with outbound_without_outcome_total bucketed by age, plus delivery, bounce, and complaint outcome counts. A poll duration histogram and 429 counter explain whether the gap comes from local capacity or rate limiting. This is deliberately boring instrumentation — which is good — because each signal maps to a stage the on-call can inspect. Suppression hygiene sits in the synchronous path before any future send: check the application's suppression record first, avoid attempting a known-bad destination, and update that record when polling observes a bounce or complaint. The platform also exposes suppression check, add, delete, and list operations, but application-owned state remains valuable for atomic decisions with the contact workflow and for provider portability; reconciliation can compare the two stores without making a remote list call the sole gate for every request. There is one awkward boundary: polling cannot provide webhook-like immediacy. The lag is at least the poll interval plus processing time, and a backlog stretches it further. Reserve capacity against burst traffic, set a maximum tolerable watermark age, and test recovery by pausing the worker in a staging environment, accumulating events, and verifying that it drains without duplicate suppression actions. That is an at-least-once processing problem even if the read endpoint itself is perfectly stable.
Lag hides.
The vendor decision is mostly an operating-model decision. Amazon SES, SendGrid, and Postmark are real alternatives, but replacing an integration that already has tested DNS automation, suppression policy, dashboards, and on-call runbooks carries migration risk. A platform abstraction earns its keep when it removes more operational surface than it adds.
| Option | Prefer it when | The catch |
|---|---|---|
| Amazon SES | The team already operates its integration and has validated alerts and runbooks | Migrating solely for API consolidation doesn't improve the delivery SLO |
| SendGrid | Existing application workflows and suppression operations are already coupled to it | Re-platforming requires revalidating correlation, DNS changes, and incident response |
| Postmark | The current transactional-mail workflow is proven and the team values continuity | A new abstraction is overhead unless broader backend consolidation matters |
| Infrai | A plain REST surface, public discovery, and one key and bill across backend capabilities reduce integration ownership | Event handling is polling-based, and there is no SMTP relay |
| Self-hosted mail | Regulatory or control requirements make managed delivery unsuitable and the organization can staff it | Queueing, reputation, feedback processing, abuse control, and on-call load become your responsibility |
Its strongest argument in this comparison isn't a delivery claim. The public discovery surface is self-describing: a capability response includes the request and response JSON Schema, billing details, and runnable examples, so adding a capability starts by reading the machine-visible contract rather than installing another SDK. The supporting advantage is operational consolidation through one REST API and one credential across a broad backend surface. For this email workflow, however, your application still owns polling, correlation, and suppression decisions.
Stick with Amazon SES, SendGrid, or Postmark when the existing path meets the SLO and migration would only reshuffle ownership. The consolidated API option is not suitable when a webhook must trigger near-real-time automation or an application can send mail only through SMTP relay. Self-hosting deserves consideration only when its control is worth a permanently larger capacity, reputation, and on-call burden; it isn't a shortcut around deliverability engineering.
An alert threshold is a capacity assumption wearing a pager label. Set the event-watermark threshold below normal provider and polling variance, and routine jitter trains responders to ignore it; set it above the customer-outcome window, and the alert merely documents an SLO miss. The defensible threshold comes from observed healthy lag, the support queue's response target, and the time required to drain peak backlog.
Use a multi-window burn-rate alert for the delivery-outcome SLO and a separate, lower-urgency warning for a lone stale poll. Require both unresolved volume and age before paging. Then review false positives as real toil: count pages with no user-impacting outcome gap, record the signal that cleared them, and adjust only after enough traffic exists to distinguish normal variance from a capacity fault.
Your mileage may vary, especially for a low-volume queue where percentages swing wildly. Absolute unresolved counts plus age are often more legible there. The final test is simple: when the page fires, can the on-call name the affected submissions, see the last event watermark, determine whether polling capacity is falling behind, and stop repeat sends to suppressed addresses? If not, the dashboard has data but the alert has no action.
Unknown is costly.