Short answer: log each email API attempt with a stable submission ID, retry only classified transient failures, and keep the support ticket separate from the notification. That gives a failed delivery a traceable outcome without creating duplicate tickets.
A contact form should enter the support queue exactly once, even when the email API times out after accepting the request. The practical rule is to separate the support ticket from its notification: record the routing decision first, attach a stable delivery key, and retry only failures that are plausibly temporary. Permanent rejects become visible evidence, not an endless background job.
This matters for a small B2B SaaS team because email delivery is an external boundary with ambiguous outcomes. A 500 response tells you the request failed at the boundary. A timeout tells you almost nothing about whether the provider accepted it. Treating both cases as the same error is how duplicate tickets and duplicate customer emails appear.
The timeout is the hard one.
How should error logging and retry strategies handle failed email deliveries?
For each contact submission, I want to answer three questions without reconstructing a distributed trace from memory: what queue did the form select, what payload was sent, and what happened after the API call? A useful record has a submission ID, a queue ID, a message kind, a hashed recipient address, a provider request ID when one exists, an attempt number, the HTTP status or network error class, and timestamps. It should not store the full message body or arbitrary customer data in an error log.
The distinction between a submission and a delivery attempt is important. One form submission can have an initial attempt, a retry, and a final permanent failure. Giving each attempt a new business ID makes the system look like it received three forms. Keep the business ID stable and make the attempt ID unique.
Here is a small TypeScript shape for that boundary. It is deliberately boring; boring fields are easy to query six weeks later.
type DeliveryState = | "pending" | "sent" | "retryable" | "permanent_failure"; type DeliveryAttempt = { submissionId: string; attemptId: string; queueId: string; kind: "support-notification"; recipientHash: string; providerRequestId?: string; state: DeliveryState; statusCode?: number; errorClass?: "timeout" | "connection" | "rate_limit" | "rejected" | "server"; occurredAt: string; }; The log is evidence, not a transcript. Redact addresses, subject lines, authorization headers, and response bodies unless a reviewed retention policy says otherwise. Keep a correlation ID that the support application can use, but avoid putting a customer's message into every retry log line.
How do retries avoid making the incident worse?
First classify the outcome. A connection reset, a request timeout, a 429, and a provider-side 5xx are usually candidates for bounded retry, subject to the API's documented limits and any Retry-After value. An invalid recipient, rejected sender identity, policy rejection, or malformed request needs correction or human review. Retrying those conditions only creates noise.
The dangerous case is an unknown outcome. If the client times out after sending bytes, the service may have accepted the email. A blind retry can send two notifications. The clean boundary is an idempotency key when the API supports one. When it does not, make the job idempotent on your side, store the attempt and response evidence, and choose a duplicate-tolerant notification policy. For a contact form, the support ticket can be created once while the notification is retried separately.
A bounded delay can use exponential backoff with jitter. For example, the delay for attempt n can be capped rather than allowed to grow forever: min(60_000, 2 ** n * 1_000 + random(0, 1_000)). The exact ceiling is a policy decision based on how quickly the support queue must react. It is not a magic reliability number. Stop after a small, explicit attempt budget and send the record to a review queue.
function retryDelay(attempt: number, randomMs: number): number { const exponentialMs = 2 ** attempt * 1_000; return Math.min(60_000, exponentialMs + randomMs); } function isRetryable(statusCode?: number, errorClass?: DeliveryAttempt["errorClass"]): boolean { return errorClass === "timeout" || errorClass === "connection" || errorClass === "rate_limit" || errorClass === "server" || statusCode === 429 || (statusCode !== undefined && statusCode >= 500); } Do not infer success from the absence of an exception. Persist the provider response before acknowledging the queue job, and make the state transition conditional on the current attempt. That prevents two workers from both turning the same pending record into a fresh retry. A dead-letter queue is useful only if someone owns its review; otherwise it is a quiet archive of customer-facing failures.
Where do common implementations lose the plot?
The first trap is logging only the exception string. It loses the status code, the attempt number, and the provider correlation value needed for investigation. The second is logging every payload for convenience. That creates a privacy and retention problem while still failing to answer whether the provider accepted the request. The third is treating all 4xx responses as permanent. Rate limiting is a 4xx response that often deserves a delayed retry; an invalid address is not.
The sender side also has a standards boundary. SPF describes how a receiving system can evaluate whether an IP is authorized to send for a domain; it does not guarantee inbox placement or prove that an individual API request was delivered. DKIM, DMARC, bounce processing, and provider event data answer different questions. Keep those signals separate in the data model instead of collapsing them into a single delivered: true flag.
If the team uses a managed transport, the trade-off is operational surface area. Amazon SES exposes a sending API and event-publishing options, but the application still owns correlation and retry policy. SendGrid offers API response and event mechanisms, while its categories of failures still need to be mapped to the application's states. Mailgun provides sending and event APIs, but the same distinction between an accepted request and final mailbox outcome remains. These are different integration boundaries, not interchangeable proof of delivery. Use the documentation for the selected transport to verify status semantics and limits; do not make the product name your error taxonomy.
| Transport example | Integration boundary | Useful evidence | Main limitation |
|---|---|---|---|
| Amazon SES | API plus event publishing | Request response and event records | Application still owns correlation and retry state |
| SendGrid | Mail send API plus events | API response and event data | Failure categories need an application mapping |
| Mailgun | Sending API plus events | Request and event records | Accepted requests are not final mailbox outcomes |
I would choose the smallest boundary that the team can observe and operate. The table is a comparison of integration work, not a ranking of transports.
A small operational contract
Before shipping, test the boundary with a fake transport that produces a timeout after request submission, a 429 with a retry hint, a malformed-recipient rejection, and a successful response carrying a request ID. Assert that each case creates the expected state and that a replay of the same submission does not create a second support ticket. Then inspect a real log record and confirm that it contains enough evidence to trace the decision without exposing the message contents.
The production checklist is short in code but strict in behavior: persist the routing decision before sending, use one stable submission ID, record each attempt separately, classify errors explicitly, honor server-provided retry timing, cap retries, and surface exhausted jobs to a human-owned queue. Add metrics for retryable failures, permanent failures, unknown outcomes, and age of the review queue. Those measurements tell you whether the problem is transport instability, bad form input, or a broken sender configuration.
The conclusion is intentionally narrow. Email APIs can tell you what happened at their request boundary; your application must preserve what happened around it. A support workflow is dependable when its ticket identity, delivery evidence, retry budget, and privacy rules remain clear even during a timeout.
Start there.
References
- RFC 7208, Sender Policy Framework (SPF): https://datatracker.ietf.org/doc/html/rfc7208
- RFC 9110, HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
- MDN,
Retry-Afterheader: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Retry-After - Amazon SES API Reference: https://docs.aws.amazon.com/ses/latest/APIReference/Welcome.html
- SendGrid Mail Send API Reference: https://www.twilio.com/docs/sendgrid/api-reference/mail-send/mail-send
- Mailgun API Documentation: https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/api-reference/