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 builds the subject and body (including the signed-token link) and
hands them to you; you decide how to deliver (SMTP, SES, a task queue...).
kind lets you pick the template; a bad kind is a type error.
Example
send
abstractmethod
async
¶
Deliver one message.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
to
|
str
|
Recipient address. |
required |
subject
|
str
|
Message subject crudauth composed. |
required |
body
|
str
|
Message body, including any signed-token link. |
required |
kind
|
EmailKind
|
Which message this is - one of :data: |
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.EmailFlowService
¶
EmailFlowService(
*,
repo: UserRepository,
secret_key: str,
config: EmailConfig,
hooks: AuthHooks,
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,
)
Mints/verifies signed tokens and drives the email flows.
The package owns token lifecycle; delivery is the app's EmailSender. 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.
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_email_verification
async
¶
Send a verification link. Idempotent; never reveals account existence.
confirm_email_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 link. Idempotent; never reveals account existence.
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.