Email flows¶
Verify, reset, and change-email flows. You implement the EmailSender port; CRUDAuth signs
and verifies the single-use tokens through EmailFlowService.
crudauth.email.EmailConfig
dataclass
¶
EmailConfig(
sender: EmailSender,
frontend_url: str = "",
verify_ttl_hours: int = DEFAULT_VERIFY_TTL_HOURS,
reset_ttl_hours: int = DEFAULT_RESET_TTL_HOURS,
change_ttl_hours: int = DEFAULT_CHANGE_TTL_HOURS,
verify_path: str = DEFAULT_VERIFY_PATH,
reset_path: str = DEFAULT_RESET_PATH,
change_path: str = DEFAULT_CHANGE_PATH,
)
Wire the sender port plus token lifetimes and link targets.
Links point at frontend_url with the signed token appended as a query
param, e.g. {frontend_url}{verify_path}?token=....
Note
Putting the token in a URL query string is acceptable only because the
tokens are one-time-use (consumed via a TTL'd store) and short-lived; a
leaked link is single-shot and expires fast. frontend_url should be
set - an empty value produces host-less, dead links and is warned about
at construction.
link
¶
Build a frontend link: {frontend_url}{path}?token={token}.
crudauth.email.EmailSender
¶
Bases: ABC
Adapter crudauth calls to deliver a message.
crudauth composes the subject and a plain-text body (with the signed-token
link in it) and hands them to you; you decide how to deliver (SMTP, SES, a task
queue...). For a plain sender, deliver body as-is. To send your own HTML,
read context - context.link is the
assembled URL, so you can build a real <a href> button or branded template
without regexing the link out of body.
context carries crudauth-owned render data only (link, kind, expiry,
recipient), never the bare token and never user-controlled fields. For per-user
personalization, write a DeliveryChannel
(it has db and owns escaping) rather than reaching for user data here.
Example
class MyEmailSender(EmailSender):
async def send(self, *, to, subject, body, kind, context):
# plain: deliver crudauth's text as-is.
# html: build your own from context.link.
html = render(f"{kind}.html", link=context.link) if context.link else body
await my_task_queue.enqueue(send_email, to=to, subject=subject, html=html)
send
abstractmethod
async
¶
send(
*,
to: str,
subject: str,
body: str,
kind: EmailKind,
context: EmailContext,
) -> None
Deliver one message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
to
|
str
|
Recipient address. |
required |
subject
|
str
|
Message subject crudauth composed. |
required |
body
|
str
|
Plain-text message body crudauth composed, including the
signed-token link. Deliver it as-is for a plain sender, or ignore
it and render your own from |
required |
kind
|
EmailKind
|
Which message this is - one of :data: |
required |
context
|
EmailContext
|
crudauth-owned render data
(EmailContext): the assembled
|
required |
Note
Prefer to enqueue (hand off to a task queue) rather than block on SMTP/provider I/O here. crudauth treats the registration sends as best-effort (a failure is logged, not surfaced), but other flows may propagate a raised send as a 5xx - a non-blocking adapter avoids both slow requests and transient-failure errors.
crudauth.email.EmailContext
dataclass
¶
crudauth-owned render data handed to EmailSender.send.
Everything a sender needs to render its own HTML around crudauth's recovery
flow, and nothing else. Specifically it carries the assembled link (the
signed token is already embedded in the URL, which is what gets emailed), not
the bare token, and it carries no user-controlled fields (no username,
no email). That is deliberate: a sender drops these values into HTML, so
crudauth keeps anything injectable out of reach and is never the XSS vector.
For per-user personalization (Hi Alice), write a
DeliveryChannel instead: it receives
the db handle and the user row, and owns its own escaping.
Attributes:
| Name | Type | Description |
|---|---|---|
kind |
EmailKind
|
Which message this is - one of :data: |
link |
str | None
|
The assembled, ready-to-click URL with the token embedded, or
|
recipient |
str
|
The destination address crudauth resolved. |
expires_in |
int
|
Token lifetime in seconds, or |
Example
crudauth.email.EmailFlowService
¶
EmailFlowService(
*,
repo: UserRepository,
secret_key: str,
hooks: AuthHooks,
config: EmailConfig | None = None,
channels: list[DeliveryChannel] | None = None,
algorithm: str = DEFAULT_ALGORITHM,
token_store: AbstractSessionStorage[Any] | None = None,
session_manager: "SessionManager | None" = None,
rate_limiter: "RateLimiterBackend | None" = None,
rate_limits: dict[str, RateLimit] | None = None,
verify_ttl_hours: int | None = None,
reset_ttl_hours: int | None = None,
change_ttl_hours: int | None = None,
)
Mints/verifies signed tokens and drives the recovery flows.
The package owns token lifecycle; delivery is pluggable via one or more DeliveryChannels (email is the built-in one). Trigger endpoints are throttled two ways: a per-IP edge limit (in the router) and a silent per-target-email limit here - silent because a 429 on a victim's address would re-introduce the enumeration oracle and hand an attacker a DoS lever against that user.
Construction is additive: pass config=EmailConfig(...) (back-compat, which
builds an EmailChannel and seeds the
token TTLs) and/or channels=[...] plus explicit *_ttl_hours. Reachable
as auth.emails (None when no recovery is configured).
notify_existing_account
async
¶
Tell an existing owner someone tried to register with their email.
Lets registration stay non-enumerable: the API responds identically whether or not the email was already taken, and the real owner gets a security heads-up.
Note
Uses kind="existing_account" - a security notice, distinct from
the welcome template, so the adapter doesn't render a cheery
greeting to someone who already has an account.
Note
Subject to the same silent per-target throttle as the other flows, so a register-spray (the per-IP limit is spoofable) can't email-bomb a victim's address. A throttled send is a silent no-op - the route still returns its uniform response, preserving non-enumeration.
request_recovery_verification
async
¶
Send a verification token for the contract's recovery factor.
Idempotent; never reveals account existence. The user is looked up by the recovery factor (email for email recovery, phone for phone recovery) and the token is delivered to that factor's value over the configured channel.
confirm_recovery_verification
async
¶
Verify the signed token and mark the user's email verified (one-time-use).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
AsyncSession
|
Active async session. |
required |
token
|
str
|
The signed verification token from the emailed link. |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The verified user row. |
Raises:
| Type | Description |
|---|---|
BadRequestException
|
If the token is invalid, expired, or already used. |
request_password_reset
async
¶
Send a reset token over the configured channel. Idempotent; never reveals account existence. Looked up by, and delivered to, the recovery factor.
reset_password
async
¶
Reset the password and evict every outstanding credential.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
AsyncSession
|
Active async session. |
required |
token
|
str
|
The signed reset token from the emailed link. |
required |
new_password
|
str
|
The new plaintext password (hashed before storage). |
required |
Returns:
| Type | Description |
|---|---|
Any
|
The updated user row. |
Raises:
| Type | Description |
|---|---|
BadRequestException
|
If the token is invalid, expired, or already used. |
Note
A reset is attacker-eviction: it often follows a compromise, so any
credential an attacker holds must die with it. Server-side sessions
are terminated, and the user's token_version is bumped - which
invalidates all outstanding bearer access and refresh tokens (their
ver claim is now stale). Bearer eviction needs a token_version
column; without it (a custom model that omits it) only sessions are
evicted.
request_email_change
async
¶
Send a confirmation link to the proposed new address.
Note
Requires the current password as re-auth. OAuth-only accounts hold the unusable-password sentinel and therefore cannot use this flow as written - give them a password first (a "set password" flow) or wire a provider re-auth path before exposing email change to them.
Note
Availability is checked best-effort and idempotently: if the address is already taken the token is silently skipped, so the response can't be used to probe which emails exist.
confirm_email_change
async
¶
Apply a confirmed email change.
Note
The confirmation link is delivered to, and clicked from, the new
address, so completing this flow proves control of it - the new email
is therefore marked verified (email_verified=True) alongside the
address update.
Note
Availability is re-checked before consuming the token so a token
isn't burned when the address was taken in the meantime - but that
check is best-effort: the DB unique constraint is the real backstop.
A concurrent confirm to the same address surfaces as IntegrityError,
which is caught and surfaced as a clean duplicate error.