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: the request waits on this call. A raised send is logged and swallowed on every flow, so it never fails the request, but that message is lost unless the sender retries it.
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,
password_policy: PasswordPolicy | 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.
Each token carries a fingerprint of the account state it authorizes (an
HMAC over, never a copy of, that state), so it stops working once that state
moves on: a reset token after the password or recovery value changes, an
email-change token after the password, email, or token_version changes,
and a verification token after the recovery value changes.
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).
supports_email_change
property
¶
Whether change-email can run: the model has an email column and at
least one channel emails the recipient (sends_email).
notify_existing_account
async
¶
Tell an existing owner someone tried to register with their email or
recovery value (value, which is also the notice's recipient).
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
¶
request_recovery_verification(
db: AsyncSession,
value: str,
*,
redirect_to: str | None = None,
) -> None
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.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
AsyncSession
|
Active async session. |
required |
value
|
str
|
The recovery value to look up and deliver to. |
required |
redirect_to
|
str | None
|
Where the app should send the person once they confirm. It rides inside the signed token, so the emailed link is unchanged and the destination survives the link being opened on another device. Only a same-origin relative path is carried; anything else is dropped and the flow proceeds without one. |
None
|
confirm_recovery_verification
async
¶
confirm_recovery_verification(
db: AsyncSession, token: str
) -> EmailFlowResult
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 |
|---|---|
EmailFlowResult
|
The verified user row and the |
EmailFlowResult
|
an EmailFlowResult. |
Raises:
| Type | Description |
|---|---|
BadRequestException
|
If the token is invalid, expired, or already used, or the recovery value changed since it was sent. |
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.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
AsyncSession
|
Active async session. |
required |
value
|
str
|
The recovery value to look up and deliver to. |
required |
redirect_to
|
str | None
|
Where the app should send the person once the password is reset; carried inside the signed token, same-origin paths only. |
None
|
reset_password
async
¶
reset_password(
db: AsyncSession, token: str, new_password: str
) -> EmailFlowResult
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 |
|---|---|
EmailFlowResult
|
The updated user row and the |
EmailFlowResult
|
an EmailFlowResult. |
Raises:
| Type | Description |
|---|---|
BadRequestException
|
If the token is invalid, expired, or already used, or the password or recovery value changed since it was sent. |
PasswordPolicyException
|
If |
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
¶
request_email_change(
db: AsyncSession,
user: Any,
new_email: str,
password: str,
*,
redirect_to: str | None = None,
) -> None
Send a confirmation link to the proposed new address.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
db
|
AsyncSession
|
Active async session. |
required |
user
|
Any
|
The authenticated user changing their address. |
required |
new_email
|
str
|
The proposed address. |
required |
password
|
str
|
The current password, as re-auth. |
required |
redirect_to
|
str | None
|
Where the app should send the person once the new address is confirmed; carried inside the signed token, same-origin paths only. |
None
|
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
¶
confirm_email_change(
db: AsyncSession, token: str
) -> EmailFlowResult
Apply a confirmed email change.
Returns:
| Type | Description |
|---|---|
EmailFlowResult
|
The updated user row and the |
EmailFlowResult
|
an EmailFlowResult. |
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. The previous address, if any, gets an email_changed
notice, so an owner learns their address was replaced.
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.
crudauth.email.EmailFlowResult
¶
Bases: NamedTuple
What a confirmed recovery flow produced.
Attributes:
| Name | Type | Description |
|---|---|---|
user |
Any
|
The affected user row. |
redirect_to |
str | None
|
Where the app should send the person next - the
|