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 is a security notice, 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. 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

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 existing_account (a notice with no action).

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

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

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

notify_existing_account async

notify_existing_account(email: str) -> None

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

request_recovery_verification(
    db: AsyncSession, value: str
) -> 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.

confirm_recovery_verification async

confirm_recovery_verification(
    db: AsyncSession, token: str
) -> Any

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

request_password_reset(
    db: AsyncSession, value: str
) -> None

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_password(
    db: AsyncSession, token: str, new_password: str
) -> Any

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

request_email_change(
    db: AsyncSession,
    user: Any,
    new_email: str,
    password: str,
) -> None

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

confirm_email_change(db: AsyncSession, token: str) -> Any

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.