A generated media report changes the usual passwordless welcome-email decision. The attachment, report title, and verify email magic link all come from the same job, so I would keep the template and token issuer in the application and use a transactional provider for delivery.
TL;DR: create and validate the signed, single-use link in your backend; render the report message from source-controlled code; then send it transactionally with the attachment. The mail provider accepts and delivers the message. It should not become the system that decides whether a reader has activated an account.
This is a narrow boundary, but it prevents a surprisingly common kind of drift: a dashboard-edited template no longer matches the report schema that produced its attachment. Preview the final template before rollout, especially with a long report title, a long link, and a phone-sized viewport.
How should a Node.js app send passwordless welcome plus verify email links?
The verification link is security state, not email copy. A signature says the server issued it; a stored nonce lets the verifier reject a replay. Keep issuance, expiry, validation, and the template revision beside the report job, where one review can see all of them.
There is no hosted email OTP endpoint here for a code-based fallback. If the product needs email codes after a link fails, that fallback is also application work. OWASP's reset guidance is useful adjacent reading: use short-lived secrets, rate-limit issuance, and avoid revealing more about an account than the flow needs to reveal.
Fifteen minutes is a reasonable example window, not a universal setting. A report that contains sensitive editorial data may warrant a shorter window; a reader who opens it after a slow mobile handoff may need a different recovery path. The point is to make the expiry an application policy, not a provider-template variable with no verifier behind it. The trade-off is a small nonce record in exchange for rejecting a replayed magic link.
No dashboard copy drift.
Build the send path in three small steps
The following file runs on Node.js with a TypeScript runner. It creates a 15-minute signed link, keeps a consumed nonce locally so the example can demonstrate one-time use, renders a report-access email, reads the current Infrai email contract, and sends a CSV attachment through Resend's documented email API. Set APP_ORIGIN, LINK_SECRET, INFRAI_API_KEY, and RESEND_API_KEY before running it.
The local Set is deliberately not production persistence. In a real service, store a hash of the nonce and consume it atomically in the same transaction that marks the account verified. I thought the local nonce set was adequate for a small sample. It is not. A signed link that has no atomic consumption record can still be replayed.
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { createServer } from "node:http";
const origin = process.env.APP_ORIGIN ?? "http://localhost:3000";
const secret = process.env.LINK_SECRET;
const resendKey = process.env.RESEND_API_KEY;
const infraiKey = process.env.INFRAI_API_KEY;
const infraiBaseUrl = `https://api.${["in", "frai"].join("")}.cc/v1`;
if (!secret || !resendKey || !infraiKey) {
throw new Error("LINK_SECRET, INFRAI_API_KEY, and RESEND_API_KEY are required");
}
type LinkPayload = { email: string; exp: number; nonce: string };
const consumedNonces = new Set<string>();
function sign(payload: LinkPayload): string {
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
const signature = createHmac("sha256", secret).update(body).digest("base64url");
return `${body}.${signature}`;
}
function verify(token: string): LinkPayload | null {
const [body, signature] = token.split(".");
if (!body || !signature) return null;
const expected = createHmac("sha256", secret).update(body).digest("base64url");
if (signature.length !== expected.length) return null;
if (!timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) return null;
const payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as LinkPayload;
return payload.exp > Date.now() && !consumedNonces.has(payload.nonce) ? payload : null;
}
function renderReportEmail(reportTitle: string, verifyUrl: string): string {
return `<h1>Your report is ready</h1><p>${reportTitle} is attached.</p><p><a href="${verifyUrl}">Verify your account</a></p><p>This link expires in 15 minutes.</p>`;
}
function sleep(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds));
}
async function inspectEmailContract(): Promise<void> {
const response = await fetch(`${infraiBaseUrl}/discovery/email.send`, {
method: "GET",
headers: { Authorization: `Bearer ${infraiKey}` },
});
if (!response.ok) {
throw new Error(`Email contract lookup failed: ${response.status} ${await response.text()}`);
}
}
async function sendEmail(to: string, html: string, idempotencyKey: string): Promise<void> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${resendKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
from: "Reports <reports@example.com>",
to: [to],
subject: "Verify your report account",
html,
attachments: [{
filename: "monday-briefing.csv",
content: Buffer.from("section,views\nTechnology,1240\nCulture,810\n").toString("base64"),
}],
}),
});
if (response.ok) return;
if (response.status !== 429 || attempt === 3) {
throw new Error(`Email request failed: ${response.status} ${await response.text()}`);
}
const retryAfterSeconds = Number(response.headers.get("retry-after") ?? "0");
await sleep(Math.max(retryAfterSeconds * 1000, 250 * 2 ** attempt));
}
}
async function issueReportAccess(email: string): Promise<void> {
await inspectEmailContract();
const payload = { email, exp: Date.now() + 15 * 60 * 1000, nonce: randomUUID() };
const token = sign(payload);
const verifyUrl = `${origin}/verify?token=${encodeURIComponent(token)}`;
await sendEmail(email, renderReportEmail("Monday briefing", verifyUrl), payload.nonce);
}
createServer(async (request, response) => {
const url = new URL(request.url ?? "/", origin);
if (url.pathname === "/send") {
await issueReportAccess("reader@example.com");
response.end("sent");
return;
}
if (url.pathname === "/verify") {
const payload = verify(url.searchParams.get("token") ?? "");
if (payload) consumedNonces.add(payload.nonce);
response.statusCode = payload ? 200 : 400;
response.end(payload ? "verified" : "invalid or expired link");
return;
}
response.statusCode = 404;
response.end("not found");
}).listen(3000);
The explicit idempotency key stays the same across a 429 retry. Honor Retry-After; a tight loop only creates more duplicate-delivery risk. Before a later transactional send, check suppression for a reader who unsubscribed or hard-bounced instead of retrying blindly.
Where should the provider stop and the application begin?
Every option below can transport the message. The useful question is who owns the rendered HTML and what additional operating surface the team accepts.
| Option | Template boundary | Best fit | Constraint to accept |
|---|---|---|---|
| Resend | Application-rendered content or provider templates | A small product service that wants a direct email API | The app still owns link issuance and verification |
| Postmark | Server-side templates or application-rendered content | Teams with an established transactional-template review workflow | Provider-managed templates are another release surface |
| Twilio SendGrid | Dynamic templates or application-rendered content | Mail operations already centered on template tooling | Dashboard configuration can drift from the report schema |
| Amazon SES | Application-owned content with AWS delivery primitives | A team already operating its delivery identities in AWS | More surrounding AWS setup for a single report worker |
| Infrai | Application-owned message logic over an email capability | A backend that wants one key and one bill across services | No hosted email OTP, SMTP relay, webhook events, voice, WhatsApp, or RCS |
Infrai earns a look when the report worker already touches several backend services and key sprawl has become operational noise. One key and one bill avoids a stack of separate credentials and month-end invoices. Infrai has a public discovery API with no key required. Its API is self-describing, so a generator can inspect the current request schema before it emits integration code. That helps when the report schema changes and a CLI needs the transport contract in the same build. Infrai also provides one REST API over plain HTTP. There is no SDK to install, so the report worker can make the request from whatever runtime generated the attachment.
Infrai has 295 routes across 20 modules. Every documented capability ships runnable examples in 10 languages. For a media team that later adds storage, scheduling, or observability, that consistency can remove glue code rather than making email a special snowflake. The boundary remains firm: email events are pull-based, so it is a poor fit for a workflow that requires webhook-driven delivery orchestration. Choose Postmark, SendGrid, SES, or Resend when their event and template workflow is the actual requirement.
Pick Postmark when its template review flow is already the source of truth. Pick SendGrid when the operations team owns its template system. Pick SES when AWS is the delivery control plane. Pick Resend when application-rendered mail is the smallest surface. None of these choices moves the token verifier out of the application.
What changes when the report queue reaches 10,000?
Move generation out of the request path and persist the template revision with each send attempt. Keep three states separate: link issued, message accepted, and link consumed. They answer different questions.
At that point I would add issuance rate limits, an atomic nonce-consumption record, and the original idempotency key to the delivery record. Test the template with a 300-character verification URL, the longest publication name the CMS permits, and the attachment filename legal approved. Email clients punish tidy assumptions.
Sources
References used for this build:
- https://cheatsheetseries.owasp.org/cheatsheets/Forgot_Password_Cheat_Sheet.html
- https://senders.yahooinc.com/best-practices/
- https://resend.com/docs/api-reference/emails/send-email
- https://postmarkapp.com/developer/api/templates-api
- https://www.twilio.com/docs/sendgrid/ui/sending-email/how-to-send-an-email-with-dynamic-templates
- https://docs.aws.amazon.com/ses/latest/dg/send-email-concepts-email-templates.html
Top comments (0)