Short answer: when comparing Resend with an alternative transactional email API for Europe, own the password-reset template, its data contract, and its tests in your application; let the delivery API own transport. That boundary makes a GDPR and custom-domain review useful because you can judge processing terms, regional handling, and operational behavior without rewriting the message for every candidate. An apparently cheaper service is weak leverage if welcome emails and security messages can only be edited in its dashboard.
For a B2B SaaS reset flow, the message has one job: deliver a single-use link before its short expiry. The application creates the reset record, renders an immutable template version, submits a generic message, and records the provider-neutral result. The delivery adapter stays thin. This is the design decision I would make before opening any pricing page.
What should a cheaper alternative transactional email API own?
A hosted template looks convenient during the first integration. Later, it can split the release into two control planes: application code changes in one place while subject lines, variables, and HTML change somewhere else. Review becomes harder because the exact message sent for a given commit is no longer visible in that commit. A provider move also becomes a content migration.
Keep the source template beside the reset-flow code. Pin a version such as password-reset.v3, define the allowed variables, and render both plain text and HTML before calling the adapter. The provider then receives ordinary message fields rather than a proprietary template identifier.
One repository. One review path.
The portable asset is the rendering contract, not the HTTP request. Each delivery service will have its own authentication, error model, event schema, and account controls. Those details belong behind an adapter and in the evaluation notes, not inside the business template.
Put the runnable boundary in code first
The application should pass an already rendered message to a narrow interface. This example leaves token creation and persistence outside the mailer: the reset service must create and store the single-use secret before delivery is attempted.
interface TransactionalMessage {
from: string;
to: string;
subject: string;
text: string;
html: string;
messageKey: string;
tags: Record<string, string>;
}
interface DeliveryReceipt {
accepted: boolean;
providerMessageId?: string;
retryable: boolean;
}
interface EmailTransport {
send(message: TransactionalMessage): Promise<DeliveryReceipt>;
}
type ResetTemplateData = {
resetUrl: string;
expiresInMinutes: number;
workspaceName: string;
};
function renderPasswordReset(
data: ResetTemplateData,
): Pick<TransactionalMessage, "subject" | "text" | "html"> {
const subject = `Reset your ${data.workspaceName} password`;
const expiry = `${data.expiresInMinutes} minutes`;
return {
subject,
text: [
`Use this link to reset your password: ${data.resetUrl}`,
`The link expires in ${expiry}.`,
"If you did not request this, you can ignore this message.",
].join("\n\n"),
html: [
"<p>Use this link to reset your password:</p>",
`<p><a href="${escapeAttribute(data.resetUrl)}">Reset password</a></p>`,
`<p>The link expires in ${expiry}.</p>`,
"<p>If you did not request this, you can ignore this message.</p>",
].join(""),
};
}
function escapeAttribute(value: string): string {
return value
.replaceAll("&", "&")
.replaceAll("\"", """)
.replaceAll("<", "<")
.replaceAll(">", ">");
}
async function sendPasswordReset(
transport: EmailTransport,
input: {
userId: string;
email: string;
resetUrl: string;
expiresInMinutes: number;
workspaceName: string;
},
): Promise<DeliveryReceipt> {
const rendered = renderPasswordReset(input);
return transport.send({
from: "Account Security <security@notify.example.com>",
to: input.email,
...rendered,
messageKey: `password-reset:${input.userId}`,
tags: { flow: "password-reset", template: "password-reset.v3" },
});
}
One trap is visible here: HTML escaping is context-specific. Escaping an attribute does not make an arbitrary URL trustworthy. Build the reset URL from an application-controlled origin, allow the expected HTTPS scheme, and place only the opaque token in its intended parameter. The template renderer should never accept a complete link supplied by a browser.
I initially reach for a snapshot test for both bodies because it ships quickly. The trade-off is that snapshots are too forgiving when someone updates them blindly, so I would assert the security-relevant facts separately: the expiry appears, the reset URL appears once, the plain-text body exists, and no unexpected template variable survives rendering. Small tests. High value.
Compare evidence, not feature-page checkmarks
A neutral comparison starts with the same trial message and the same questions for every candidate. For European personal data, record the actual controller-processor arrangement, subprocessors, transfer mechanism where relevant, retention controls, and the regions used by message processing, logs, and support access. GDPR Article 28 describes required processor contract terms; Articles 44 through 49 govern transfers of personal data to third countries. A vague claim of readiness does not answer either review.
Custom-domain support also needs evidence. SPF publishes which hosts are authorized to use a domain in the SMTP MAIL FROM identity, as specified by RFC 7208. It is one part of domain authentication, not a complete deliverability verdict. During a trial, capture the DNS records the service asks for, verify them independently, and inspect the received message headers. Do this on the exact subdomain intended for transactional traffic.
Use a compact scorecard that preserves hard distinctions:
| Decision area | Evidence to collect | Reject when |
|---|---|---|
| Template ownership | The API accepts rendered text and HTML without a hosted template | Core copy or variables must live only in a dashboard |
| Data handling | Contract terms, subprocessors, transfer basis, retention, and processing locations | The legal or operational path cannot be documented |
| Domain control | Required DNS records and received-header results on the sending subdomain | Authentication cannot be verified on the custom domain |
| Failure behavior | Structured errors, retry guidance, duplicate controls, and event semantics | Temporary and permanent failures cannot be distinguished |
| Operations | Log retention, export path, alert inputs, and suppression handling | The team cannot investigate a delayed reset safely |
| Cost model | Included event traffic, retention, infrastructure, and volume tiers | A necessary control makes the expected bill unknowable |
This table does not produce a universal winner. It exposes which service fits your constraints. A small SaaS may accept shorter log retention to reduce the amount of personal data held, while a regulated buyer may require a longer audit trail. Those are different operating choices, not a leaderboard.
The application-owned template approach has a real limitation: teams whose copy changes several times a day under non-engineering ownership may prefer a reviewed hosted-template workflow. Keeping HTML in the repository also means the application team owns rendering tests, previews, localization files, and release coordination. I accept that work for password resets because the copy is compact, security-sensitive, and changed rarely; I would revisit the decision for a large multilingual welcome campaign managed by a lifecycle team. This is a boundary, not a universal rule.
Treat retries as a security and latency problem
A password reset is time-sensitive, so queueing and retries need an expiry-aware policy. Do not keep retrying after the reset record expires. Do not generate a fresh token inside a delivery retry, either; one user action should map to one reset record and one stable message key. If the transport reports an ambiguous timeout, retry only through a mechanism whose duplicate behavior you have tested.
Keep provider event payloads at the adapter boundary. Convert them into a small internal vocabulary such as accepted, delivered, temporarily delayed, permanently failed, and complained, while retaining the original event in restricted storage for investigation. The mapping must be documented because providers do not promise identical event meanings. A delivery event also does not prove that a human read the message.
The user-facing reset endpoint should resist account discovery: return the same public response regardless of whether an address exists. Internally, metrics still need enough separation to detect trouble. Track request-to-accept latency, accept-to-delivery-event latency where available, permanent failures, retry counts, and resets that expire before a successful delivery event. Avoid putting email addresses or reset tokens in metric labels.
Two clocks matter. The reset record has a security expiry, and the delivery operation has a much shorter usefulness window. Once the latter closes, alert the flow owner or offer the user a controlled way to request another reset; a queue that eventually sends expired links is technically busy and operationally useless.
Expired means stop.
Ship the migration before choosing a winner
Run the candidate adapter against a non-production subdomain, then send the same locally rendered fixture through each transport. Compare received content byte-for-byte where practical, except for transport-added headers and link transformations you explicitly allow. Exercise a valid address, a controlled rejection, an induced timeout, and a duplicate submission. Record what happened rather than relying on documentation adjectives.
Next, deploy the new adapter behind a configuration switch with no template changes. Keep one internal message schema and one event schema. A rollback then changes transport selection; it does not reconstruct copy, import remote template versions, or ask support to recover an old dashboard state.
The final operational check is prose-sized because the workflow is small: verify SPF and the other domain records requested by the chosen service, confirm the processor paperwork and data path, render version password-reset.v3 in CI, send a canary, inspect the received headers and both bodies, test permanent and temporary failure mappings, confirm that logs contain neither tokens nor full message bodies, and alert when delivery usefulness approaches the reset expiry. Only after those checks should traffic move. Price can break a tie, but template control, documented data handling, and observable failure behavior decide whether the integration remains operable.
Top comments (0)