Short answer: for a property-management signup flow, choose the password reset email integration that leaves one component in charge of token creation and redemption, lets the application submit mail through a narrow provider-neutral adapter, and exposes enough delivery state to investigate a missing verification link without making a webhook mandatory.
Integration effort is the deciding constraint here, but counting setup steps gives the wrong answer. The expensive boundary is ownership: which system creates the secret, which one renders user-controlled text, which one sends the message, and which one can say what happened afterward. A direct API can look wonderfully small until two authentication systems both believe they own the reset URL. Then the team has fewer lines of integration code and a much larger security problem.
For a property manager waiting to invite a tenant, the useful outcome is plain: the intended recipient gets one valid link, in the expected language, and support can distinguish “accepted for delivery” from “used to finish signup.” Those are different states. Keep them different.
What must the property signup boundary own?
Start by drawing a line around the authentication authority. Supabase Auth, Clerk, and Auth.js (formerly NextAuth) appear together in many shortlists, but a product-name comparison skips the decisive question: does the existing authentication layer own the recovery token and redirect validation, or does the application? The answer determines the viable integration, regardless of which mail transport eventually carries the message.
The reset secret should have a single issuer and a single redemption authority. Passing an opaque link to a mail adapter is a smaller and more auditable contract than asking that adapter to understand accounts, leases, roles, or token rules. The adapter needs delivery fields: a recipient, a template key, template variables, an idempotency key, and a correlation identifier. It does not need the tenant record or a database session.
That division also limits data copied across systems. A property signup may contain unit, building, manager, and applicant details, yet the delivery request usually needs far less. Put only the display name and verification URL into the template variables when those fields are required. Treat everything else as unnecessary exposure — especially internal property identifiers that would add no value to the recipient.
Here is the contract I would evaluate before reading any provider-specific quickstart. The values are illustrative application data, not a vendor wire format:
from dataclasses import dataclass
from datetime import datetime
from typing import Mapping, Protocol
@dataclass(frozen=True)
class VerificationMessage:
recipient: str
template_key: str
variables: Mapping[str, str]
idempotency_key: str
correlation_id: str
expires_at: datetime
@dataclass(frozen=True)
class SubmissionReceipt:
provider_message_id: str
accepted_at: datetime
class MailTransport(Protocol):
def submit(self, message: VerificationMessage) -> SubmissionReceipt:
...
Notice what the receipt does not claim. “Accepted” is not “delivered,” and neither is “verified.” The authentication authority records redemption; the mail side records submission and whatever delivery evidence its interface makes available. Collapsing those facts into a single sent boolean is a common failure mode because it gives support a confident answer that the system never actually observed.
How should a custom password reset email API work without webhooks?
A custom password reset email API without webhooks needs two independent loops: an immediate submission path for the user request, and a bounded polling path for delivery evidence. The signup request should not wait for a final mailbox outcome. It should create or request the authentication link, submit the message once under an idempotency key, persist the provider message identifier, and return the same neutral response the application uses for account-recovery requests.
Polling belongs outside the request path. Schedule it only for records whose delivery state is still unresolved, stop it when the configured observation window closes, and retain the last evidence rather than manufacturing certainty. I’m not sure there is one defensible polling interval for every provider because the supplied interface, quota, and event-retention details decide that value; the selection review must obtain those three facts from current documentation and a sandbox test. Your mileage may vary. The architecture should therefore store polling policy as configuration, not bury it in the transport adapter.
A small state machine is enough:
from dataclasses import dataclass, replace
from datetime import datetime
from enum import Enum
class DeliveryState(str, Enum):
SUBMITTED = "submitted"
DELIVERED = "delivered"
REJECTED = "rejected"
UNKNOWN = "unknown"
@dataclass(frozen=True)
class DeliveryRecord:
correlation_id: str
provider_message_id: str
state: DeliveryState
observed_at: datetime
poll_count: int
def apply_observation(
record: DeliveryRecord,
state: DeliveryState,
observed_at: datetime,
) -> DeliveryRecord:
return replace(
record,
state=state,
observed_at=observed_at,
poll_count=record.poll_count + 1,
)
Do not turn “no event returned” into rejection. It means unknown unless the interface contract explicitly says otherwise. The distinction matters during signup: sending a second live reset message on every unknown result can invalidate an earlier link, confuse the resident, or produce two messages whose arrival order differs from creation order. The safer operational action is to keep observation separate from token issuance and let an explicit user action request another link under the authentication system’s rules.
No webhook is not the same as no observability.
The polling worker should record the attempt time, the returned state, and the provider message identifier while keeping the authentication token out of logs. A dashboard can then show submission age and unresolved count. An alert based only on delivery events will miss failures before a receipt exists, so monitor the submission path and the observation path separately. This is the same instinct used in a storage layer: a successful write acknowledgment has a precise meaning, and expanding that meaning after the fact does not make the underlying guarantee stronger.
Four constraints reveal the real integration cost
The useful comparison happens after the boundaries are fixed. Score each candidate implementation — built-in auth mail, a custom provider adapter, or a queue-backed internal mail service — against the same test cases. A feature checklist cannot expose split ownership or a support dead end.
| Constraint | Evidence to collect | Failure mode it prevents | When it loses |
|---|---|---|---|
| Token authority | Identify the sole issuer and redemption handler | Two systems create competing reset links | A custom mail layer cannot accept an opaque URL |
| Submission contract | Verify idempotency behavior and stable message identifiers | A retry creates duplicate verification mail | The interface couples delivery to provider-specific templates |
| Delivery inquiry | Confirm which states can be queried and for how long | Support treats acceptance as mailbox delivery | Polling limits cannot meet the required observation window |
| Change isolation | Replace the adapter in a test without changing auth logic | A transport migration changes account security behavior | The existing auth system intentionally owns the full email flow |
The catch is that the option with the fewest components is not always the option with the least integration effort. Built-in auth email is suitable when its templates, sending controls, and available delivery evidence meet the property workflow; stick with it then, because an adapter would create another deployment and another credential boundary without buying useful isolation. A direct custom transport is suitable when the auth layer can hand off an opaque link and the team needs control over message rendering or delivery inquiry. A queue-backed service earns its complexity only when several application flows need the same policy, retries, and audit trail.
There is also a hard boundary for polling. It is not suitable when the provider’s documented query limits or event-retention window cannot support the team’s incident-response window. Choose an event-capable integration in that case. Conversely, webhooks impose their own receiver authentication, replay handling, deployment, and data-retention work; requiring one by default can increase the exact integration effort this design is meant to contain.
Count operational surfaces, not SDK calls. The transport with a five-line example may still require template synchronization, secret rotation, retry storage, a poller, and a support view, while an existing authentication integration may already own most of those surfaces. Documentation can establish the advertised contract, but only a controlled test can show whether the contract gives your team the identifiers and transitions needed for this signup flow.
Test the failure modes before selecting the provider
Build one conformance suite against the application-level adapter and run it for every candidate. This avoids letting each quickstart define a different meaning of success. The test matrix should include duplicate submission with the same idempotency key, a transport timeout after the remote side may have accepted the request, an unknown polling result, a terminal rejection, an expired verification link, and a second user-requested link created while the first message remains in flight.
Keep the assertions local to facts your system can observe. The test can assert that the same logical request is not submitted twice, that a provider receipt is persisted before polling begins, that an unknown observation does not become delivered, and that no log record contains the verification URL. It cannot honestly assert mailbox placement merely because submission returned normally.
The most revealing drill starts between two writes: assume the transport accepts a message, then the application process stops before saving the receipt. Recovery now depends on the idempotency contract. If replaying the operation can create a second message, the design needs a durable outbox before the transport call; if the candidate provides a documented idempotent submission mechanism, the adapter can use the stable application key and reconcile the receipt. This is where integration effort hides — not in creating the first request, but in explaining the ambiguous one.
Postmark’s transactional-email guidance is a useful general checklist for separating transactional mail from bulk traffic and considering authentication, reputation, and message content. It should not substitute for testing the exact interface under consideration. Likewise, the MDN Fetch API documentation describes the browser request primitive, not a delivery guarantee; a resolved network call tells the client about an HTTP response, while the application still needs a domain-specific interpretation of that response.
Short tests are cheap. Ambiguous recovery is not.
Roll out by moving one boundary at a time
Begin with a shadow record, not shadow email: keep the current signup delivery path, generate the new adapter’s intended request locally, and compare required fields without sending a second message. Next, route an internal test cohort through the adapter and verify correlation from signup request to submission receipt to any queryable delivery state. Expand only after support can explain submitted, delivered, rejected, and unknown without opening provider dashboards.
Keep the old transport available during the migration window, but never send through both for the same live reset action. Rollback should switch the adapter binding; it should not change token issuance, redirect validation, or the public response. Once the observation window has passed and audit retention requirements are met, remove old credentials and provider-specific template artifacts.
The final selection rule is intentionally unglamorous: choose the design that preserves one token authority, minimizes copied signup data, survives an ambiguous submission, and gives operators evidence within their required window. If no candidate passes those checks, the missing contract is the answer. Adding another library will not supply it.
Top comments (0)