Short answer: keep password-reset token creation and validation in the authentication system, call a custom email provider API only when the added delivery control earns its integration cost, and treat polling events without webhooks as an observability path rather than part of the reset flow.
For a one-person healthtech marketplace, that boundary protects the work that matters: a seller can recover access, see a new order, and get back to serving a buyer. It also keeps the weekly shipping cadence intact. The provider name is secondary. The integration contract is the decision.
Supabase Auth, Clerk, and NextAuth may all appear in an initial search, but a useful selection does not begin with a logo grid. It begins with ownership: which component issues the reset token, which component renders and sends the message, and which component records delivery evidence.
Start with ownership, not a vendor shortlist
A password-reset email has two jobs that are easy to blur together. The authentication layer creates a time-bound recovery action and decides whether it is valid. The delivery layer accepts message data, attempts delivery, and may expose later events. Keep those responsibilities separate. A delivery event must never decide whether a reset link is valid, and an email provider should not become the source of truth for account recovery.
This sounds fussy until the first integration decision. Built-in mail is the smallest surface: fewer credentials, fewer calls, and less code to operate. A custom provider API adds template control and a place to inspect delivery activity, but it also adds authentication, retry policy, idempotency, event ingestion, and data-retention questions. Polling adds a scheduled worker and a cursor. Every box costs revenue-producing hours.
The practical rule is blunt. Use the auth system's built-in path while it meets the product's real requirements. Move delivery behind a small interface when message control or operational evidence has become a requirement, not a hypothetical preference. Outsource the undifferentiated work, but keep the interface under your control.
Don't migrate merely because a provider comparison page has more checkmarks.
The catch is that the smallest integration is not suitable when support needs delivery evidence, the healthtech marketplace has strict message-content review, or one sending contract must serve several authentication systems. In those cases, a custom adapter can be worth its maintenance cost. The reverse is also true: stick with built-in delivery when nobody will operate polling, investigate events, or maintain the extra credential. An unused observability pipeline is only inventory.
What changed the choice for a healthtech marketplace?
The concrete constraint is integration effort around a seller's account-recovery path. A new order notification is commercially urgent, but it cannot help a seller who is locked out. That makes password-reset delivery important without making email-event data part of authentication itself.
Write down the decision before writing code. This compact matrix is enough for a first pass:
| Option | Integration work | Control gained | Poor fit |
|---|---|---|---|
| Auth-managed email | Lowest: configure and test the existing path | Limited to the auth system's exposed controls | A team that needs its own delivery-event record |
| Custom email API | Adapter, secret handling, templates, retries, and tests | A stable application-owned sending boundary | A solo product with no time to operate another dependency |
| Custom API plus polling | Everything above, plus a cursor and scheduled reconciliation | Periodic delivery evidence when webhooks are unavailable | Workflows that require immediate event notification |
There is no universal winner. The last row is especially easy to overbuy. If the product only needs a dependable reset request followed by a clear in-app confirmation, polling may create more integration work than useful information. If support regularly needs to distinguish accepted, delivered, and failed messages, periodic reconciliation has a concrete operator and a concrete purpose.
I'm not sure what event vocabulary a chosen provider exposes until its current API contract is checked. Your mileage may vary here. Normalize only the states the application actually uses, preserve the provider's raw event type for investigation, and avoid pretending that unlike states from different systems mean the same thing.
The smallest working Node.js implementation
The application needs one narrow port. The auth handler supplies a reset URL created by the authentication system; the mail adapter supplies transport. The example below deliberately uses a configured endpoint instead of an invented vendor route. It relies on the standard Fetch API, checks the HTTP response, and uses an idempotency key generated for this send attempt.
interface PasswordResetMessage {
recipient: string;
resetUrl: string;
requestId: string;
}
interface EmailReceipt {
messageId: string;
}
interface EmailTransport {
sendPasswordReset(message: PasswordResetMessage): Promise<EmailReceipt>;
}
class ApiEmailTransport implements EmailTransport {
constructor(
private readonly endpoint: string,
private readonly apiKey: string,
) {}
async sendPasswordReset(
message: PasswordResetMessage,
): Promise<EmailReceipt> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
authorization: `Bearer ${this.apiKey}`,
'content-type': 'application/json',
'idempotency-key': message.requestId,
},
body: JSON.stringify({
template: 'password-reset',
to: message.recipient,
variables: { resetUrl: message.resetUrl },
}),
});
if (!response.ok) {
throw new Error(`Email API returned HTTP ${response.status}`);
}
const receipt = (await response.json()) as EmailReceipt;
return receipt;
}
}
This is an application-side contract, not a claim that every provider accepts those fields or headers. Map it to the selected API at the adapter boundary. That distinction matters — copying a fictional universal endpoint into production is faster for ten minutes and slower for the rest of the week.
Keep secrets out of browser code. Log the requestId and returned messageId, but don't log the reset URL or its token. Render message copy from reviewed templates. The reset request should return a neutral user-facing response regardless of whether an account exists; exact account-disclosure behavior belongs to the authentication system's security design, not the email adapter. Test the boundary with a fake EmailTransport. One test verifies that the auth-created URL reaches the template variables. Another verifies that a non-success HTTP response becomes a controlled failure. A third verifies that repeated work carries the same idempotency key. Those three tests buy more confidence than a broad provider abstraction nobody has used. Then test one real message in a non-production environment. Inspect the sender, recipient, subject, link destination, mobile rendering, and expiry behavior. Transactional email guidance emphasizes clear identification, focused content, and deliberate handling of delivery; the useful standard is what the recipient can understand and act on, not how elaborate the template looks.
Ship the narrow version.
How should Node.js poll password reset email provider events without webhooks?
Polling is a reconciliation loop. Run it on a schedule, request events after the last committed cursor or timestamp, normalize the few fields the application needs, upsert by a stable event identifier, and advance the cursor only after the batch is stored. The polling worker can lag without blocking a seller from requesting another reset. That separation is the whole design.
The provider's documented event endpoint, method, pagination rules, and rate limits must be substituted at integration time; there is no honest universal route. The generic worker can still be precise about its local contract:
type DeliveryState = 'accepted' | 'delivered' | 'failed' | 'unknown';
interface DeliveryEvent {
eventId: string;
messageId: string;
state: DeliveryState;
occurredAt: string;
rawType: string;
}
interface EventPage {
events: DeliveryEvent[];
nextCursor: string | null;
}
interface DeliveryEventSource {
listAfter(cursor: string | null): Promise<EventPage>;
}
interface EventStore {
readCursor(): Promise<string | null>;
upsert(events: DeliveryEvent[]): Promise<void>;
commitCursor(cursor: string): Promise<void>;
}
async function reconcileDeliveryEvents(
source: DeliveryEventSource,
store: EventStore,
): Promise<void> {
let cursor = await store.readCursor();
for (;;) {
const page = await source.listAfter(cursor);
await store.upsert(page.events);
if (page.nextCursor === null) return;
await store.commitCursor(page.nextCursor);
cursor = page.nextCursor;
}
}
Keep the loop boring. A 401 means credentials or scope need attention; a 429 means the scheduler must respect the API's retry instructions and reduce pressure. Fetch resolves when an HTTP response arrives even when the status is an error, so checking response.ok in the concrete adapter is mandatory. Network failures need bounded retries with delay. Duplicate pages must be harmless because upsert uses eventId.
A cursor is preferable when the provider contract offers one because it names a position in that contract. If only timestamps are available, poll with a small overlap and deduplicate; timestamps can share a value, and a strict greater-than query can skip records at the boundary. The exact overlap is an operating choice based on documented ordering and event delay. Do not invent it from instinct.
There is one more trade-off. Polling is not suitable when downstream action must happen immediately after delivery. Use a documented push mechanism when low-latency events are a genuine requirement. Stick with polling when delayed operational visibility is acceptable and the team can tolerate scheduled API traffic. For password resets, the user action remains the source of truth: the link is either accepted or rejected by the auth system.
What I would change at scale
At low volume, one adapter, one scheduled worker, and one event table are enough. At higher volume, split sending from the request handler with a durable queue, cap retry attempts, add a dead-letter review path, and expose two operational measures: age of the oldest unsent message and age of the polling cursor. Those measures describe work the operator can act on.
I would also separate account-recovery mail from new-order notifications at the policy layer even if they share transport. They have different templates, urgency, and product consequences. A backlog of order notifications should not consume every worker slot while someone waits for account recovery. This is an architectural priority rule, not a reason to duplicate the entire email stack.
Review the provider choice when requirements change: another auth system is added, support begins using delivery history, polling load approaches documented limits, or message review becomes a release bottleneck. Until then, keep the boundary small and ship weekly. The best selection is the one whose operational burden matches the evidence the business actually needs.
Top comments (0)