Choose an email API only if one immutable correlation ID can connect the password-reset request to five records: authorization, token issuance, queue acceptance, provider acceptance, and the final status observation. For an edtech marketplace seller locked out while a new-order notice is waiting, that chain matters more than a polished template editor. Without callbacks, use bounded polling as evidence collection, not as a substitute for delivery truth.
Short answer: keep token creation inside the authentication boundary, place a redacted delivery command in a durable outbox, and require the email service to return a stable message identifier that can be queried later. Record state transitions with timestamps, but never store the reset token or full reset URL in logs. If an API cannot support that evidence chain, it is a poor fit for this workload.
How should auth systems handle custom password email without webhooks?
A reset message sits between security and operations. The application must distinguish five events that are often collapsed into one vague sent flag:
- An authenticated policy decision permitted a reset attempt.
- The auth component issued a single-purpose, expiring token.
- The application committed a delivery command to its queue or outbox.
- The email endpoint accepted that command and returned a message ID.
- A later query observed a terminal or last-known delivery state.
These records answer different questions. An HTTP success response proves that an endpoint accepted a request according to its contract; it does not, by itself, prove that the seller received or opened a message. Likewise, a polling result is an observation at a particular time. Preserve the raw status, observation time, and source alongside the normalized state so an investigator can reconstruct what the system knew then.
The shortlist is often framed as Supabase Auth versus Clerk versus NextAuth, now documented as Auth.js, plus a custom email provider API. Start one level lower. For each auth option, document which component issues the reset token, which component renders the link, and whether the application can obtain a delivery receipt without exposing the secret. Then apply the same evidence test to every candidate. This avoids pretending that unlike integration boundaries are interchangeable products, and it keeps the selection tied to the seller-account workflow rather than a brand checklist.
This is the decision rule I would put in the runbook: reject any design that forces support staff to infer token issuance from an email status, or email delivery from an application log line. The boundaries must remain visible.
Failure signal: a committed reset with no delivery command
Commit the reset request and an outbox row in the same database transaction. A worker then claims the row, calls a generic email interface, and stores the returned message ID. This closes the gap where the auth operation succeeds but the process exits before enqueueing mail. It also gives retries a durable starting point.
The idempotency key should identify one logical delivery command, not one network attempt. Reuse it after a timeout because the remote endpoint may have accepted the first call even though the response was lost. A new reset request gets a new key and should invalidate or supersede earlier tokens according to the authentication system's documented behavior.
package resetmail
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"time"
)
type Command struct {
ResetID string
RecipientID string
Template string
ExpiresAt time.Time
}
type Receipt struct {
MessageID string
Accepted time.Time
}
type Sender interface {
SendReset(ctx context.Context, cmd Command, idempotencyKey string) (Receipt, error)
}
func DeliveryKey(cmd Command) (string, error) {
if cmd.ResetID == "" || cmd.RecipientID == "" {
return "", errors.New("missing delivery identity")
}
sum := sha256.Sum256([]byte(cmd.ResetID + "\x00" + cmd.RecipientID))
return hex.EncodeToString(sum[:]), nil
}
The command contains identifiers and expiry metadata, not the secret. Resolve the reset URL at the narrowest component that needs it, keep it out of metrics labels, and apply the same redaction to errors. The Fetch API documentation is a useful reminder for browser-side integrations: a fulfilled fetch does not mean the server returned a success status, so callers must inspect the response status explicitly. For reset delivery, I still prefer the server-side worker boundary because it avoids exposing service credentials and gives retries one controlled owner.
Operations: observe states without inventing certainty
No callback means the poller owns a small state machine. It should query by the provider's stable message ID, persist each meaningful transition, and stop on a documented terminal state or a local deadline. Use exponential backoff with jitter and a maximum interval. Put a hard cap on attempts so an unknown message cannot occupy the queue forever.
Short gaps are normal.
Never submit a second message.
Treat unknown, not found, transport failure, and rate limiting as separate observations. Do not convert all four into failed; doing so destroys evidence and can trigger a duplicate reset message. A retryable poll error changes when to observe again. It does not authorize another send.
The API evaluation therefore needs a controlled test with at least 6 cases: acceptance followed by a successful lookup, repeated lookup of the same ID, lookup before status data is ready, an invalid ID, throttling, and retention after the reset token has expired. Record the actual response fields and timestamps from the candidate's sandbox or test account. Documentation can define expected behavior, but only the test shows whether your adapter preserves it.
Governance: set evidence boundaries before scoring APIs
Use a scorecard, but make disqualifiers explicit. Stable identifiers, documented status meanings, query retention compatible with the organization's investigation window, and predictable authentication are gates. Template tooling and dashboard ergonomics come later.
| Criterion | Pass condition | Operational reason |
|---|---|---|
| Acceptance receipt | Stable message ID plus timestamp | Connects a send attempt to later observations |
| Status query | Documented lookup by message ID | Supports bounded polling without recipient searches |
| Idempotent submission | Same logical key cannot create uncontrolled duplicates | Makes timeout recovery safe |
| State semantics | Accepted, deferred, delivered, bounced, and unknown are distinguishable where supported | Prevents false claims in audit reports |
| Retention | Measured and documented against the evidence policy | Keeps investigations possible for the required window |
| Data controls | Recipient data and message content have defined handling and deletion rules | Lets security and compliance review the real exposure |
Do not award points for a status label until its meaning is documented. Delivered commonly describes a handoff stage in a mail system, not human readership; the transactional email best-practices guide in the references also separates delivery concerns such as authentication, reputation, bounces, and transactional message handling. Your internal schema should retain the provider term and map it to a deliberately conservative local state.
The cost review belongs here, but it is secondary. Estimate send volume, status-query volume, retention storage, and support labor under the same failure scenarios. A cheap send path that cannot answer a routine investigation has merely moved cost into operations.
The trade-off is real: polling adds read traffic, delayed observations, state storage, and another scheduled workload to operate. I choose it only when callbacks are unavailable and the status query has retention and semantics that meet the evidence policy. This design is a poor fit when the provider cannot return a stable ID, when status records disappear before the required review window, or when the team cannot own a poller. In those cases, keep delivery inside the auth system if its native audit record is sufficient, or select an API with an authenticated event mechanism and verify those events at ingestion. A custom adapter is also the wrong choice for a team that cannot maintain redaction tests as token formats change.
Rollback: preserve receipts before replaying work
Before production, run one synthetic reset recipient through the full path. Assert that no token appears in application logs, traces, metrics, queue inspection, or stored provider responses. Then expire the token and verify that the audit records remain useful without making the credential reusable. Access to those records should be narrower than access to ordinary product analytics.
Deploy the worker and poller behind separate controls. Start with a small traffic slice, watch outbox age, claim latency, submission attempts, poll lag, terminal-state counts, and the age of the oldest unresolved message. Alert on backlog age and evidence gaps, not on a single transient request error. The latter is noise; the former threatens the runbook.
Rollback must stop new dispatches without deleting outbox rows. Drain or release worker leases, preserve message IDs already received, and let the previous adapter resume only after it can read the same normalized records. Never roll back by replaying every nonterminal row as a new email. First reconcile acceptance receipts, because a timeout may hide a successful submission.
After an incident, the review should be able to reconstruct one seller's reset attempt without opening the message body: policy decision, queue commit, attempt history, remote acceptance, every observation, and the final local classification. If any link depends on an operator remembering which dashboard they checked, the API selection is unfinished.
References
- Postmark, "Transactional Email Best Practices": https://postmarkapp.com/guides/transactional-email-best-practices
- MDN Web Docs, "Fetch API": https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
Top comments (0)