Skip to content

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.

Example
EmailConfig(sender=MyEmailSender(), frontend_url="https://app.example.com")
link(path: str, token: str) -> str

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 context.

required
kind EmailKind

Which message this is - one of :data:EMAIL_KINDS. Use it to select the template (existing_account and email_changed are security notices, not a welcome).

required
context EmailContext

crudauth-owned render data (EmailContext): the assembled link, kind, recipient, and expires_in. Read context.link to build HTML. It carries no bare token and no user-controlled fields by design.

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

EmailContext(
    kind: EmailKind,
    link: str | None,
    recipient: str,
    expires_in: int,
)

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:EMAIL_KINDS.

link str | None

The assembled, ready-to-click URL with the token embedded, or None for a notice with no action (existing_account, email_changed).

recipient str

The destination address crudauth resolved.

expires_in int

Token lifetime in seconds, or 0 when link is None.

Example
# inside EmailSender.send, render your own HTML from the context:
html = f'<a href="{context.link}">Verify your email</a>'

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).

Example
if auth.emails is not None:
    await auth.emails.request_password_reset(db, email)

supports_email_change property

supports_email_change: bool

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

notify_existing_account(value: str) -> None

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 redirect_to the request carried, as

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

request_password_reset(
    db: AsyncSession,
    value: str,
    *,
    redirect_to: str | None = None,
) -> None

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 redirect_to the request carried, as

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 new_password fails the password policy. The token isn't used up, so the user can retry with a stronger password.

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 redirect_to the request carried, as

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 redirect_to they asked for when the link was requested, if it survived validation, else None.