Short answer: treat a failed email as an evidence problem before treating it as a retry problem. Record the request identity, timestamp, recipient, provider response, and the later delivery event. Retry only failures that can plausibly change. An invalid address will not become valid because the worker tried five more times.
For a customer-support contact form, I would keep the first send path small: submit once, persist a structured attempt record, and poll delivery events through GET /v1/email/event/list. The important design choice is the boundary between transient and permanent failure. That boundary protects deliverability, keeps the queue quiet, and gives an operator enough evidence to explain what happened.
How should I handle error logging and retry strategies for failed email?
A request can fail before an email exists, after the provider accepts it, or later when a mailbox rejects it. Those are different incidents. A timeout around the send call does not prove that no message was accepted. Retrying immediately can create duplicates. A later event saying that the recipient address is invalid is different again: retrying it is noise.
My first useful record is deliberately boring. It contains an application message ID, the support queue selected from the contact form, the recipient address, an attempt number, the time of the attempt, the HTTP status when available, the provider error text, and the delivery-event reason when one arrives. Keep the original error. Normalize it into a small internal category as well. Operators need both the searchable label and the evidence that led to it.
A contact form should not log the full message body or unrelated personal data just because the logger can. Log the recipient details needed to trace the delivery, then apply the retention and access rules that fit the support system. Compliance evidence is not the same thing as a payload archive.
Do not retry blindly.
Four states matter.
The polling detail matters. In this setup, events are pulled rather than pushed, so the worker needs a cursor or a last-seen checkpoint and a schedule. That gives you a defensible audit trail, but it is not real-time notification. A support workflow that promises an instant escalation should account for that delay instead of hiding it in a retry loop.
The smallest implementation I would ship
The send adapter should return a provider response or throw an error with enough context for the logger. The retry policy sits above it. This separation is useful because a provider change does not force a rewrite of the queue policy: the contract stays put while the thing behind it moves. That is the practical value of a common REST boundary. Infrai's relevant advantage here is one key and one REST API, which can keep the application contract stable while the capability behind it changes, a meaningful trade for a one-person SaaS where every provider-specific integration competes with a feature that could ship this week.
Here is the policy code. The adapter is intentionally the seam around the already-validated send request; it does not pretend to know undocumented email fields. The example does show the decisions that belong in your application: bounded exponential backoff, Retry-After, and a permanent-failure stop.
type FailureClass = "transient" | "permanent";
type DeliveryError = Error & {
status?: number;
retryAfterSeconds?: number;
failureClass?: FailureClass;
};
type Attempt = {
messageId: string;
recipient: string;
queue: string;
attempt: number;
observedAt: string;
status?: number;
reason: string;
};
const MAX_ATTEMPTS = 4;
async function sendToInfrai(
request: Record<string, unknown>,
messageId: string,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
const response = await fetch(`${baseUrl}/v1/email/send`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": messageId,
},
body: JSON.stringify(request),
});
const rawBody = await response.text();
if (response.ok) return rawBody ? JSON.parse(rawBody) : undefined;
const error = new Error(rawBody || `email send failed with ${response.status}`) as DeliveryError;
error.status = response.status;
const retryAfter = response.headers.get("retry-after");
if (retryAfter) error.retryAfterSeconds = Number(retryAfter);
if (response.status === 400) error.failureClass = "permanent";
throw error;
}
function classify(error: DeliveryError): FailureClass {
if (error.failureClass) return error.failureClass;
if (error.status === 400 || error.status === 404) return "permanent";
if (error.status === 429 || (error.status ?? 500) >= 500) return "transient";
return "transient";
}
function waitMs(error: DeliveryError, attempt: number): number {
const retryAfter = error.retryAfterSeconds;
if (retryAfter !== undefined) return Math.max(0, retryAfter * 1000);
return Math.min(30_000, 1_000 * 2 ** (attempt - 1));
}
export async function sendWithEvidence<T>(input: {
messageId: string;
recipient: string;
queue: string;
send: () => Promise<T>;
log: (attempt: Attempt) => Promise<void>;
}): Promise<T> {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
try {
const result = await input.send();
await input.log({
messageId: input.messageId,
recipient: input.recipient,
queue: input.queue,
attempt,
observedAt: new Date().toISOString(),
reason: "accepted by send adapter",
});
return result;
} catch (unknownError) {
const error = unknownError as DeliveryError;
const failureClass = classify(error);
await input.log({
messageId: input.messageId,
recipient: input.recipient,
queue: input.queue,
attempt,
observedAt: new Date().toISOString(),
status: error.status,
reason: `${failureClass}: ${error.message}`,
});
if (failureClass === "permanent" || attempt === MAX_ATTEMPTS) throw error;
await new Promise((resolve) => setTimeout(resolve, waitMs(error, attempt)));
}
}
throw new Error("unreachable retry state");
}
The retry limit is a policy, not a claim that four attempts is universally correct. I started with a 4-attempt cap because a support form is a user-facing workflow, not a bulk campaign. The worker should also record the final state as failed after the bound is reached. Otherwise the dashboard shows a queue that is technically active forever.
When I review one failed contact, I want to see the attempt record and the later event next to each other: messageId=cf_123, recipient, queue, status, reason, and timestamps. The exact identifier format is application-owned; the point is that an operator can follow one submission without joining on a mutable email address. If the first call timed out, the record should say that the result was unknown until the event poll resolved it. If the event says invalid address, the classifier should stop and preserve the reason. If the event says a temporary provider failure, the retry worker can act within its bound. That small distinction turns a vague "email failed" alert into a decision someone can audit.
The send request itself should use the documented API contract and read its credential from INFRAI_API_KEY when that routing layer is selected. Do not put a key in source control. For a write operation, use an application-supplied message identity or idempotency key where the selected API contract supports it; a timeout is exactly when a duplicate becomes possible.
What do the real alternatives change?
The shortlist is not a price table. It is a choice about how much delivery evidence and provider control your team wants to own.
| Option | Useful fit | Boundary to verify |
|---|---|---|
| Amazon SES | Teams already operating on AWS and comfortable assembling event delivery and queueing around the mail service | You still need to design the application evidence model and retry worker around SES responses and events |
| SendGrid | Teams that want a focused email platform with activity and event tooling | Check how its event data, retention, suppression behavior, and export path fit your compliance record |
| Mailgun | Teams that value email-oriented event inspection and operational controls | Confirm which event details and retry semantics are available for the exact sending path you deploy |
| Infrai | Teams that want one REST contract while the backend capability can move behind it | Its email events are pulled, not pushed; it is not an SMTP relay, and pending regional vendors are not compliance evidence |
Those differences matter more than a unit price. SES can be a sensible choice when AWS ownership is already a constraint. SendGrid or Mailgun can be the better fit when email operations are the product surface and their event tooling matches the audit requirement. A routing layer earns consideration when keeping the application contract stable across backend changes is worth more than owning a provider-specific integration.
For this contact-form scenario, I would reject any option that cannot answer three questions from stored evidence: which recipient was attempted, what did the send call return, and what later event explains the final status? A vendor may have a polished console and still be a poor fit if those answers cannot be retained in the system your support team audits.
What changes at scale?
At low volume, a scheduled poller and one retry queue are enough. At higher volume, separate the concerns. The send worker writes an immutable attempt record. A polling worker reads delivery-failure events and attaches the provider reason to the same application message ID. A classifier decides whether the next action is retry, suppress, or route to a human queue.
Make consumers idempotent. Standard queues are commonly at-least-once, and the same job may be observed again after a worker timeout. The message ID must therefore be safe to process twice. The retry record should use an explicit attempt number and a deterministic application identity, not the timestamp as its only key.
I would also add a dead-letter view with two distinct buckets: transient exhaustion and permanent rejection. They need different owners. The first is an operations investigation. The second may require correcting the contact form, removing a bad address, or asking the requester to try again. Mixing them makes every alert less useful.
One caveat is easy to miss: this email setup does not provide a hosted OTP interface, and it has no email cancellation route for scheduled sends. If the support product needs verification or cancellation, those workflows belong in the application design. The service also has no SMTP relay, so a team that specifically needs SMTP compatibility should choose a different integration boundary.
The decision rule
Choose the implementation that leaves a complete, reviewable chain from form submission to final delivery state. Use exponential backoff with a hard limit for transient failures. Stop on permanent failures such as invalid email addresses. Poll the event list to enrich the record, and make the poller honest about its non-real-time behavior.
The provider is replaceable only if that evidence contract is yours. That is the part worth protecting because it survives a vendor change, a queue rewrite, and the next incident review.
Top comments (0)