Short answer: choose the email API whose template contract accepts a small, versioned view model and leaves authorization, attachments, and retries in your Node.js application.
The integration constraint changes the answer. For a one-person SaaS, I would choose an email API with template support only if its contract lets the application keep ownership of security, data selection, and retry policy. The API should receive a small, named view model. It should not receive a user object or a blob of HTML.
That same boundary works for the concrete job here: send a generated gaming report as an email attachment. Password reset mail and report mail have different risk profiles, but both benefit from a narrow adapter and a worker that can be tested without calling an external service. The goal is integration effort, not a feature-count contest.
Keep it boring.
That is the point.
What should the application own?
The application creates intent. For a password reset, it creates a short-lived reset URL and decides who may receive it. For a gaming report, it decides which match data is safe to disclose, generates the file, and chooses whether the file belongs in the message at all.
The template owns presentation. Its input can be as small as resetUrl, expiresInMinutes, and productName. A report template might receive matchName, winner, and reportFilename. Keeping those fields explicit makes review easier. It also keeps a copy edit from becoming an accidental data export.
Here is the interface I would put between the Node.js application and any email API:
type EmailMessage = {
to: string;
templateId: string;
variables: Record<string, string>;
attachment?: {
filename: string;
contentBase64: string;
contentType: string;
};
};
type DeliveryResult = {
accepted: boolean;
messageId?: string;
retryable: boolean;
};
interface EmailTransport {
send(message: EmailMessage): Promise<DeliveryResult>;
}
This is deliberately boring. The rest of the product should not know the provider's request shape, response codes, or template editor vocabulary. A transport adapter translates this contract once. That is a good place to spend an hour because it protects every future hour spent changing providers or revising the report workflow.
Which template contract survives a password reset?
The important question is not whether a service has templates. It is whether a deployment can identify the exact template revision and test its output.
I first thought the template identifier was the whole integration decision. It is not. The variable schema, event contract, and retry behavior decide how much code remains in the product after the first successful send.
For reset mail, I would keep a fixture with a fake URL and a fake expiry. The test should require the reset link, require the expiry text, and reject unexpected fields. The token itself still belongs to the application. It should be generated, scoped, and expired there; a template system should never decide whether a reset is authorized.
The same test style catches a quieter gaming failure. Suppose a report contains player names, scores, and a generated file. A broad template payload can accidentally expose internal identifiers or moderation notes. A typed view model makes that mistake visible during review. It also gives a solo operator a stable seam for weekly releases.
Versioning has a trade-off. A hosted template editor can reduce copy-editing work, while a checked-in renderer gives tighter review and deployment control. Neither removes responsibility for the final message. I would choose the option that makes the revision visible in CI and in the delivery record, even if that means less convenient editing.
How do I send an attachment without tying up the request?
The request handler should create a job and return. A worker can load an approved view model, render the template, prepare the attachment, call the transport, and record the result. A password reset should follow the same pattern, with its own purpose and urgency policy.
type ReportEmail = {
recipient: string;
reportFilename: string;
reportContentBase64: string;
reportContentType: string;
matchName: string;
winner: string;
};
async function deliverReportEmail(
transport: EmailTransport,
input: ReportEmail,
): Promise<DeliveryResult> {
return transport.send({
to: input.recipient,
templateId: "gaming-report-v1",
variables: {
matchName: input.matchName,
winner: input.winner,
reportFilename: input.reportFilename,
},
attachment: {
filename: input.reportFilename,
contentBase64: input.reportContentBase64,
contentType: input.reportContentType,
},
});
}
The attachment boundary needs its own checks. Set the filename and media type explicitly. Validate the size before handing the file to the transport. Remove temporary material according to the job's retention policy. For a large report, an authenticated download link is usually a better product decision than making email act like file storage.
The worker also needs an attempt record. Store the internal job ID, purpose, template ID, attempt count, and any provider message ID returned by the adapter. Do not store reset tokens as ordinary operational logs. A duplicate delivery event must not send a second message or mark the same report as failed twice.
What fails first in a small implementation?
The first shortcut is a shared HTML string. It mixes copy, escaping, security links, and transport details. It is fast on day one and expensive when a report attachment or a second message type arrives.
That cost hides in the unhappy path. A generated report can fail after the file is created but before the message is accepted. A reset message can be accepted while its worker later receives a transient event. If both flows share one unstructured payload and one retry loop, the operator cannot tell whether the problem is the recipient, the template, the attachment, or the transport. A small adapter does not remove those states; it gives each state a name, a place to be recorded, and a policy that can be tested without waiting for a real inbox.
The second shortcut is a synchronous send in the password-reset route. A connection problem then holds open the request that should finish the security flow. A queue adds a worker and replay state, so it is not free. For account recovery, that operational cost is easier to reason about than making external delivery part of request latency.
Retry rules should live with the worker. A malformed recipient is a permanent failure. A temporary connection failure may be retried. A rate limit needs delay and a bounded attempt count. Those categories are application decisions, not reasons to blindly repeat every response.
I would test three outcomes before shipping: accepted by the transport, rejected permanently, and retryable. I would also inspect one rendered reset message and one three-file report message in a test environment. Check the plain-text alternative, displayed filenames, media types, link destinations, and visible sender behavior. SPF describes how a receiving system evaluates authorized sending hosts; it does not replace DKIM, DMARC, or abuse controls.
A delivery dashboard should distinguish accepted, delivered, bounced, and expired jobs. One number is not enough. The useful alert is usually a change in failure rate or processing age, because those signals point to an action a solo operator can take.
What changes when volume grows?
At low volume, one worker, one adapter, and a small attempt table are enough. The integration stays cheap because the provider-specific surface is isolated.
As volume grows, I would separate security mail from report mail. A batch of generated reports should not delay account recovery. I would add purpose-specific rate limits, a dead-letter path for inspection, and retention rules for message metadata. I would also make template contracts part of CI: render fixed fixtures, require the intended links, validate plain text, and reject unknown variables.
The decision rule is straightforward: select the template API that minimizes the code your product must own while leaving authorization, data shaping, attachment policy, and failure recovery in your code. That keeps the email integration replaceable and leaves the scarce hours for features that produce revenue.
Top comments (0)