|
| 1 | +"""Delivery channels: route a recovery token over a medium (email is built in). |
| 2 | +
|
| 3 | +crudauth owns the token (mint, one-time-use, redemption); a [DeliveryChannel] |
| 4 | +[crudauth.email.channel.DeliveryChannel] owns the medium and the copy. The |
| 5 | +recovery flows hand each configured channel a [DeliveryIntent] |
| 6 | +[crudauth.email.channel.DeliveryIntent] and fire them all best-effort, so an app |
| 7 | +can route reset/verify over email, SMS, WhatsApp, push, or several at once. |
| 8 | +[EmailChannel][crudauth.email.channel.EmailChannel] is the built-in one. |
| 9 | +""" |
| 10 | + |
| 11 | +from __future__ import annotations |
| 12 | + |
| 13 | +from abc import ABC, abstractmethod |
| 14 | +from dataclasses import dataclass |
| 15 | +from typing import TYPE_CHECKING, Any |
| 16 | + |
| 17 | +from .config import EmailConfig |
| 18 | +from .constants import ( |
| 19 | + SUBJECT_CHANGE, |
| 20 | + SUBJECT_EXISTING_ACCOUNT, |
| 21 | + SUBJECT_RESET, |
| 22 | + SUBJECT_VERIFY, |
| 23 | + EmailKind, |
| 24 | +) |
| 25 | + |
| 26 | +if TYPE_CHECKING: # pragma: no cover |
| 27 | + from sqlalchemy.ext.asyncio import AsyncSession |
| 28 | + |
| 29 | +__all__ = ["DeliveryKind", "DeliveryIntent", "DeliveryChannel", "EmailChannel"] |
| 30 | + |
| 31 | +# The channel-facing name for the message kind. Aliased to EmailKind so there is |
| 32 | +# one source of truth for the values, no translation layer. |
| 33 | +DeliveryKind = EmailKind |
| 34 | + |
| 35 | + |
| 36 | +@dataclass(frozen=True) |
| 37 | +class DeliveryIntent: |
| 38 | + """A recovery message crudauth needs delivered. |
| 39 | +
|
| 40 | + crudauth owns the token and its lifetime; the channel owns the medium and the |
| 41 | + copy. Read what you need off ``recipient`` / ``user``; do not assume email. |
| 42 | +
|
| 43 | + Attributes: |
| 44 | + kind: Which message this is (``verify_email`` / ``reset_password`` / |
| 45 | + ``change_email`` / ``existing_account``). |
| 46 | + token: The signed token, or ``None`` for ``existing_account`` (a notice |
| 47 | + with no action). |
| 48 | + user: The logical-contract user dict (``repo.to_dict``); empty for the |
| 49 | + ``existing_account`` notice. Contract fields only, so an app column |
| 50 | + (``phone``, ``whatsapp_id``, ...) is NOT here - load it off the ``db`` |
| 51 | + handed to [deliver][crudauth.email.channel.DeliveryChannel.deliver]. |
| 52 | + recipient: The resolved recovery destination (the email today; for an |
| 53 | + email change, the NEW address). A non-email channel typically ignores |
| 54 | + this and loads its own destination off the user. |
| 55 | + expires_in: Token lifetime in seconds (``0`` when ``token`` is ``None``). |
| 56 | + """ |
| 57 | + |
| 58 | + kind: DeliveryKind |
| 59 | + token: str | None |
| 60 | + user: dict[str, Any] |
| 61 | + recipient: str |
| 62 | + expires_in: int |
| 63 | + |
| 64 | + |
| 65 | +class DeliveryChannel(ABC): |
| 66 | + """A medium crudauth routes a recovery message over. |
| 67 | +
|
| 68 | + crudauth fires every configured channel best-effort and swallows failures per |
| 69 | + channel, so raise freely on failure (it never surfaces and never stops the |
| 70 | + next channel). Reliability (retry/queue) belongs inside a channel. |
| 71 | +
|
| 72 | + Example: |
| 73 | + ```python |
| 74 | + class SMSChannel(DeliveryChannel): |
| 75 | + async def deliver(self, intent: DeliveryIntent, db) -> None: |
| 76 | + if intent.kind != "reset_password" or intent.token is None or db is None: |
| 77 | + return |
| 78 | + user = await db.get(User, intent.user["id"]) # an app column |
| 79 | + if user and user.phone: |
| 80 | + await sms.enqueue(to=user.phone, token=intent.token) # hand off |
| 81 | + ``` |
| 82 | + """ |
| 83 | + |
| 84 | + @abstractmethod |
| 85 | + async def deliver(self, intent: DeliveryIntent, db: AsyncSession | None) -> None: |
| 86 | + """Route, render, and send ``intent``. |
| 87 | +
|
| 88 | + Raise on failure (crudauth swallows per channel). Must not assume email; |
| 89 | + read ``intent.recipient`` / ``intent.user``. |
| 90 | +
|
| 91 | + ``db`` is the request-scoped session for the actionable flows (verify / |
| 92 | + reset / change), or ``None`` for the ``existing_account`` notice. Use it |
| 93 | + to load an app column you need (e.g. |
| 94 | + ``await db.get(User, intent.user["id"])`` for a phone number). It must be |
| 95 | + used **synchronously** and never committed or captured for deferred work: |
| 96 | + it is closed when the request ends, so a queued job that kept it would use |
| 97 | + a dead session. Read what you need, then enqueue the actual delivery. |
| 98 | + """ |
| 99 | + raise NotImplementedError |
| 100 | + |
| 101 | + |
| 102 | +# kind -> (subject, EmailConfig path attribute, body prefix) for the link kinds. |
| 103 | +_EMAIL_SPECS: dict[str, tuple[str, str, str]] = { |
| 104 | + "verify_email": (SUBJECT_VERIFY, "verify_path", "Verify your email:"), |
| 105 | + "reset_password": (SUBJECT_RESET, "reset_path", "Reset your password:"), |
| 106 | + "change_email": (SUBJECT_CHANGE, "change_path", "Confirm your new email:"), |
| 107 | +} |
| 108 | + |
| 109 | + |
| 110 | +class EmailChannel(DeliveryChannel): |
| 111 | + """The built-in channel: renders crudauth's recovery copy and calls the |
| 112 | + [EmailSender][crudauth.email.sender.EmailSender]. |
| 113 | +
|
| 114 | + Behaviorally identical to the email delivery crudauth shipped before delivery |
| 115 | + was pluggable; the subject/body/link building lives here now. |
| 116 | + """ |
| 117 | + |
| 118 | + def __init__(self, config: EmailConfig): |
| 119 | + self._config = config |
| 120 | + |
| 121 | + async def deliver(self, intent: DeliveryIntent, db: AsyncSession | None) -> None: |
| 122 | + cfg = self._config |
| 123 | + if intent.kind == "existing_account": |
| 124 | + await cfg.sender.send( |
| 125 | + to=intent.recipient, |
| 126 | + subject=SUBJECT_EXISTING_ACCOUNT, |
| 127 | + body=( |
| 128 | + "Someone tried to register with this email. You already have an " |
| 129 | + f"account - sign in or reset your password at {cfg.frontend_url}." |
| 130 | + ), |
| 131 | + kind="existing_account", |
| 132 | + ) |
| 133 | + return |
| 134 | + subject, path_attr, prefix = _EMAIL_SPECS[intent.kind] |
| 135 | + assert intent.token is not None |
| 136 | + link = cfg.link(getattr(cfg, path_attr), intent.token) |
| 137 | + await cfg.sender.send( |
| 138 | + to=intent.recipient, subject=subject, body=f"{prefix} {link}", kind=intent.kind |
| 139 | + ) |
0 commit comments