Sending a generated customer-support report from a custom domain is simplest when the application owns the message template and the delivery service owns transport state. Use an HTTP API to submit the attachment, authenticate the sending domain with SPF and DKIM, and consume bounce and suppression state through one machine-readable interface. Do not make SMTP or a webhook your only recovery path.
The decision rule is template ownership. If changing the report email requires editing a dashboard-owned template, the integration has already gained a second deployment system. Keep subject, text, HTML, attachment metadata, and template version in the same repository as the report generator. The delivery layer should accept a complete message and return a stable submission identifier.
What should a custom-domain email deliverability service own?
A customer-support report is application output, not a marketing campaign. Its columns, filename, explanatory copy, and access rules change with the code that generates it. Keeping those pieces together makes a pull request show the whole user-visible change. It also lets a test assert that a CSV attachment named weekly-support-report.csv accompanies the exact subject and body expected for template version 3.
The custom domain still needs operational ownership. SPF publishes which systems may send on behalf of a domain; RFC 7208 also warns that SPF records can trigger at most 10 DNS-querying terms during evaluation. That is a concrete reason to avoid accumulating forgotten senders in one record. DKIM signing belongs in the transport boundary, while the application records which verified domain it intended to use.
Keep these concerns separate:
- The repository owns rendering, attachment bytes, and template versions.
- DNS owners publish and review authentication records.
- The delivery adapter submits mail and normalizes delivery events.
- The support system decides what a bounce or suppression means for a recipient.
Short boundaries beat config sprawl.
The smallest working contract
The useful abstraction is deliberately boring. It contains no SMTP settings and no vendor template identifier. The endpoint is configuration because the interface is the durable part; each adapter translates this contract to its chosen HTTP API.
Start small.
interface ReportMessage {
from: string;
to: string;
subject: string;
text: string;
attachment: {
filename: string;
contentType: "text/csv";
base64: string;
};
metadata: { templateVersion: 3; reportId: string };
}
interface SubmissionReceipt {
submissionId: string;
}
async function sendReport(
endpoint: URL,
token: string,
message: ReportMessage,
): Promise<SubmissionReceipt> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
"idempotency-key": message.metadata.reportId,
},
body: JSON.stringify(message),
});
if (!response.ok) {
throw new Error(`Report submission failed with status ${response.status}`);
}
return (await response.json()) as SubmissionReceipt;
}
The adapter needs a written contract for retries. A timeout does not prove rejection, so retrying without a stable idempotency key can duplicate a report. Conversely, treating every non-success response as retryable can keep submitting an invalid recipient or oversized attachment. Categorize outcomes into accepted, retryable, and terminal; preserve the original submission identifier when the remote API supplies one.
Retries get weird.
I would benchmark this path with time-to-first-accepted-call, but I would also count configuration inputs. Domain, sender, credential, endpoint, and event cursor are understandable. A separate remote template ID per environment is glue, and glue tends to become release work.
Bounces without webhook lock-in
Webhooks are useful for low-latency updates, but they should be an optimization rather than the sole record of delivery state. The application needs a replayable ingestion boundary: a signed webhook can feed it, while a cursor-based event pull or exported event queue can repair gaps. Require stable event IDs, timestamps, recipient identity, submission identity, event type, and a cursor or equivalent replay position.
Suppression is a policy decision above that feed. A permanent delivery failure may block future sends; a temporary failure may schedule a bounded retry. An unsubscribe must remain distinct from a bounce because the reason matters to support agents and to later automation. Store the normalized reason and source event, not just a boolean named suppressed.
A dashboard cannot repair missing history.
Test the ugly sequence: the send call times out, the report is accepted, an event arrives twice, and the webhook arrives after the polling repair job. The expected result is one logical submission and one state transition per unique event. This test reveals more than a happy-path SDK sample.
For API credentials, keep rotation and least-privilege access in the design rather than in a setup note. The NIST authenticator guidance is aimed at digital identity, not transactional email, but its treatment of authenticator lifecycle is a useful reminder that credential issuance, replacement, and revocation need explicit procedures.
What I would change at scale
At low volume, the report job can render, submit, and store the receipt in one worker. At scale, I would put a durable outbox between report generation and delivery. The outbox record carries the report ID, template version, content hash, recipient, and attempt state. Workers may retry transport; they may not silently regenerate different attachment bytes under the same logical ID.
I would also separate domain verification from the hot send path. Deployment should fail its readiness check when the intended sending domain is not verified, rather than discovering a DNS mistake while a support report is due. Monitor authentication status, event-ingestion lag, duplicate-event rate, terminal failures, and suppression growth. Avoid a vanity dashboard with only an accepted count; acceptance is not delivery.
The trade-off is extra storage and a reconciliation worker. That cost buys auditability: a support engineer can connect a generated report to one submission, its attachment hash, and every later event. For sensitive attachments, retention and access controls deserve the same review as the email body. Email is a transport decision, not permission to keep report copies forever.
A compact selection test
Before choosing a delivery layer, implement one adapter and run a small contract suite. Verify that it can submit a complete application-rendered message, attach the generated file, use a custom authenticated domain, return a stable identifier, expose normalized bounce data, and retrieve suppression state by API. Then interrupt event delivery and prove that replay catches up without duplicating transitions.
Reject any option that forces template content into its control plane for this workflow. Also reject an integration whose bounce history exists only in a dashboard or only in an ephemeral webhook attempt. Those constraints move ownership away from the code and make incident recovery depend on manual access.
The final architecture is intentionally plain: application-owned templates, authenticated domain transport, an HTTP submission adapter, and replayable delivery state. It has fewer knobs, but more importantly, each knob has one owner.
Further reading
- RFC 7208, Sender Policy Framework: https://datatracker.ietf.org/doc/html/rfc7208
- NIST SP 800-63B, Digital Identity Guidelines: https://pages.nist.gov/800-63-3/sp800-63b.html
Top comments (0)