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 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
class MyEmailSender(EmailSender):
    async def send(self, *, to, subject, body, kind):
        await my_task_queue.enqueue(send_email, to=to, subject=subject, html=body)

send abstractmethod async

send(
    *, to: str, subject: str, body: str, kind: EmailKind
) -> None

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:EMAIL_KINDS. Use it to select the template (existing_account is a security notice, not a welcome).

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

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_email_verification async

request_email_verification(
    db: AsyncSession, email: str
) -> None

Send a verification link. Idempotent; never reveals account existence.

confirm_email_verification async

confirm_email_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, email: str
) -> None

Send a reset link. Idempotent; never reveals account existence.

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.