Skip to content

Commit 850d17d

Browse files
authored
Merge pull request #6 from benavlabs/delivery-channels
Pluggable delivery channels: route recovery tokens over any medium
2 parents fc91f6f + 8cf6713 commit 850d17d

9 files changed

Lines changed: 619 additions & 65 deletions

File tree

crudauth/__init__.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,14 @@ async def me(user: Principal = Depends(auth.current_user())):
1818
from importlib.metadata import version
1919

2020
from .core import AuthContext, CookieConfig, Transport
21-
from .email import EmailConfig, EmailSender
21+
from .email import (
22+
DeliveryChannel,
23+
DeliveryIntent,
24+
DeliveryKind,
25+
EmailChannel,
26+
EmailConfig,
27+
EmailSender,
28+
)
2229
from .exceptions import (
2330
BadRequestException,
2431
CSRFException,
@@ -49,6 +56,10 @@ async def me(user: Principal = Depends(auth.current_user())):
4956
"OAuthCredentials",
5057
"EmailConfig",
5158
"EmailSender",
59+
"DeliveryChannel",
60+
"DeliveryIntent",
61+
"DeliveryKind",
62+
"EmailChannel",
5263
"AuthHooks",
5364
"HookContext",
5465
"NewUserContext",

crudauth/crud_auth.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ async def me(user: Principal = Depends(auth.current_user())):
2929
USED_TOKEN_TTL_SECONDS,
3030
)
3131
from .core import AuthContext, AuthRuntime, CookieConfig, Transport
32+
from .email.channel import DeliveryChannel
3233
from .email.router import build_email_router
3334
from .email.service import EmailFlowService
3435
from .exceptions import (
@@ -100,6 +101,7 @@ def __init__(
100101
column_map: dict[str, str] | None = None,
101102
oauth: dict[str, Any] | None = None,
102103
email: Any = None,
104+
channels: list[DeliveryChannel] | None = None,
103105
hooks: AuthHooks | None = None,
104106
redirect_base_url: str | None = None,
105107
algorithm: str = DEFAULT_ALGORITHM,
@@ -129,8 +131,15 @@ def __init__(
129131
column names when they differ (e.g. ``{"hashed_password": "pw_hash"}``).
130132
oauth: ``{provider_name: OAuthCredentials}`` to enable OAuth login;
131133
requires ``redirect_base_url`` and a session transport.
132-
email: An [EmailConfig][crudauth.email.config.EmailConfig] to enable verify/reset/change
133-
flows; ``None`` disables them.
134+
email: An [EmailConfig][crudauth.email.config.EmailConfig] to enable
135+
verify/reset/change flows over email (the built-in delivery
136+
channel); ``None`` disables email delivery. Either ``email`` or
137+
``channels`` enables the recovery endpoints.
138+
channels: Additional [DeliveryChannel][crudauth.email.channel.DeliveryChannel]s
139+
to route recovery tokens over (SMS, WhatsApp, push, ...). Fired
140+
alongside the email channel if ``email`` is also set, every channel
141+
best-effort. With ``channels`` and no ``email``, the recovery
142+
endpoints still mount (token lifetimes fall back to the defaults).
134143
hooks: Lifecycle callbacks ([AuthHooks][crudauth.hooks.AuthHooks]).
135144
redirect_base_url: Public base URL used to build OAuth redirect URIs
136145
and the post-login redirect default.
@@ -226,8 +235,8 @@ def __init__(
226235

227236
self._email_service: EmailFlowService | None = None
228237
self._email_token_store: AbstractSessionStorage[Any] | None = None
229-
if email is not None:
230-
self._build_email(email, algorithm)
238+
if email is not None or channels:
239+
self._build_email(email, channels, algorithm)
231240

232241
self._oauth_router: APIRouter | None = None
233242
self._oauth_state_storage: AbstractSessionStorage[Any] | None = None
@@ -357,7 +366,9 @@ def sessions(self):
357366
return self._session_transport.manager
358367

359368
# --- email wiring --------------------------------------------------------
360-
def _build_email(self, email: Any, algorithm: str) -> None:
369+
def _build_email(
370+
self, email: Any, channels: list[DeliveryChannel] | None, algorithm: str
371+
) -> None:
361372
backend, redis_url = self._backend_config()
362373
token_store = get_session_storage(
363374
backend, prefix="used_token:", expiration=USED_TOKEN_TTL_SECONDS, redis_url=redis_url
@@ -367,6 +378,7 @@ def _build_email(self, email: Any, algorithm: str) -> None:
367378
repo=self.repo,
368379
secret_key=self.runtime.secret_key,
369380
config=email,
381+
channels=channels,
370382
hooks=self.hooks,
371383
algorithm=algorithm,
372384
token_store=token_store,

crudauth/email/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@
22

33
from __future__ import annotations
44

5+
from .channel import DeliveryChannel, DeliveryIntent, DeliveryKind, EmailChannel
56
from .config import EmailConfig
67
from .sender import EmailSender
78
from .service import EmailFlowService
89

9-
__all__ = ["EmailSender", "EmailConfig", "EmailFlowService"]
10+
__all__ = [
11+
"EmailSender",
12+
"EmailConfig",
13+
"EmailFlowService",
14+
"DeliveryChannel",
15+
"DeliveryIntent",
16+
"DeliveryKind",
17+
"EmailChannel",
18+
]

crudauth/email/channel.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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

Comments
 (0)