diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f6718bfc7..4e3aba8a3 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -235,7 +235,7 @@ jobs: # Branding is included so the admin section's "edited on its own page, # never in the generic module editor" rule is actually exercised — the # server-side half of that is a 409 guard, which is worth CI coverage. - SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog","Branding"]' + SM_MODULES_ENABLED: '["Auth","Users","Mail","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog","Branding"]' # Every test signs in from scratch, all from 127.0.0.1: the login page, # the login POST and the landing page are anonymous public-route hits, # ~120 a minute for the whole suite — right at the default @@ -368,7 +368,7 @@ jobs: # Keycloak excluded (SM020: one auth provider). BackgroundTasks # excluded because its DB-hydrated settings reject a localhost broker # under SM_ENVIRONMENT=production. - SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]' + SM_MODULES_ENABLED: '["Auth","Users","Mail","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]' run: | uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 & echo $! > api.pid diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6da54b06..cd82048b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -125,6 +125,7 @@ jobs: - simple_module_feature_flags - simple_module_file_storage - simple_module_keycloak + - simple_module_mail - simple_module_permissions - simple_module_settings - simple_module_site_lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 518668c20..04062513b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,25 @@ All notable changes to this project are documented in this file. The format is b ## [Unreleased] +### Added +- New `mail` module (`simple_module_mail`) owns outgoing mail: the SMTP/console + transport, its settings (`/admin/settings/`, package `mail`) and the on-demand + `mail.mailer` health check behind "Test mailer connection". Modules send through + `simple_module_core.mail` (`current_mail_sender()` / `resolve_mail_sender(db)`), + which falls back to logging when no transport is installed (#496). +- Tenant invitations are now emailed (#496). With `background_tasks` installed + the email goes through the durable outbox (`tenants.send_invitation_email`, + retried); otherwise it is sent after the request commits. A link is only + emailed when `public_base_url` is set. The invite API response gains + `emailed`, and the invite form says whether the link was emailed. +- `captured_mail` pytest fixture in `simple_module_test` records sent mail. + ### Changed +- **Behaviour change:** the `users` mailer settings (`mailer`, `smtp_*`) moved to + the `mail` module as `backend` and `smtp_*`; a data migration (`d7b3a9e5c1f2`) + moves stored values (#496). `simple_module_users` now depends on `simple_module_mail`, and + the `users.mailer` health check is now `mail.mailer`. The `users.Mailer` + protocol is unchanged. - `ty` now checks `invalid-argument-type` and `invalid-assignment` in shipped code (tests stay exempt), and the ~150 mypy-style `# type: ignore[...]` comments, which suppressed nothing under ty, are gone (#507). Remaining diff --git a/CLAUDE.md b/CLAUDE.md index 71db58fde..61fea8f47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -345,6 +345,11 @@ development and testing (`session_cookie_secure`). heavy imports (`torch`, `cv2`, …) inside the task body (`SM028`). Enqueue from a transaction with `background_tasks.enqueue_after_commit(db, task, …)`, never `.delay()`. Long tasks call `heartbeat()`. `docs/modules/background_tasks.md`. +- **Mail**: build a `MailMessage` and send through `simple_module_core.mail` + (`current_mail_sender()` in request code, `await resolve_mail_sender(db)` in + durable handlers, which have no app). The `mail` module owns SMTP and its + settings; without it the sender only logs (`delivers_email` is false). Tests + use the `captured_mail` fixture. - **Audit links / design packs**: `register_audit_links` takes `AuditLink(entity_type=Model.__name__, …)` (class name, not table name, or it silently never matches); `register_design_packs` slugs must match diff --git a/README.md b/README.md index b5ae64ef9..e6e6ce40e 100644 --- a/README.md +++ b/README.md @@ -299,15 +299,10 @@ Set `SM_USERS_ALLOW_SIGNUP=true` and restart the server. The `/users/register` p ### Switching to SMTP +Outgoing mail is configured in the `mail` module (admin UI at `/admin/settings/`, under Mail), not through `SM_USERS_*` variables. Users only needs its public link base: + ``` -SM_USERS_MAILER=smtp SM_USERS_BASE_URL=https://your-domain.com -SM_USERS_SMTP_HOST=smtp.example.com -SM_USERS_SMTP_PORT=587 -SM_USERS_SMTP_USERNAME=no-reply@example.com -SM_USERS_SMTP_PASSWORD=secret -SM_USERS_SMTP_FROM=no-reply@example.com -SM_USERS_SMTP_TLS=true ``` ## Architecture diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 336d97685..c0bbebb25 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -189,6 +189,7 @@ export default defineConfig({ { text: "background_tasks", link: "/modules/background_tasks" }, { text: "audit_log", link: "/modules/audit_log" }, { text: "site_lock", link: "/modules/site_lock" }, + { text: "mail", link: "/modules/mail" }, { text: "tenants", link: "/modules/tenants" }, { text: "dashboard", link: "/modules/dashboard" }, ], diff --git a/docs/framework/settings.md b/docs/framework/settings.md index 3cbc81e23..e897543dd 100644 --- a/docs/framework/settings.md +++ b/docs/framework/settings.md @@ -46,12 +46,7 @@ from pydantic_settings import BaseSettings class UsersEnv(BaseSettings): allow_signup: bool = False - mailer: str = "console" base_url: str | None = None - smtp_host: str | None = None - smtp_port: int = 587 - smtp_username: str | None = None - smtp_password: str | None = None class Config: env_prefix = "SM_USERS_" @@ -65,7 +60,7 @@ from dataclasses import dataclass @dataclass class UsersState: settings: UsersEnv - mailer: Mailer # picked based on settings.mailer + mailer: Mailer # sends through the mail module's sender principal_serializer: Callable[[User], dict] ``` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 36850c027..81da87c46 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -49,7 +49,7 @@ These are **DB-backed host settings** (under `host` in `/admin/settings/`), not ## Users module -Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*`). Signup policy, mailer, SMTP creds, and base URL all moved to the DB-backed settings store — edit them under `users` at `/admin/settings/`. +Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*`). Signup policy and base URL are DB-backed settings (edit them under `users` at `/admin/settings/`); the mailer and SMTP credentials live under `mail`. | Variable | Default | Notes | |---|---|---| @@ -58,7 +58,7 @@ Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*` | `SM_USERS_BOOTSTRAP_USER_EMAIL` | unset | Optional second, non-admin seed user. | | `SM_USERS_BOOTSTRAP_USER_PASSWORD` | unset | Paired with the user email above. | -DB-backed users settings (defaults): `allow_signup=false`, `mailer=console` (or `smtp`), `base_url=http://localhost:8000`, and the `smtp_*` fields (`smtp_port=587`, `smtp_from=no-reply@localhost`, `smtp_tls=true`). +DB-backed users settings (defaults): `allow_signup=false` and `base_url=http://localhost:8000`. Outgoing mail (SMTP host, credentials, sender address) is configured in the [`mail` module](/modules/mail), not under Users. ## Background tasks (Celery) diff --git a/docs/modules/index.md b/docs/modules/index.md index e97fc32a3..8277fe3cb 100644 --- a/docs/modules/index.md +++ b/docs/modules/index.md @@ -1,6 +1,6 @@ # Bundled modules -simple_module_python ships with fourteen first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need. +simple_module_python ships with fifteen first-party modules. Each is a regular Python package — same shape as a module you'd write yourself — registered through the `simple_module` entry point and discovered at boot. They are independent: install only what you need. | Module | Depends on | What it provides | |---|---|---| @@ -18,6 +18,7 @@ simple_module_python ships with fourteen first-party modules. Each is a regular | [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). | | [`tenants`](/modules/tenants) | `auth`, `settings` | SaaS organisations: tenants, many-to-many memberships with per-tenant roles, invitations, the membership-validated tenant resolver, and the entitlement/lifecycle seams billing plugs into. | | [`site_lock`](/modules/site_lock) | `settings`, `auth` | Optional site-wide shared-password gate for staging / pre-launch sites. Off by default. | +| [`mail`](/modules/mail) | `settings` | Outgoing mail transport: SMTP (aiosmtplib) or a logging console backend, DB-backed settings, live reload, `mail.mailer` connection check. Other modules send through `simple_module_core.mail`. | ## How modules are wired in diff --git a/docs/modules/mail.md b/docs/modules/mail.md new file mode 100644 index 000000000..228274077 --- /dev/null +++ b/docs/modules/mail.md @@ -0,0 +1,61 @@ +# mail + +Outgoing mail for the app. The module owns the transport (SMTP through `aiosmtplib`, or a `console` backend that logs each message) and its settings. Other modules never import it: they send through `simple_module_core.mail`, which works whether or not this module is installed. + +## ModuleMeta + +| Field | Value | +|---|---| +| `name` | `Mail` | +| `route_prefix` | *(none)* | +| `view_prefix` | *(none)* | +| `depends_on` | `["Settings"]` | + +The module has no tables, routes or pages. It is configured on the generic settings page (**Settings -> Modules -> Mail**). + +## Settings + +DB-backed (`MailSettings`, package `mail`), hot-reloaded without a restart. + +| Field | Default | Notes | +|---|---|---| +| `backend` | `console` | `console` logs the whole message (development); `smtp` delivers | +| `smtp_host` | `""` | | +| `smtp_port` | `587` | | +| `smtp_username` | `""` | blank means no authentication | +| `smtp_password` | `""` | masked in the settings UI | +| `smtp_from` | `no-reply@localhost` | | +| `smtp_tls` | `true` | implicit TLS (`use_tls`) | + +Earlier versions kept these under `users` (`users.mailer`, `users.smtp_*`). A data migration renames the stored keys to `mail.backend` and `mail.smtp_*`; an existing `mail.*` row wins, and the downgrade reverses it. + +## Sending mail from a module + +```python +from simple_module_core.mail import MailMessage, current_mail_sender + +sender = current_mail_sender() +await sender.send(MailMessage(to="a@example.com", subject="Hi", text="Plain body")) +``` + +- `current_mail_sender()` is synchronous and never raises; with nothing installed it returns a console sender that logs only the recipient and subject (bodies carry one-time tokens). +- `sender.delivers_email` is `False` for console senders, so a screen can show a link instead of promising an inbox. +- Code that may run in the outbox worker (no `app`) uses `await resolve_mail_sender(db)`. The module installs a resolver in `register_event_handlers` before its `app is None` return; it hydrates `MailSettings` from the given session. A worker restricted with `SM_MODULES_ENABLED` must therefore list `Mail` (and any module whose durable handler sends mail, such as `Tenants`); without it the tenants invitation handler raises, so the outbox retries the row (and marks it `dead` after its retries) instead of dropping the email. + +## Lifecycle + +| Hook | What it does | +|---|---| +| `register_settings` | `register_module_settings` mounts `app.state.mail` | +| `register_health_checks` | adds the on-demand `mail.mailer` check | +| `register_event_handlers` | installs the resolver; on `SettingsReloaded(package="mail")` rebuilds and reinstalls the sender | +| `on_startup` | installs the sender built from the hydrated settings | +| `on_shutdown` | uninstalls the sender and the resolver | + +## Health check + +`mail.mailer` (`probe=False`, so it never runs on a readiness timer) re-reads the installed sender on each run. The console backend is healthy ("needs no connection"); the SMTP backend connects and authenticates without sending anything. The settings page's **Test connection** button runs it. + +## Tests + +`simple_module_test` provides a `captured_mail` fixture that installs a recording sender for the test and restores the previous state afterwards. diff --git a/docs/modules/tenants.md b/docs/modules/tenants.md index be679be6b..e342cc407 100644 --- a/docs/modules/tenants.md +++ b/docs/modules/tenants.md @@ -54,6 +54,19 @@ owns and maps onto owner and admin (#382); the page writes through permission — declared, owner-only, and checked by nothing — is retired, so there is one permission for the one surface. +Invitations are emailed through the [mail module](/modules/mail). With +`background_tasks` installed, delivery uses the outbox and retries failures; +otherwise it runs after the request commits. Set `public_base_url` for absolute +accept links: relative links are not emailed. Nothing is queued unless the +`mail` module is installed with a delivering backend (SMTP); with the console +backend the invite form hands the link back instead. The UI shows the link and copy button either way, +and says whether the link was emailed. + +An outbox-delivered invitation keeps its accept link, one-time token included, +in the outbox row until the `background_tasks` retention purge. The token is +single-use and expires with the invitation (`invitation_ttl_hours`), but treat +outbox rows and database backups as holding live invitation links until then. + ## Configuration DB-backed (Settings screen): @@ -87,8 +100,7 @@ The module ships no billing, but a billing module needs nothing more from it: - Events, published after commit: `TenantCreated`, `TenantStatusChanged`, `TenantDeleted` (`tenant_id`, `slug`, `name`; from `TenantService.delete_tenant` — subscribe to clean up per-tenant state), - `MembershipAdded`, `MembershipRemoved`, `InvitationCreated` (also the hook a - mailer uses to deliver the invitation link). + `MembershipAdded`, `MembershipRemoved`, `InvitationCreated`. ## Models diff --git a/docs/modules/users.md b/docs/modules/users.md index 6ffe74841..d9077327e 100644 --- a/docs/modules/users.md +++ b/docs/modules/users.md @@ -192,9 +192,7 @@ Everything else is DB-backed (initial values are pydantic defaults; edit under U | `cookie_max_age_seconds` | `1_209_600` (14 days) | | `cookie_secure` | `True` (flipped to `False` in dev at startup) | | `cookie_samesite` | `"lax"` | -| `mailer` | `"console"` (or `"smtp"`) | | `base_url` | `"http://localhost:8000"` | -| `smtp_host` / `smtp_port` / `smtp_username` / `smtp_password` / `smtp_from` / `smtp_tls` | SMTP config when `mailer="smtp"` | | `login_rate_limit_failures` | `5` — failures from one IP for one username before that source is throttled for the window | | `login_rate_limit_window_seconds` | `300` — the window for both lockout tiers | | `login_rate_limit_cooldown_seconds` | `900` — how long an account lock lasts | @@ -389,16 +387,11 @@ Trade-offs to know about: - **Without Redis, entries can be evicted.** The in-process store caps its key count and evicts under that pressure, so a flood of distinct usernames can drop an unknown username's lock or a source's throttle early. Account locks live on the row and are unaffected. Set `SM_REDIS_URL` in production. - Login values longer than an address can be (254 characters) are hashed before they become store keys, so a large request body cannot park a large key in Redis. -## Mailer backends +## Mailer -`mailer/` ships two implementations of the `Mailer` protocol: +`mailer/` ships one implementation of the `Mailer` protocol, `TemplatedMailer`. It renders the bundled verify / reset / invite templates and sends each as a `MailMessage` through the framework mail sender (`simple_module_core.mail.current_mail_sender()`), looked up at send time. The transport (SMTP or console), its credentials and the `mail.mailer` health check belong to the [`mail` module](./mail.md); the former `mailer` and `smtp_*` users settings no longer exist. -| Backend | When to use | Configured via | -|---|---|---| -| `ConsoleMailer` | dev — prints invite / verify / reset links to stdout | `mailer="console"` | -| `SmtpMailer` | prod — talks SMTP | `mailer="smtp"` + `smtp_*` settings | - -`build_mailer(settings)` returns the right instance based on `users.mailer`. +`delivers_email` reflects the installed sender, so screens that need to hand the one-time link back (reset-sent card, add people, resend and bulk invite) do so whenever the sender only logs. `build_mailer(settings)` returns the `TemplatedMailer`; a third-party `Mailer` can still be assigned to `app.state.users.mailer`. ## OAuth providers diff --git a/docs/reference/env-vars.md b/docs/reference/env-vars.md index 711520218..ef20a05fc 100644 --- a/docs/reference/env-vars.md +++ b/docs/reference/env-vars.md @@ -61,7 +61,7 @@ The two maintenance fields have **no working env var**, and this is worth statin ## Users module -Most users settings (signup, mailer, SMTP, OAuth/OIDC credentials, rate limits) are now **DB-backed** — edit them in the admin UI at `/admin/settings/` under Users, not via env vars. The env vars below are the genuine bootstrap-time exceptions read at process start. +Most users settings (signup, OAuth/OIDC credentials, rate limits; SMTP lives in the [`mail` module](/modules/mail)) are now **DB-backed** — edit them in the admin UI at `/admin/settings/` under Users, not via env vars. The env vars below are the genuine bootstrap-time exceptions read at process start. | Variable | Default | Notes | |---|---|---| diff --git a/docs/superpowers/specs/2026-10-20-mail-module-design.md b/docs/superpowers/specs/2026-10-20-mail-module-design.md new file mode 100644 index 000000000..833657919 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-mail-module-design.md @@ -0,0 +1,137 @@ +# Mail module and tenant invitation emails (GH #496) + +Status: approved 2026-10-20. + +## Problem + +`tenants` publishes `InvitationCreated` "for a mailer module to send", but nothing +subscribes, so invitations reach nobody unless the admin copies the link by hand. +The only mailer lives inside `users`, and its `Mailer` protocol has one method per +users flow, so another module cannot reuse it. Keycloak installs have no `users` +and therefore no mail at all. + +## Decisions + +- SMTP transport and its settings move into a new **`mail` plugin module** + (`simple_module_mail`). The framework core gets only the interface. +- Tenant invitation emails go through the **durable outbox when + `background_tasks` is installed** (retried, at-least-once), and are sent from + the request's after-commit callback otherwise. +- Mail stays plain text and English, like `users` today. HTML and translated + mail are out of scope. + +## 1. Core interface: `simple_module_core.mail` + +```python +@dataclass(frozen=True) +class MailMessage: + to: str + subject: str + text: str + html: str | None = None + + +class MailSender(Protocol): + delivers_email: bool # False for a sender that only logs + + async def send(self, message: MailMessage) -> None: ... + + +class ConsoleMailSender: # delivers_email = False; logs to "simple_module.mail" + ... + + +MailSenderResolver = Callable[[Any], Awaitable[MailSender]] # takes an AsyncSession + + +def install_mail_sender(sender: MailSender | None) -> None: ... +def install_mail_resolver(resolver: MailSenderResolver | None) -> None: ... +def current_mail_sender() -> MailSender: ... # installed sender, else ConsoleMailSender +async def resolve_mail_sender(db: Any | None = None) -> MailSender: ... +``` + +Process-level state, because the outbox worker builds modules without an app +(`register_event_handlers(bus, None)`), so a durable handler cannot reach +`app.state`: + +- `install_mail_sender` sets the sender of a web process (built from cached + settings, rebuilt on `settings_reloaded`). `current_mail_sender()` is sync so + views can ask `delivers_email` while rendering. +- `install_mail_resolver` sets an async factory that builds a sender from the DB. + It is used by `resolve_mail_sender(db)` only when no sender is installed (the + worker). With neither, both functions return a `ConsoleMailSender`, so callers + never need to check whether mail is configured. +- The fallback console sender (nothing installed) logs recipient and subject + only, at WARNING: bodies carry one-time tokens. The `mail` module's explicit + `console` backend logs the whole message, as `users` did, for development. +- No settings and no SMTP code in core; SM009 holds. + +## 2. `mail` plugin module (`modules/mail/mail/`) + +- `MailSettings(DbBackedSettings)`: `backend: Literal["console", "smtp"] = "console"`, + `smtp_host`, `smtp_port = 587`, `smtp_username`, `smtp_password`, + `smtp_from = "no-reply@localhost"`, `smtp_tls = True`. Registered with + `register_module_settings`; edited on the generic settings page. +- `SmtpMailSender` (aiosmtplib, the existing code moved from `users`, plus + `verify_connection()`), `build_sender(settings)`. +- Lifecycle: `register_settings` mounts `app.state.mail`; `on_startup` installs + the sender; `settings_reloaded` rebuilds and reinstalls it; `on_shutdown` + uninstalls. `register_event_handlers` installs the resolver (it runs in the + worker too) — it hydrates `MailSettings` from the given session. +- Health check `mail.mailer` (on demand, `probe=False`) replaces `users.mailer` (same button label); + the settings page's "Test mailer connection" button points at it. +- No tables, no pages. Full module checklist: pyproject metadata, README, + `docs/modules/mail.md`, docs index + sidebar, release matrix, root + `testpaths`/`extra-paths`, host dependency, CHANGELOG. + +### Settings data migration + +Data-only Alembic revision on the users trunk: for system-scope rows of the +settings table, `users.mailer` → `mail.backend` and `users.smtp_*` → +`mail.smtp_*` (same suffix). The downgrade reverses it. An existing `mail.*` +row wins over a `users.*` one (the old row is then deleted), so a re-run is safe. + +## 3. `users` + +- `UsersSettings` loses `mailer` and `smtp_*`; keeps `base_url`. +- `Mailer` protocol unchanged (third-party mailers keep working). The built-in + implementation renders the existing templates and subjects and sends through + `current_mail_sender()` at send time, so a settings change takes effect + without a restart. `delivers_email` is a property reading the current sender. + `ConsoleMailer` and `SmtpMailer` are folded into it. +- `mailer_delivers`, the bulk/resend invite fallbacks and the test doubles keep + their behaviour. The `users.mailer` health check moves to `mail`. +- `simple_module_users` depends on `simple_module_mail` (pyproject), so an + upgrade does not silently lose SMTP. + +## 4. `tenants` + +- After creating an invitation, `InvitationService.create`: + - if `bus.durable_transport` is set, `publish_durable(InvitationCreated(...), db)` + in the request transaction, handled by the durable handler + `tenants.send_invitation_email` (registered before any `app is None` return); + - otherwise sends from the existing after-commit callback. A failure there + logs a warning; the invitation still exists and the link is still shown. + - The in-process `InvitationCreated` publish is unchanged. +- The handler resolves the sender with `resolve_mail_sender(ctx.db)` and renders + `tenants/templates/invitation.txt` (stdlib `string.Template`; no new dependency) + with tenant name, role and link. +- A root-relative `accept_url` (no `public_base_url`) is not emailed: the handler + logs a warning naming the setting, since a relative link is useless in a mail. +- `InvitationIssued` gains `emailed: bool` = the current sender delivers and the + link is absolute. `InviteForm` shows "Invitation emailed to …" or "Email isn't + configured — share this link"; the copy button stays. New i18n keys. +- `docs/modules/tenants.md` corrected. + +## 5. Tests + +- `captured_mail` fixture in `simple_module_test`: installs a recording sender, + restores the previous state afterwards; an autouse reset so process-level + state never leaks between tests. +- Core: resolution order (installed → resolver → console). +- mail: settings → sender, reload reinstalls, resolver hydrates from DB, health + check; migration up/down. +- users: existing flows pass against `captured_mail`. +- tenants: emailed via on-commit path; emailed via durable handler; relative + link not emailed; send failure keeps the invitation; `emailed` flag. +- Vitest: both InviteForm states. diff --git a/docs/testing/fixtures.md b/docs/testing/fixtures.md index b6ba4eb5e..ac276c130 100644 --- a/docs/testing/fixtures.md +++ b/docs/testing/fixtures.md @@ -13,6 +13,7 @@ The `simple_module_test` plugin provides app-level fixtures that every test dire | `app` | function | `FastAPI` | Endpoint tests that don't need an HTTP client. | | `client` | function | `httpx.AsyncClient` | Anonymous HTTP calls to the app. | | `authenticated_client` | function | `httpx.AsyncClient` | Admin-scoped HTTP calls — carries a signed session cookie. | +| `captured_mail` | function | `CapturedMail` | Asserting on outgoing mail: installs a recording sender (`.messages`, `.to(address)`) as the process mail sender. | All fixtures are **function-scoped** — each test gets its own. The SQLite database is in-memory; tearing down and re-creating is cheap. @@ -185,6 +186,8 @@ Don't rely on `datetime.now()` assertions with tolerance windows — they're fla For HTTP, SMTP, storage backends — mock at the boundary with `httpx_mock`, `aiosmtpd`, or a service-registered fake. **Don't** monkeypatch the module you're testing; its behavior is what's under test. +For mail, request `captured_mail` instead of mocking SMTP: everything sent through `simple_module_core.mail` lands in `captured_mail.messages` as `MailMessage`s. `CapturedMail(delivers_email=False)` installed with `install_mail_sender` exercises the "share the link by hand" paths. The process sender is reset after every test, so one test's sender never reaches the next. + Example with `httpx_mock`: ```python diff --git a/framework/core/simple_module_core/mail.py b/framework/core/simple_module_core/mail.py new file mode 100644 index 000000000..b41bf9de5 --- /dev/null +++ b/framework/core/simple_module_core/mail.py @@ -0,0 +1,115 @@ +"""Outgoing mail: the message type, the sender protocol and the process's sender. + +Modules send through :func:`current_mail_sender` (web code) or +:func:`resolve_mail_sender` (code that may run in the outbox worker) and never +need to know whether mail is configured: with nothing installed both return a +:class:`ConsoleMailSender`, which logs the message instead of delivering it. + +The transport (SMTP and its settings) belongs to the ``mail`` module, which +installs a sender in a web process and a resolver everywhere. The state is +process-level rather than on ``app.state`` because the outbox worker builds +modules without an app (``register_event_handlers(bus, None)``), so a durable +handler has only its database session to go on (GH #496). +""" + +from __future__ import annotations + +import logging +from collections.abc import Awaitable, Callable +from dataclasses import dataclass +from typing import Any, Protocol, runtime_checkable + +logger = logging.getLogger("simple_module.mail") + + +@dataclass(frozen=True) +class MailMessage: + to: str + subject: str + text: str + html: str | None = None + + +@runtime_checkable +class MailSender(Protocol): + #: ``False`` for a sender that only logs: screens use it to hand the link + #: back to the person instead of promising an inbox. + delivers_email: bool + + async def send(self, message: MailMessage) -> None: ... + + +class ConsoleMailSender: + """Logs each message instead of sending it. + + With ``include_body`` (the ``mail`` module's explicit ``console`` backend, + for development) the whole message is logged, so a developer can follow a + reset or invite link. The fallback used when no transport is installed + passes ``include_body=False``: those bodies carry one-time tokens and must + not end up in a production log just because mail was never configured. + """ + + delivers_email = False + + def __init__(self, *, include_body: bool = True) -> None: + self.include_body = include_body + + async def send(self, message: MailMessage) -> None: + extra = {"mail_to": message.to, "mail_subject": message.subject} + if self.include_body: + logger.info( + "Mail to %s (not sent, console sender): %s\n%s", + message.to, + message.subject, + message.text, + extra=extra, + ) + else: + logger.warning( + "Mail to %s not sent: no mail transport is installed (%s)", + message.to, + message.subject, + extra=extra, + ) + + +#: Builds a sender from the database (``AsyncSession``); used where no sender +#: is installed, i.e. in the outbox worker. +MailSenderResolver = Callable[[Any], Awaitable[MailSender]] + +_sender: MailSender | None = None +_resolver: MailSenderResolver | None = None + + +def install_mail_sender(sender: MailSender | None) -> None: + """Set (or with ``None`` clear) this process's sender.""" + global _sender + _sender = sender + + +def install_mail_resolver(resolver: MailSenderResolver | None) -> None: + """Set (or with ``None`` clear) the factory used when no sender is installed.""" + global _resolver + _resolver = resolver + + +def mail_transport_installed() -> bool: + """Whether a transport (sender or resolver) is installed in this process. + + False means mail would only reach the fallback log. A durable handler uses + it to fail, and be retried, rather than drop mail in a worker that was + started without the ``mail`` module while the web process has it. + """ + return _sender is not None or _resolver is not None + + +def current_mail_sender() -> MailSender: + """The installed sender, else a console sender. Never raises.""" + return _sender if _sender is not None else ConsoleMailSender(include_body=False) + + +async def resolve_mail_sender(db: Any | None = None) -> MailSender: + """The installed sender, else one built by the resolver from *db*, else console.""" + if _sender is None and _resolver is not None and db is not None: + return await _resolver(db) + return current_mail_sender() diff --git a/framework/core/tests/test_core_mail_resolution.py b/framework/core/tests/test_core_mail_resolution.py new file mode 100644 index 000000000..699dc2464 --- /dev/null +++ b/framework/core/tests/test_core_mail_resolution.py @@ -0,0 +1,107 @@ +"""Resolution order of the process mail sender (GH #496).""" + +from __future__ import annotations + +import logging + +import pytest +from simple_module_core import mail +from simple_module_core.mail import ( + ConsoleMailSender, + MailMessage, + MailSender, + current_mail_sender, + install_mail_resolver, + install_mail_sender, + resolve_mail_sender, +) + + +class _Recording: + delivers_email = True + + def __init__(self) -> None: + self.sent: list[MailMessage] = [] + + async def send(self, message: MailMessage) -> None: + self.sent.append(message) + + +@pytest.fixture(autouse=True) +def _clean_state(): + install_mail_sender(None) + install_mail_resolver(None) + yield + install_mail_sender(None) + install_mail_resolver(None) + + +def test_nothing_installed_gives_a_console_sender() -> None: + assert isinstance(current_mail_sender(), ConsoleMailSender) + assert current_mail_sender().delivers_email is False + + +async def test_installed_sender_wins_over_the_resolver() -> None: + sender = _Recording() + install_mail_sender(sender) + + async def _resolver(db): + raise AssertionError("resolver must not run while a sender is installed") + + install_mail_resolver(_resolver) + assert current_mail_sender() is sender + assert await resolve_mail_sender(object()) is sender + + +async def test_resolver_is_used_with_a_session_when_no_sender_is_installed() -> None: + built = _Recording() + seen = [] + + async def _resolver(db): + seen.append(db) + return built + + install_mail_resolver(_resolver) + session = object() + assert await resolve_mail_sender(session) is built + assert seen == [session] + # Without a session there is nothing to build from. + assert isinstance(await resolve_mail_sender(), ConsoleMailSender) + # The sync accessor never runs the resolver. + assert isinstance(current_mail_sender(), ConsoleMailSender) + + +async def test_console_sender_logs_instead_of_sending(caplog) -> None: + with caplog.at_level(logging.INFO, logger="simple_module.mail"): + await ConsoleMailSender().send(MailMessage("a@example.com", "Hi", "Body text")) + assert "a@example.com" in caplog.text + assert "Body text" in caplog.text + + +def test_protocol_is_runtime_checkable() -> None: + assert isinstance(_Recording(), MailSender) + assert isinstance(ConsoleMailSender(), MailSender) + assert mail.MailSender is MailSender + + +async def test_fallback_sender_never_logs_the_body(caplog) -> None: + """Bodies carry one-time tokens; an unconfigured install must not log them.""" + with caplog.at_level(logging.INFO, logger="simple_module.mail"): + await current_mail_sender().send(MailMessage("a@example.com", "Hi", "token=s3cr3t")) + assert "a@example.com" in caplog.text + assert "s3cr3t" not in caplog.text + + +async def test_transport_installed_tracks_sender_or_resolver() -> None: + from simple_module_core.mail import mail_transport_installed + + assert mail_transport_installed() is False + install_mail_sender(_Recording()) + assert mail_transport_installed() is True + install_mail_sender(None) + + async def _resolver(db): + return _Recording() + + install_mail_resolver(_resolver) + assert mail_transport_installed() is True diff --git a/framework/testing/simple_module_test/mail.py b/framework/testing/simple_module_test/mail.py new file mode 100644 index 000000000..076905233 --- /dev/null +++ b/framework/testing/simple_module_test/mail.py @@ -0,0 +1,49 @@ +"""Mail fixtures: capture what the app sends, and never leak a sender between tests. + +The process mail sender (``simple_module_core.mail``) is module-level state that +an app's ``mail`` module installs on startup, so a test that boots an app would +otherwise hand its SMTP sender to the next test in the same worker. +""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from simple_module_core import mail as core_mail +from simple_module_core.mail import MailMessage + + +class CapturedMail: + """A sender that records every message instead of sending it.""" + + def __init__(self, *, delivers_email: bool = True) -> None: + self.delivers_email = delivers_email + self.messages: list[MailMessage] = [] + + async def send(self, message: MailMessage) -> None: + self.messages.append(message) + + def to(self, address: str) -> list[MailMessage]: + return [m for m in self.messages if m.to == address] + + +@pytest.fixture(autouse=True) +def _reset_process_mail_sender() -> Iterator[None]: + sender, resolver = core_mail._sender, core_mail._resolver + yield + core_mail.install_mail_sender(sender) + core_mail.install_mail_resolver(resolver) + + +@pytest.fixture +def captured_mail() -> Iterator[CapturedMail]: + """Install a recording sender that claims to deliver; yields it. + + Installed after any app fixture requested before it has started, so it wins + over the ``mail`` module's sender. A ``settings_reloaded`` in the test + reinstalls the real one; reinstall with ``install_mail_sender(captured)``. + """ + captured = CapturedMail() + core_mail.install_mail_sender(captured) + yield captured diff --git a/framework/testing/simple_module_test/plugin.py b/framework/testing/simple_module_test/plugin.py index 16341b0c6..6f424e4bc 100644 --- a/framework/testing/simple_module_test/plugin.py +++ b/framework/testing/simple_module_test/plugin.py @@ -33,6 +33,7 @@ settings, setup_pending_app, ) +from simple_module_test.mail import _reset_process_mail_sender, captured_mail # noqa: F401 from simple_module_test.redis_server import redis_server # noqa: F401 from simple_module_test.tenant_client import tenant_client # noqa: F401 diff --git a/host/migrations/versions/d7b3a9e5c1f2_mail_settings_keys_from_users.py b/host/migrations/versions/d7b3a9e5c1f2_mail_settings_keys_from_users.py new file mode 100644 index 000000000..1454e6c09 --- /dev/null +++ b/host/migrations/versions/d7b3a9e5c1f2_mail_settings_keys_from_users.py @@ -0,0 +1,63 @@ +"""move the users mailer settings to the mail module + +Revision ID: d7b3a9e5c1f2 +Revises: c8e4f1a2b6d9 +Create Date: 2026-10-20 10:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "d7b3a9e5c1f2" +down_revision: str | None = "c8e4f1a2b6d9" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +# SMTP transport and its settings moved from ``users`` to the ``mail`` module +# (GH #496). Data only: rewrites the key of system-scope rows in the settings +# table. An existing target row wins, so a re-run (or an admin who already set +# the new keys) is safe and the stale row is simply dropped. + +_TABLE = "settings_setting" +_SCOPE_SYSTEM = "system" +_SYSTEM_SCOPE_ID = "" + +_KEY_PAIRS = ( + ("users.mailer", "mail.backend"), + *((f"users.smtp_{f}", f"mail.smtp_{f}") for f in ("host", "port", "username", "password")), + ("users.smtp_from", "mail.smtp_from"), + ("users.smtp_tls", "mail.smtp_tls"), +) + + +def _rename(old: str, new: str) -> None: + scope = {"scope": _SCOPE_SYSTEM, "scope_id": _SYSTEM_SCOPE_ID, "old": old, "new": new} + bind = op.get_bind() + bind.execute( + sa.text( + f"DELETE FROM {_TABLE} WHERE scope = :scope AND scope_id = :scope_id AND key = :old" + f" AND EXISTS (SELECT 1 FROM {_TABLE} AS t WHERE t.scope = :scope" + " AND t.scope_id = :scope_id AND t.key = :new)" + ), + scope, + ) + bind.execute( + sa.text( + f"UPDATE {_TABLE} SET key = :new" + " WHERE scope = :scope AND scope_id = :scope_id AND key = :old" + ), + scope, + ) + + +def upgrade() -> None: + for old, new in _KEY_PAIRS: + _rename(old, new) + + +def downgrade() -> None: + for old, new in _KEY_PAIRS: + _rename(new, old) diff --git a/host/pyproject.toml b/host/pyproject.toml index 5c1efbd16..0ea4952f6 100644 --- a/host/pyproject.toml +++ b/host/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "simple_module_site_lock", "simple_module_tenants", "simple_module_api_tokens", + "simple_module_mail", "python-multipart>=0.0.6", ] @@ -34,3 +35,4 @@ simple_module_branding = { workspace = true } simple_module_site_lock = { workspace = true } simple_module_tenants = { workspace = true } simple_module_api_tokens = { workspace = true } +simple_module_mail = { workspace = true } diff --git a/host/tests/test_migration_graph.py b/host/tests/test_migration_graph.py index fe4ef0e6f..0fc0ae4be 100644 --- a/host/tests/test_migration_graph.py +++ b/host/tests/test_migration_graph.py @@ -30,6 +30,7 @@ "d4e8a1b6c392": "users", "b5018b617142": "users", "c8e4f1a2b6d9": "users", + "d7b3a9e5c1f2": "mail", # data-only: users.* settings keys -> mail.* "a659221c464b": "file_storage", "e5a8c1f27b94": "file_storage", "c7f2d9a41e83": "file_storage", @@ -50,7 +51,7 @@ # head revision -> module owning the tables it creates or alters. _HEADS = { "a7c2e91d4b58": "audit_log", - "c8e4f1a2b6d9": "users", # reported as ``api_tokens`` (inherited label) + "d7b3a9e5c1f2": "mail", # reported as ``api_tokens`` (inherited label) "f1a9c3d7b2e4": "tenants", "f6a3b9d2e8c4": "keycloak", } @@ -80,6 +81,7 @@ "92965b00f105": "api_tokens", "b5018b617142": "api_tokens", "c8e4f1a2b6d9": "api_tokens", + "d7b3a9e5c1f2": "api_tokens", } diff --git a/modules/mail/LICENSE b/modules/mail/LICENSE new file mode 100644 index 000000000..9cf106272 --- /dev/null +++ b/modules/mail/LICENSE @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/modules/mail/README.md b/modules/mail/README.md new file mode 100644 index 000000000..104efd8cf --- /dev/null +++ b/modules/mail/README.md @@ -0,0 +1,50 @@ +# simple_module_mail + +Outgoing mail for `simple_module` apps. The module owns the transport (SMTP via +`aiosmtplib`, or a console backend that logs each message) and its settings, and +installs a process-level sender that any module can use through +`simple_module_core.mail` without knowing whether mail is configured. + +The default backend is `console`: messages are logged, nothing is delivered. +Switch to `smtp` and fill in the server details to deliver real email. + +## Install + +Add the package to your host and re-sync. Discovery picks it up from the +`simple_module` entry point, no host code changes: + +```toml +# host/pyproject.toml +dependencies = ["simple_module_mail"] +``` + +```bash +uv sync --all-packages +``` + +## Usage + +1. Sign in as an admin and open **Settings -> Modules -> Mail**. +2. Set `backend` to `smtp` and fill `smtp_host`, `smtp_port`, `smtp_username`, + `smtp_password` (masked in the UI), `smtp_from` and `smtp_tls`. Saving + rebuilds the sender live, no restart needed. +3. Use **Test connection** to run the `mail.mailer` health check. It logs in to + the server and hangs up without sending anything. + +From code, send through the core interface: + +```python +from simple_module_core.mail import MailMessage, current_mail_sender + +await current_mail_sender().send( + MailMessage(to="a@example.com", subject="Hello", text="Plain body") +) +``` + +Code that may run in the outbox worker (no `app`) calls +`await resolve_mail_sender(db)` instead; the module installs a resolver that +builds the sender from the stored settings. `sender.delivers_email` is `False` +for the console backend, so screens can fall back to showing a link. + +Settings stored by earlier versions as `users.mailer` / `users.smtp_*` are moved +to `mail.backend` / `mail.smtp_*` by a data migration. diff --git a/modules/mail/mail/__init__.py b/modules/mail/mail/__init__.py new file mode 100644 index 000000000..ec1557c3e --- /dev/null +++ b/modules/mail/mail/__init__.py @@ -0,0 +1 @@ +"""Mail module.""" diff --git a/modules/mail/mail/constants.py b/modules/mail/mail/constants.py new file mode 100644 index 000000000..aba3f76c5 --- /dev/null +++ b/modules/mail/mail/constants.py @@ -0,0 +1,24 @@ +"""Every literal the mail module depends on.""" + +from __future__ import annotations + +MODULE_NAME = "Mail" +MODULE_PACKAGE = "mail" + +# Dependency names must match the other modules' ``ModuleMeta.name``. +MODULE_SETTINGS = "Settings" + +BACKEND_CONSOLE = "console" +BACKEND_SMTP = "smtp" + +# On-demand health check; also drives the settings page's "Test connection". +CHECK_MAILER = "mail.mailer" + +DEFAULT_FROM = "no-reply@localhost" +DEFAULT_SMTP_PORT = 587 +MIN_PORT = 1 +MAX_PORT = 65535 + +#: Seconds per SMTP operation (aiosmtplib defaults to 60). A send can run in a +#: request's after-commit hook, so a hung server must not hold it for a minute. +SMTP_TIMEOUT_SECONDS = 15.0 diff --git a/modules/mail/mail/module.py b/modules/mail/mail/module.py new file mode 100644 index 000000000..c094117a3 --- /dev/null +++ b/modules/mail/mail/module.py @@ -0,0 +1,75 @@ +"""Mail module — the app's outgoing-mail transport, configured from the DB.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from simple_module_core.health import HealthCheck, HealthRegistry +from simple_module_core.mail import install_mail_resolver, install_mail_sender +from simple_module_core.module import ModuleBase, ModuleMeta + +from mail import constants as c + +if TYPE_CHECKING: + from fastapi import FastAPI + from simple_module_core.events import EventBus + + +class MailModule(ModuleBase): + meta = ModuleMeta( + name=c.MODULE_NAME, + # ``Settings`` so register_module_settings can reach the registry. + depends_on=[c.MODULE_SETTINGS], + ) + + def register_settings(self, app: FastAPI) -> None: + from settings.registration import register_module_settings + + from mail.settings import MailSettings + from mail.state import MailState + + register_module_settings( + app, c.MODULE_PACKAGE, MailSettings, lambda s: MailState(settings=s) + ) + + def register_health_checks(self, registry: HealthRegistry) -> None: + from mail.runtime import check_smtp + + registry.add( + HealthCheck( + name=c.CHECK_MAILER, + check=check_smtp, + # On demand only: this authenticates against the mail provider, + # which must not happen on a readiness-probe timer. + probe=False, + ) + ) + + def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: + # Before the ``app is None`` return: the outbox worker builds modules + # without an app and still has to send durable mail. + from mail.runtime import resolve_sender_from_db + + install_mail_resolver(resolve_sender_from_db) + if app is None: + return + + from settings.contracts.events import SettingsReloaded + + from mail.runtime import install_app_sender + + async def _reinstall(event: SettingsReloaded) -> None: + if event.package != c.MODULE_PACKAGE: + return + install_app_sender(app) + + bus.subscribe(SettingsReloaded, _reinstall) + + async def on_startup(self, app: FastAPI) -> None: + from mail.runtime import install_app_sender + + install_app_sender(app) + + async def on_shutdown(self, app: FastAPI) -> None: + install_mail_sender(None) + install_mail_resolver(None) diff --git a/modules/mail/mail/py.typed b/modules/mail/mail/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/modules/mail/mail/runtime.py b/modules/mail/mail/runtime.py new file mode 100644 index 000000000..8cc89bb8f --- /dev/null +++ b/modules/mail/mail/runtime.py @@ -0,0 +1,63 @@ +"""Resolver and health check: the parts that work without an app.""" + +from __future__ import annotations + +import os +from typing import Any + +from settings.hydrate import hydrate_settings +from settings.service import SettingService +from settings.store import SettingsStore +from simple_module_core.environments import NON_PROD_ENVIRONMENTS +from simple_module_core.health import HealthCheckResult, HealthStatus +from simple_module_core.mail import MailSender, current_mail_sender, install_mail_sender + +from mail import constants as c +from mail.sender import build_sender +from mail.settings import MailSettings + + +async def resolve_sender_from_db(db: Any) -> MailSender: + """Build a sender from the stored settings; used where none is installed. + + The outbox worker builds modules without an app, so it has only a session. + """ + store = SettingsStore(SettingService(db)) + settings = await hydrate_settings(MailSettings, store, c.MODULE_PACKAGE, lenient=True) + return build_sender(settings, log_bodies=_worker_is_non_prod()) + + +def _worker_is_non_prod() -> bool: + # No app (and so no host settings) in the worker: read the process's + # environment the way background_tasks' own checks do. + return os.environ.get("SM_ENVIRONMENT", "development") in NON_PROD_ENVIRONMENTS + + +def install_app_sender(app: Any) -> None: + """Install a sender built from *app*'s current mail settings. + + Bodies are logged only in development or testing (host ``environment``). + """ + log_bodies = app.state.sm.settings.environment in NON_PROD_ENVIRONMENTS + install_mail_sender(build_sender(app.state.mail.settings, log_bodies=log_bodies)) + + +async def check_smtp() -> HealthCheckResult: + """Verify the installed sender, re-read on every run. + + Settings can change after boot, so a check pinned to the boot-time sender + would keep testing credentials the admin has replaced. + """ + sender = current_mail_sender() + verify = getattr(sender, "verify_connection", None) + if verify is None: + return HealthCheckResult( + status=HealthStatus.HEALTHY, + detail=f"{type(sender).__name__} needs no connection", + ) + try: + await verify() + except Exception as exc: + # The reason matters more than the traceback. + return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc)) + return HealthCheckResult(status=HealthStatus.HEALTHY, detail="SMTP reachable") diff --git a/modules/mail/mail/sender.py b/modules/mail/mail/sender.py new file mode 100644 index 000000000..a5d0ae8a9 --- /dev/null +++ b/modules/mail/mail/sender.py @@ -0,0 +1,94 @@ +"""Mail senders and the settings -> sender mapping.""" + +from __future__ import annotations + +import contextlib +from email.message import EmailMessage + +import aiosmtplib +from simple_module_core.mail import ConsoleMailSender, MailMessage, MailSender + +from mail import constants as c +from mail.settings import MailSettings + + +class SmtpMailSender: + """Delivers through an SMTP server with aiosmtplib.""" + + delivers_email = True + + def __init__( + self, + host: str, + port: int, + username: str, + password: str, + from_address: str, + use_tls: bool, + ) -> None: + self._host = host + self._port = port + self._username = username + self._password = password + self._from = from_address + self._use_tls = use_tls + + async def send(self, message: MailMessage) -> None: + email = EmailMessage() + email["From"] = self._from + email["To"] = message.to + email["Subject"] = message.subject + email.set_content(message.text) + if message.html: + email.add_alternative(message.html, subtype="html") + + await aiosmtplib.send( + email, + hostname=self._host, + port=self._port, + username=self._username or None, + password=self._password or None, + use_tls=self._use_tls, + timeout=c.SMTP_TIMEOUT_SECONDS, + ) + + async def verify_connection(self) -> None: + """Open an SMTP session and authenticate, then hang up. + + Deliberately stops short of sending anything: an admin checking their + config should not put a stray message in someone's inbox. Raises + whatever aiosmtplib raises so the caller can show the real reason. + """ + client = aiosmtplib.SMTP( + hostname=self._host, + port=self._port, + use_tls=self._use_tls, + timeout=c.SMTP_TIMEOUT_SECONDS, + ) + await client.connect() + try: + if self._username: + await client.login(self._username, self._password or "") + finally: + # A failure hanging up must not mask the login error being unwound. + with contextlib.suppress(Exception): + await client.quit() + + +def build_sender(settings: MailSettings, *, log_bodies: bool = True) -> MailSender: + """``smtp`` -> :class:`SmtpMailSender`; ``console`` -> a logging sender. + + *log_bodies* is false outside development and testing: a console backend + left in place in production must not write reset and invite tokens to the + log, so there it logs only recipient and subject. + """ + if settings.backend == c.BACKEND_SMTP: + return SmtpMailSender( + host=settings.smtp_host, + port=settings.smtp_port, + username=settings.smtp_username, + password=settings.smtp_password, + from_address=settings.smtp_from, + use_tls=settings.smtp_tls, + ) + return ConsoleMailSender(include_body=log_bodies) diff --git a/modules/mail/mail/settings.py b/modules/mail/mail/settings.py new file mode 100644 index 000000000..e80acf716 --- /dev/null +++ b/modules/mail/mail/settings.py @@ -0,0 +1,30 @@ +"""Mail module settings — DB-backed via ``register_module_settings``. + +``smtp_password`` is masked by the settings UI because its name matches the +secret-field pattern (``settings._secrets.is_secret_field``). +""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import Field +from pydantic_settings import SettingsConfigDict +from simple_module_core.settings_base import DbBackedSettings + +from mail import constants as c + + +class MailSettings(DbBackedSettings): + """Which transport sends mail, and the SMTP account it uses.""" + + # ``DbBackedSettings`` so the DB is the only source (GH #283). + model_config = SettingsConfigDict(extra="ignore") + + backend: Literal["console", "smtp"] = c.BACKEND_CONSOLE + smtp_host: str = "" + smtp_port: int = Field(default=c.DEFAULT_SMTP_PORT, ge=c.MIN_PORT, le=c.MAX_PORT) + smtp_username: str = "" + smtp_password: str = "" + smtp_from: str = c.DEFAULT_FROM + smtp_tls: bool = True diff --git a/modules/mail/mail/state.py b/modules/mail/mail/state.py new file mode 100644 index 000000000..56b1e9093 --- /dev/null +++ b/modules/mail/mail/state.py @@ -0,0 +1,14 @@ +"""Module-owned state attached to ``app.state.mail``.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from mail.settings import MailSettings + + +@dataclass +class MailState: + """``settings`` is reassigned in place on a hot reload, so stay mutable.""" + + settings: MailSettings diff --git a/modules/mail/pyproject.toml b/modules/mail/pyproject.toml new file mode 100644 index 000000000..7d08c68de --- /dev/null +++ b/modules/mail/pyproject.toml @@ -0,0 +1,38 @@ +[project] +name = "simple_module_mail" +version = "0.0.37" +description = "Outgoing mail (SMTP or console) for simple_module apps, configured from the DB-backed settings" +readme = "README.md" +license = "MIT" +license-files = ["LICENSE"] +requires-python = ">=3.12" +authors = [{ name = "Anto Subash", email = "antosubash@live.com" }] +keywords = ["simple-module", "mail", "smtp", "email"] +dependencies = [ + "simple_module_core==0.0.37", + "simple_module_settings==0.0.37", + "aiosmtplib>=3.0", +] + +[project.entry-points.simple_module] +mail = "mail.module:MailModule" + +[project.urls] +Homepage = "https://github.com/antosubash/simple_module_python" +Repository = "https://github.com/antosubash/simple_module_python" +Issues = "https://github.com/antosubash/simple_module_python/issues" +Changelog = "https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +# The distribution name (simple_module_mail) doesn't match the package +# directory (mail), so hatchling can't infer what to ship — without +# this the wheel builds empty and the entry point fails to import. +[tool.hatch.build.targets.wheel] +packages = ["mail"] + +[tool.uv.sources] +simple_module_core = { workspace = true } +simple_module_settings = { workspace = true } diff --git a/modules/mail/tests/test_mail_health.py b/modules/mail/tests/test_mail_health.py new file mode 100644 index 000000000..8180b1fb2 --- /dev/null +++ b/modules/mail/tests/test_mail_health.py @@ -0,0 +1,56 @@ +"""The ``mail.mailer`` check re-reads the installed sender on every run.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +from mail.runtime import check_smtp +from mail.sender import SmtpMailSender +from simple_module_core import mail as core_mail +from simple_module_core.health import HealthStatus +from simple_module_core.mail import ConsoleMailSender + + +async def test_console_is_healthy_without_a_connection() -> None: + core_mail.install_mail_sender(ConsoleMailSender()) + result = await check_smtp() + assert result.status is HealthStatus.HEALTHY + assert "needs no connection" in result.detail + + +async def test_nothing_installed_is_also_healthy() -> None: + core_mail.install_mail_sender(None) + assert (await check_smtp()).status is HealthStatus.HEALTHY + + +@pytest.fixture +def smtp_client(monkeypatch) -> MagicMock: + client = MagicMock() + client.connect = AsyncMock() + client.login = AsyncMock() + client.quit = AsyncMock() + monkeypatch.setattr("mail.sender.aiosmtplib.SMTP", MagicMock(return_value=client)) + core_mail.install_mail_sender(SmtpMailSender("h", 587, "user", "pw", "f@test", True)) + return client + + +async def test_smtp_reachable(smtp_client) -> None: + result = await check_smtp() + assert result.status is HealthStatus.HEALTHY + smtp_client.login.assert_awaited_once_with("user", "pw") + smtp_client.quit.assert_awaited_once() + + +async def test_smtp_failure_reports_the_reason(smtp_client) -> None: + smtp_client.login.side_effect = RuntimeError("authentication failed") + result = await check_smtp() + assert result.status is HealthStatus.UNHEALTHY + assert result.detail == "authentication failed" + smtp_client.quit.assert_awaited_once() + + +async def test_the_check_is_registered_on_demand_only(app) -> None: + checks = {c.name: c for c in app.state.sm.health_registry.all_checks} + assert checks["mail.mailer"].probe is False + assert checks["mail.mailer"].module == "Mail" diff --git a/modules/mail/tests/test_mail_lifecycle.py b/modules/mail/tests/test_mail_lifecycle.py new file mode 100644 index 000000000..0e4649af9 --- /dev/null +++ b/modules/mail/tests/test_mail_lifecycle.py @@ -0,0 +1,61 @@ +"""Startup installs the sender, a reload reinstalls it, the resolver reads the DB.""" + +from __future__ import annotations + +from mail.sender import SmtpMailSender +from settings.contracts.events import SettingsReloaded +from settings.reload import apply_changes_and_reload +from settings.service import SettingService +from settings.store import SettingsStore +from simple_module_core import mail as core_mail +from simple_module_core.mail import ConsoleMailSender, resolve_mail_sender + + +async def test_startup_installs_the_configured_sender(app) -> None: + sender = core_mail.current_mail_sender() + assert isinstance(sender, ConsoleMailSender) + assert sender.include_body is True # the explicit backend, not the fallback + + +async def test_a_settings_reload_reinstalls_the_sender(app, db_session) -> None: + store = SettingsStore(SettingService(db_session)) + await apply_changes_and_reload( + app, + app.state.sm.event_bus, + store, + package="mail", + changes={"backend": "smtp", "smtp_host": "smtp.test"}, + ) + assert isinstance(core_mail.current_mail_sender(), SmtpMailSender) + + +async def test_another_packages_reload_is_ignored(app) -> None: + before = core_mail.current_mail_sender() + await app.state.sm.event_bus.publish(SettingsReloaded(package="users", changed=("x",))) + assert core_mail.current_mail_sender() is before + + +async def test_the_resolver_is_installed_and_shutdown_clears_both(app) -> None: + assert core_mail._resolver is not None + module = next(m for m in app.state.sm.modules if m.meta.name == "Mail") + await module.on_shutdown(app) + assert core_mail._sender is None and core_mail._resolver is None + + +async def test_the_resolver_hydrates_stored_settings(app, db_session) -> None: + core_mail.install_mail_sender(None) + store = SettingsStore(SettingService(db_session)) + await store.set_override("mail", "backend", "smtp", "string") + await store.set_override("mail", "smtp_host", "db.test", "string") + await store.set_override("mail", "smtp_port", "2525", "int") + + sender = await resolve_mail_sender(db_session) + + assert isinstance(sender, SmtpMailSender) + assert (sender._host, sender._port) == ("db.test", 2525) + + +async def test_the_resolver_falls_back_to_console_without_rows(app, db_session) -> None: + core_mail.install_mail_sender(None) + sender = await resolve_mail_sender(db_session) + assert isinstance(sender, ConsoleMailSender) diff --git a/modules/mail/tests/test_mail_sender.py b/modules/mail/tests/test_mail_sender.py new file mode 100644 index 000000000..9687131d9 --- /dev/null +++ b/modules/mail/tests/test_mail_sender.py @@ -0,0 +1,101 @@ +"""Settings -> sender selection and the message SmtpMailSender builds.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from mail.sender import SmtpMailSender, build_sender +from mail.settings import MailSettings +from simple_module_core.mail import ConsoleMailSender, MailMessage + + +def test_defaults() -> None: + s = MailSettings() + assert (s.backend, s.smtp_host, s.smtp_port) == ("console", "", 587) + assert (s.smtp_username, s.smtp_password) == ("", "") + assert s.smtp_from == "no-reply@localhost" + assert s.smtp_tls is True + + +def test_console_backend_builds_a_logging_sender_with_bodies() -> None: + sender = build_sender(MailSettings()) + assert isinstance(sender, ConsoleMailSender) + assert sender.include_body is True + assert sender.delivers_email is False + + +def test_smtp_backend_builds_an_smtp_sender() -> None: + sender = build_sender(MailSettings(backend="smtp", smtp_host="smtp.test")) + assert isinstance(sender, SmtpMailSender) + assert sender.delivers_email is True + + +def test_unknown_backend_is_rejected() -> None: + with pytest.raises(ValueError): + MailSettings(backend="carrier-pigeon") + + +def _smtp() -> SmtpMailSender: + return SmtpMailSender("smtp.test", 2525, "user", "pw", "from@test", True) + + +async def test_send_builds_a_plain_text_message(monkeypatch) -> None: + send = AsyncMock() + monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + + await _smtp().send(MailMessage(to="to@test", subject="Hi", text="Body")) + + (email,), kwargs = send.call_args + assert (email["From"], email["To"], email["Subject"]) == ("from@test", "to@test", "Hi") + assert email.get_content_type() == "text/plain" + assert email.get_content().strip() == "Body" + assert kwargs == { + "hostname": "smtp.test", + "port": 2525, + "username": "user", + "password": "pw", + "use_tls": True, + "timeout": 15.0, + } + + +async def test_send_adds_an_html_alternative(monkeypatch) -> None: + send = AsyncMock() + monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + + await _smtp().send(MailMessage(to="to@test", subject="Hi", text="Body", html="Body")) + + (email,), _ = send.call_args + assert email.get_content_type() == "multipart/alternative" + parts = {p.get_content_type(): p.get_content().strip() for p in email.iter_parts()} + assert parts == {"text/plain": "Body", "text/html": "Body"} + + +async def test_blank_credentials_are_sent_as_none(monkeypatch) -> None: + send = AsyncMock() + monkeypatch.setattr("mail.sender.aiosmtplib.send", send) + + sender = SmtpMailSender("h", 25, "", "", "f@test", False) + await sender.send(MailMessage(to="t@test", subject="s", text="b")) + + _, kwargs = send.call_args + assert kwargs["username"] is None and kwargs["password"] is None + + +def test_console_backend_outside_development_does_not_log_bodies() -> None: + """Reset and invite bodies carry one-time tokens (production console backend).""" + from mail.sender import build_sender + from mail.settings import MailSettings + + assert build_sender(MailSettings(), log_bodies=False).include_body is False + assert build_sender(MailSettings()).include_body is True + + +@pytest.mark.parametrize("port", [-1, 0, 65536, 99999]) +def test_smtp_port_outside_the_tcp_range_is_rejected(port) -> None: + from mail.settings import MailSettings + from pydantic import ValidationError + + with pytest.raises(ValidationError): + MailSettings(smtp_port=port) diff --git a/modules/mail/tests/test_mail_settings_migration.py b/modules/mail/tests/test_mail_settings_migration.py new file mode 100644 index 000000000..ec9d61646 --- /dev/null +++ b/modules/mail/tests/test_mail_settings_migration.py @@ -0,0 +1,109 @@ +"""Migration ``d7b3a9e5c1f2`` moves ``users.*`` mailer keys to ``mail.*`` (GH #496).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic import command +from alembic.config import Config + +BEFORE = "c8e4f1a2b6d9" +REVISION = "d7b3a9e5c1f2" +SCRIPTS = Path(__file__).resolve().parents[3] / "host" / "migrations" + +pytestmark = pytest.mark.filterwarnings("ignore:Skipped unsupported reflection") + +_INSERT = sa.text( + "INSERT INTO settings_setting (scope, scope_id, key, value, value_type, created_at," + " updated_at) VALUES (:scope, :scope_id, :key, :value, 'string', CURRENT_TIMESTAMP," + " CURRENT_TIMESTAMP)" +) + + +@pytest.fixture +def migrate(tmp_path, monkeypatch): + db_file = tmp_path / "migrate.db" + monkeypatch.setenv("SM_DATABASE_URL", f"sqlite+aiosqlite:///{db_file}") + config = Config() + config.set_main_option("script_location", str(SCRIPTS)) + engine = sa.create_engine(f"sqlite:///{db_file}") + yield config, engine + engine.dispose() + + +def _seed(engine, rows: list[tuple[str, str, str, str]]) -> None: + with engine.begin() as conn: + for scope, scope_id, key, value in rows: + conn.execute( + _INSERT, {"scope": scope, "scope_id": scope_id, "key": key, "value": value} + ) + + +def _rows(engine) -> set[tuple[str, str]]: + with engine.connect() as conn: + return {(k, v) for k, v in conn.execute(sa.text("SELECT key, value FROM settings_setting"))} + + +def test_upgrade_renames_and_downgrade_reverses(migrate): + config, engine = migrate + command.upgrade(config, BEFORE) + _seed( + engine, + [ + ("system", "", "users.mailer", "smtp"), + ("system", "", "users.smtp_host", "h"), + ("system", "", "users.smtp_port", "25"), + ("system", "", "users.smtp_username", "u"), + ("system", "", "users.smtp_password", "p"), + ("system", "", "users.smtp_from", "f@x"), + ("system", "", "users.smtp_tls", "False"), + ("system", "", "users.allow_signup", "True"), + ], + ) + command.upgrade(config, REVISION) + assert _rows(engine) == { + ("mail.backend", "smtp"), + ("mail.smtp_host", "h"), + ("mail.smtp_port", "25"), + ("mail.smtp_username", "u"), + ("mail.smtp_password", "p"), + ("mail.smtp_from", "f@x"), + ("mail.smtp_tls", "False"), + ("users.allow_signup", "True"), + } + + command.downgrade(config, BEFORE) + assert ("users.mailer", "smtp") in _rows(engine) + assert ("users.smtp_host", "h") in _rows(engine) + assert not any(k.startswith("mail.") for k, _ in _rows(engine)) + + +def test_an_existing_mail_row_wins(migrate): + config, engine = migrate + command.upgrade(config, BEFORE) + _seed( + engine, + [ + ("system", "", "users.smtp_host", "old"), + ("system", "", "mail.smtp_host", "new"), + ], + ) + command.upgrade(config, REVISION) + assert _rows(engine) == {("mail.smtp_host", "new")} + + +def test_only_system_scope_rows_move(migrate): + config, engine = migrate + command.upgrade(config, BEFORE) + _seed(engine, [("tenant", "t1", "users.smtp_host", "tenant-host")]) + command.upgrade(config, REVISION) + assert _rows(engine) == {("users.smtp_host", "tenant-host")} + + +def test_an_empty_table_is_a_no_op(migrate): + config, engine = migrate + command.upgrade(config, REVISION) + command.downgrade(config, BEFORE) + assert _rows(engine) == set() diff --git a/modules/settings/settings/_module_settings.py b/modules/settings/settings/_module_settings.py index b45ec746c..0dc509ddf 100644 --- a/modules/settings/settings/_module_settings.py +++ b/modules/settings/settings/_module_settings.py @@ -49,7 +49,7 @@ class ModuleSettingField: The bundled module settings classes declare no ``env_prefix`` — they are constructed from pydantic defaults and hydrated from the DB — so their ``SM_*`` vars are never consulted. Reporting a leftover - ``SM_USERS_SMTP_HOST`` as the live source would invert the very question + ``SM_MAIL_SMTP_HOST`` as the live source would invert the very question this screen answers. Classes that *do* declare one (the host's ``Settings`` with ``SM_``, and anything from the module scaffold) report ``env`` for real, because for them pydantic really does read it. diff --git a/modules/settings/settings/_row_masking.py b/modules/settings/settings/_row_masking.py index cb1b83e0f..b33f73ff5 100644 --- a/modules/settings/settings/_row_masking.py +++ b/modules/settings/settings/_row_masking.py @@ -21,7 +21,7 @@ def is_masked(entity: Setting) -> bool: :data:`SENSITIVE_KEYS` names the one key the hosting layer owns (``host.secret_key``). Everything else is judged by the same name/value rule the module editor uses, because the store is the *same data* seen - through a different screen: an override named ``users.smtp_password``, or + through a different screen: an override named ``mail.smtp_password``, or any override holding a ``postgresql://user:pw@host/db``, was rendered in clear text in the browse table and pre-filled into the edit form purely because the allowlist had a single entry in it. diff --git a/modules/settings/settings/cli.py b/modules/settings/settings/cli.py index 08fe8ca4b..1ee675eaa 100644 --- a/modules/settings/settings/cli.py +++ b/modules/settings/settings/cli.py @@ -71,7 +71,7 @@ async def run() -> int: if n == 0 and find_env_file().is_file(): typer.echo( "Hint: variables must be named SM__ " - "(e.g. SM_USERS_SMTP_PORT); none in the environment or .env matched." + "(e.g. SM_MAIL_SMTP_PORT); none in the environment or .env matched." ) return 0 diff --git a/modules/settings/tests/test_cli_import.py b/modules/settings/tests/test_cli_import.py index 17c650f03..e03fa3721 100644 --- a/modules/settings/tests/test_cli_import.py +++ b/modules/settings/tests/test_cli_import.py @@ -34,7 +34,7 @@ async def test_import_from_env_writes_overrides(db_session, monkeypatch, app) -> # the assertion exact as the settings surface grows. _drop_fixture_env(monkeypatch) monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "true") - monkeypatch.setenv("SM_USERS_SMTP_PORT", "2525") + monkeypatch.setenv("SM_MAIL_SMTP_PORT", "2525") monkeypatch.setenv("SM_BG_TASKS_RETENTION_DAYS", "30") from settings.cli import import_from_env_impl @@ -45,9 +45,10 @@ async def test_import_from_env_writes_overrides(db_session, monkeypatch, app) -> n = await import_from_env_impl(app, store) users = await store.get_overrides("users") + mail = await store.get_overrides("mail") bg = await store.get_overrides("background_tasks") assert users["allow_signup"] == ("true", "bool") - assert users["smtp_port"] == ("2525", "int") + assert mail["smtp_port"] == ("2525", "int") assert bg["retention_days"] == ("30", "int") assert n == 3 @@ -72,9 +73,9 @@ async def test_import_ignores_unknown_env(db_session, monkeypatch, app) -> None: @pytest.mark.asyncio async def test_import_reads_dotenv_by_default(db_session, monkeypatch, app, tmp_path) -> None: _drop_fixture_env(monkeypatch) - monkeypatch.delenv("SM_USERS_SMTP_PORT", raising=False) + monkeypatch.delenv("SM_MAIL_SMTP_PORT", raising=False) monkeypatch.delenv("SM_USERS_ALLOW_SIGNUP", raising=False) - (tmp_path / ".env").write_text("SM_USERS_SMTP_PORT=2626\nSM_USERS_ALLOW_SIGNUP=true\n") + (tmp_path / ".env").write_text("SM_MAIL_SMTP_PORT=2626\nSM_USERS_ALLOW_SIGNUP=true\n") monkeypatch.setenv("SM_PROJECT_ROOT", str(tmp_path)) # Real env wins over the file. monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "false") @@ -87,7 +88,7 @@ async def test_import_reads_dotenv_by_default(db_session, monkeypatch, app, tmp_ n = await import_from_env_impl(app, store) users = await store.get_overrides("users") - assert users["smtp_port"] == ("2626", "int") + assert (await store.get_overrides("mail"))["smtp_port"] == ("2626", "int") assert users["allow_signup"] == ("false", "bool") assert n == 2 @@ -95,14 +96,14 @@ async def test_import_reads_dotenv_by_default(db_session, monkeypatch, app, tmp_ @pytest.mark.asyncio async def test_import_accepts_explicit_environ(db_session, monkeypatch, app) -> None: _drop_fixture_env(monkeypatch) - monkeypatch.delenv("SM_USERS_SMTP_PORT", raising=False) + monkeypatch.delenv("SM_MAIL_SMTP_PORT", raising=False) from settings.cli import import_from_env_impl from settings.service import SettingService from settings.store import SettingsStore store = SettingsStore(SettingService(db_session)) - n = await import_from_env_impl(app, store, environ={"SM_USERS_SMTP_PORT": "2727"}) + n = await import_from_env_impl(app, store, environ={"SM_MAIL_SMTP_PORT": "2727"}) - assert (await store.get_overrides("users"))["smtp_port"] == ("2727", "int") + assert (await store.get_overrides("mail"))["smtp_port"] == ("2727", "int") assert n == 1 diff --git a/modules/settings/tests/test_known_keys_meta.py b/modules/settings/tests/test_known_keys_meta.py index a1bae926b..7784ffd7c 100644 --- a/modules/settings/tests/test_known_keys_meta.py +++ b/modules/settings/tests/test_known_keys_meta.py @@ -51,18 +51,18 @@ async def test_every_suggestion_carries_the_full_meta_shape( async def test_the_env_var_label_matches_the_module_prefix( self, authenticated_client: httpx.AsyncClient ) -> None: - mailer = _by_key(await _known_keys(authenticated_client), "users.mailer") + mailer = _by_key(await _known_keys(authenticated_client), "mail.backend") - assert mailer["env_var"] == "SM_USERS_MAILER" + assert mailer["env_var"] == "SM_MAIL_BACKEND" assert mailer["default"] == "console" async def test_a_db_backed_module_reports_its_env_var_as_not_read( self, authenticated_client: httpx.AsyncClient ) -> None: - """``UsersSettings`` declares no ``env_prefix``, so ``SM_USERS_*`` is a + """``MailSettings`` declares no ``env_prefix``, so ``SM_MAIL_*`` is a label and not a fallback. Saying otherwise inverts the question the Resolved value panel exists to answer.""" - mailer = _by_key(await _known_keys(authenticated_client), "users.mailer") + mailer = _by_key(await _known_keys(authenticated_client), "mail.backend") assert mailer["env_readable"] is False assert mailer["env_set"] is False @@ -71,14 +71,14 @@ async def test_a_db_backed_module_reports_its_env_var_as_not_read( async def test_a_pattern_constrained_string_advertises_its_choices( self, authenticated_client: httpx.AsyncClient ) -> None: - mailer = _by_key(await _known_keys(authenticated_client), "users.mailer") + mailer = _by_key(await _known_keys(authenticated_client), "mail.backend") assert mailer["choices"] == ["console", "smtp"] async def test_an_unconstrained_string_has_no_choices( self, authenticated_client: httpx.AsyncClient ) -> None: - host = _by_key(await _known_keys(authenticated_client), "users.smtp_host") + host = _by_key(await _known_keys(authenticated_client), "mail.smtp_host") assert host["choices"] is None @@ -132,10 +132,10 @@ async def test_a_module_field_wins_over_a_declaration_of_the_same_key( from settings.contracts.registry import SettingDefinition app.state.settings.registry.add( - SettingDefinition(key="users.mailer", default="wrong", description="stale") + SettingDefinition(key="mail.backend", default="wrong", description="stale") ) - mailer = [e for e in await _known_keys(authenticated_client) if e["key"] == "users.mailer"] + mailer = [e for e in await _known_keys(authenticated_client) if e["key"] == "mail.backend"] assert len(mailer) == 1 assert mailer[0]["default"] == "console" @@ -199,13 +199,13 @@ def test_an_unset_env_var_has_no_value_at_all(self, monkeypatch: pytest.MonkeyPa assert view.env_value is None def test_a_db_backed_class_reports_no_env_value(self, monkeypatch: pytest.MonkeyPatch) -> None: - """``UsersSettings`` declares no ``env_prefix``: the var is not a fallback.""" + """``MailSettings`` declares no ``env_prefix``: the var is not a fallback.""" + from mail.settings import MailSettings from settings._module_settings import _field_view - from users.settings import UsersSettings - monkeypatch.setenv("SM_USERS_SMTP_HOST", "not-a-fallback.example.com") + monkeypatch.setenv("SM_MAIL_SMTP_HOST", "not-a-fallback.example.com") - view = _field_view("smtp_host", UsersSettings(), "SM_USERS_") + view = _field_view("smtp_host", MailSettings(), "SM_MAIL_") assert view.env_readable is False assert view.env_value is None diff --git a/modules/settings/tests/test_testable_checks.py b/modules/settings/tests/test_testable_checks.py index 7bd008913..9688a1c90 100644 --- a/modules/settings/tests/test_testable_checks.py +++ b/modules/settings/tests/test_testable_checks.py @@ -29,7 +29,7 @@ async def test_it_maps_a_package_to_its_check_names( testable = await _testable(authenticated_client) assert isinstance(testable, dict) - assert testable["users"] == ["users.mailer"] + assert testable["mail"] == ["mail.mailer"] async def test_packages_without_checks_are_absent( self, authenticated_client: httpx.AsyncClient @@ -48,18 +48,18 @@ async def _ok() -> HealthCheckResult: return HealthCheckResult(status=HealthStatus.HEALTHY) app.state.sm.health_registry.add( - HealthCheck(name="users.aaa", check=_ok, module="Users", probe=False) + HealthCheck(name="mail.aaa", check=_ok, module="Mail", probe=False) ) - assert (await _testable(authenticated_client))["users"] == ["users.aaa", "users.mailer"] + assert (await _testable(authenticated_client))["mail"] == ["mail.aaa", "mail.mailer"] class TestTestConnectionStillRuns: async def test_the_named_check_reports_its_result( self, authenticated_client: httpx.AsyncClient ) -> None: - resp = await authenticated_client.post("/admin/settings/test-connection/users") + resp = await authenticated_client.post("/admin/settings/test-connection/mail") assert resp.status_code == 200, resp.text[:400] names = [c["name"] for c in resp.json()["checks"]] - assert "users.mailer" in names + assert "mail.mailer" in names diff --git a/modules/tenants/tenants/components/InviteForm.tsx b/modules/tenants/tenants/components/InviteForm.tsx index 4e2386add..ae63cef3b 100644 --- a/modules/tenants/tenants/components/InviteForm.tsx +++ b/modules/tenants/tenants/components/InviteForm.tsx @@ -32,10 +32,15 @@ export function InviteForm({ onInvited }: Props) { const [error, setError] = useState(null); const [acceptUrl, setAcceptUrl] = useState(null); + const [delivery, setDelivery] = useState({ emailed: false, email: '' }); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setSubmitting(true); setError(null); + // A new attempt replaces the previous result; a failure must not leave the + // last invitation's link and "emailed" banner beside its error. + setAcceptUrl(null); try { const response = await fetch('/api/tenants/current/invitations', { method: 'POST', @@ -47,9 +52,14 @@ export function InviteForm({ onInvited }: Props) { setError(await describe(response)); return; } - const data = (await response.json()) as { accept_url: string }; + const data = (await response.json()) as { + accept_url: string; + emailed: boolean; + email: string; + }; // Root-relative unless public_base_url is configured. setAcceptUrl(new URL(data.accept_url, window.location.origin).toString()); + setDelivery({ emailed: data.emailed, email: data.email }); setEmail(''); toast.success(t(keys.tenants.members.toast_invited)); onInvited(); @@ -110,7 +120,9 @@ export function InviteForm({ onInvited }: Props) { {acceptUrl && (

- {t(keys.tenants.members.invite_link_title)} + {delivery.emailed + ? t(keys.tenants.members.invite_emailed, { email: delivery.email }) + : t(keys.tenants.members.invite_share_link)}

{t(keys.tenants.members.invite_link_description)} diff --git a/modules/tenants/tenants/constants.py b/modules/tenants/tenants/constants.py index 640a18723..c4f59f0d6 100644 --- a/modules/tenants/tenants/constants.py +++ b/modules/tenants/tenants/constants.py @@ -64,3 +64,15 @@ class TenantStatus(StrEnum): PAGE_ADMIN = "Tenants/AdminBrowse" __all__ = ["TENANT_ROLE_PREFIX", "MembershipRole", "tenant_role"] + +INVITATION_MAIL_HANDLER = "tenants.send_invitation_email" +INVITATION_MAIL_SUBJECT = "You're invited to join {tenant_name}" +INVITATION_TEMPLATE = "templates/invitation.txt" +MAIL_URL_SCHEMES = frozenset({"http", "https"}) +MAIL_RELATIVE_WARNING = ( + "Invitation email skipped: configure tenants.public_base_url for absolute links" +) +MAIL_FAILURE_WARNING = "Invitation email failed after commit" +MAIL_NO_TRANSPORT_ERROR = ( + "No mail transport in this process: add Mail to SM_MODULES_ENABLED for the outbox worker" +) diff --git a/modules/tenants/tenants/contracts/events.py b/modules/tenants/tenants/contracts/events.py index c15b003fa..5d9a333f8 100644 --- a/modules/tenants/tenants/contracts/events.py +++ b/modules/tenants/tenants/contracts/events.py @@ -61,7 +61,7 @@ class InvitationCreated(Event): """Delivery hook: a mailer module sends ``accept_url`` to ``email``. ``accept_url`` is absolute only when the ``public_base_url`` setting is - set; otherwise it is root-relative and the mailer must prefix its origin. + set; otherwise it is root-relative and must not be emailed. """ tenant_id: str diff --git a/modules/tenants/tenants/contracts/schemas.py b/modules/tenants/tenants/contracts/schemas.py index 368c49610..8e022668e 100644 --- a/modules/tenants/tenants/contracts/schemas.py +++ b/modules/tenants/tenants/contracts/schemas.py @@ -102,6 +102,7 @@ class InvitationIssued(InvitationView): token: str accept_url: str + emailed: bool class InvitationPreview(SQLModel): diff --git a/modules/tenants/tenants/endpoints/api.py b/modules/tenants/tenants/endpoints/api.py index 2d14b5a4a..2f2b1c769 100644 --- a/modules/tenants/tenants/endpoints/api.py +++ b/modules/tenants/tenants/endpoints/api.py @@ -37,6 +37,7 @@ UserIdDep, ) from tenants.errors import TenantError +from tenants.invitation_mail import invitation_emailed from tenants.resolver import switch_active router = APIRouter() @@ -147,7 +148,9 @@ async def invite( data, base_url=request.app.state.tenants.settings.public_base_url, ) - return InvitationIssued(**invitation.model_dump(), token=token, accept_url=url) + return InvitationIssued( + **invitation.model_dump(), token=token, accept_url=url, emailed=invitation_emailed(url) + ) @router.delete("/current/invitations/{invitation_id}", status_code=204, dependencies=_MANAGE) diff --git a/modules/tenants/tenants/invitation_mail.py b/modules/tenants/tenants/invitation_mail.py new file mode 100644 index 000000000..7b4546b4b --- /dev/null +++ b/modules/tenants/tenants/invitation_mail.py @@ -0,0 +1,78 @@ +"""Render and deliver invitation mail.""" + +from __future__ import annotations + +import logging +from functools import cache +from importlib.resources import files +from string import Template +from urllib.parse import urlsplit + +from simple_module_core.durable_events import DurableContext +from simple_module_core.mail import ( + MailMessage, + current_mail_sender, + mail_transport_installed, + resolve_mail_sender, +) + +from tenants import constants as c +from tenants.contracts.events import InvitationCreated + +logger = logging.getLogger(__name__) + + +def absolute_accept_url(url: str) -> bool: + parsed = urlsplit(url) + return parsed.scheme in c.MAIL_URL_SCHEMES and bool(parsed.netloc) + + +def invitation_mail_wanted(url: str) -> bool: + """Whether to queue an invitation email at all (see ``InvitationService.create``).""" + # A sender that only logs (console backend) gets nothing either: the UI + # already hands the link back, and queuing would store the token for nothing. + if not mail_transport_installed() or not current_mail_sender().delivers_email: + return False + if not absolute_accept_url(url): + logger.warning(c.MAIL_RELATIVE_WARNING) + return False + return True + + +def invitation_emailed(url: str) -> bool: + return current_mail_sender().delivers_email and absolute_accept_url(url) + + +@cache +def _template() -> Template: + return Template(files(c.MODULE_PACKAGE).joinpath(c.INVITATION_TEMPLATE).read_text()) + + +def invitation_message(event: InvitationCreated) -> MailMessage: + return MailMessage( + to=event.email, + subject=c.INVITATION_MAIL_SUBJECT.format(tenant_name=event.tenant_name), + text=_template().substitute( + tenant_name=event.tenant_name, role=event.role, accept_url=event.accept_url + ), + ) + + +async def send_invitation_email(event: InvitationCreated, ctx: DurableContext) -> None: + """Propagate failures for outbox retries; never commit the worker session.""" + if not absolute_accept_url(event.accept_url): + logger.warning(c.MAIL_RELATIVE_WARNING) + return + if not mail_transport_installed(): + # The web process had a transport when it queued this; a worker started + # without the mail module must not mark it delivered. Fail and retry. + raise RuntimeError(c.MAIL_NO_TRANSPORT_ERROR) + sender = await resolve_mail_sender(ctx.db) + await sender.send(invitation_message(event)) + + +async def send_invitation_after_commit(event: InvitationCreated) -> None: + try: + await send_invitation_email(event, DurableContext(db=None, attempt=1)) + except Exception: + logger.warning(c.MAIL_FAILURE_WARNING, exc_info=True) diff --git a/modules/tenants/tenants/invitations.py b/modules/tenants/tenants/invitations.py index fd639212e..16f890470 100644 --- a/modules/tenants/tenants/invitations.py +++ b/modules/tenants/tenants/invitations.py @@ -1,8 +1,7 @@ """Invitations: an owner/admin invites an email; that user accepts while signed in. -Provider-agnostic on purpose — no user lookup, no mailer. The raw token is -returned once to the inviter (to copy) and carried on ``InvitationCreated`` -(for a mailer module to send); only its SHA-256 is stored. +Provider-agnostic: the raw token is returned once to the inviter and carried +on ``InvitationCreated`` for email delivery; only its SHA-256 is stored. """ from __future__ import annotations @@ -10,6 +9,7 @@ import hashlib import secrets from datetime import UTC, datetime, timedelta +from functools import partial from sqlalchemy import delete, func, select from sqlalchemy.exc import IntegrityError @@ -19,6 +19,7 @@ from tenants.contracts.events import InvitationCreated from tenants.contracts.schemas import InvitationCreate from tenants.errors import TenantError +from tenants.invitation_mail import invitation_mail_wanted, send_invitation_after_commit from tenants.models import Invitation, Tenant from tenants.service import TenantService @@ -97,9 +98,17 @@ async def create( # fails as a whole, so no savepoint is needed. raise TenantError("already_invited", status_code=409) from exc accept_url = f"{base_url.rstrip('/')}{ACCEPT_PATH}?token={token}" - self.tenants._after_commit( - InvitationCreated(tenant_id, tenant.name, data.email, data.role, accept_url) - ) + event = InvitationCreated(tenant_id, tenant.name, data.email, data.role, accept_url) + bus = self.tenants.bus + # Mail only when there is somewhere to send it: no transport means the + # fallback log, and a relative link is useless in an inbox. Either way + # the one-time token is then never persisted in the outbox. + mail = invitation_mail_wanted(accept_url) + durable = mail and bus is not None and bus.durable_transport is not None + if durable: + await bus.publish_durable(event, self.db) + send_now = partial(send_invitation_after_commit, event) if mail and not durable else None + self.tenants._after_commit(event, callback=send_now) return invitation, token, accept_url async def list_pending(self, tenant_id: str) -> list[Invitation]: diff --git a/modules/tenants/tenants/locales/en.json b/modules/tenants/tenants/locales/en.json index 421106e25..04811b26f 100644 --- a/modules/tenants/tenants/locales/en.json +++ b/modules/tenants/tenants/locales/en.json @@ -68,6 +68,8 @@ "invite_role_label": "Role", "invite_submit": "Send invitation", "invite_submitting": "Sending…", + "invite_emailed": "Invitation emailed to {email}", + "invite_share_link": "Not emailed — share this link", "invite_link_title": "Invitation link", "invite_link_description": "Share this link with the invitee — it will not be shown again.", "invite_link_copy": "Copy link", diff --git a/modules/tenants/tenants/module.py b/modules/tenants/tenants/module.py index c6e6bb70b..8f82c7918 100644 --- a/modules/tenants/tenants/module.py +++ b/modules/tenants/tenants/module.py @@ -21,6 +21,7 @@ if TYPE_CHECKING: from fastapi import APIRouter, FastAPI + from simple_module_core.events import EventBus from simple_module_core.invalidation import InvalidationBus from simple_module_core.permissions import PermissionRegistry from simple_module_core.tenancy import TenantExists @@ -50,6 +51,14 @@ class TenantsModule(ModuleBase): depends_on=[c._MODULE_AUTH, c._MODULE_SETTINGS], ) + def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None: + from tenants.contracts.events import InvitationCreated + from tenants.invitation_mail import send_invitation_email + + bus.subscribe_durable( + InvitationCreated, send_invitation_email, name=c.INVITATION_MAIL_HANDLER + ) + def register_settings(self, app: FastAPI) -> None: from simple_module_hosting.shared_props import register_inertia_shared_provider diff --git a/modules/tenants/tenants/service.py b/modules/tenants/tenants/service.py index 3b2d43a22..36279869d 100644 --- a/modules/tenants/tenants/service.py +++ b/modules/tenants/tenants/service.py @@ -1,8 +1,7 @@ """Tenant and membership business logic. -Every membership operation takes ``tenant_id`` explicitly and filters on it — -these tables are not ``MultiTenantMixin`` (see ``models``), so the scoping the -listener would otherwise add is done here, by hand, in one place. +Membership operations filter explicitly on ``tenant_id``: these registry +tables are not ``MultiTenantMixin`` (see ``models``), so scoping is done here. Side effects (domain events, cache invalidation) run *after commit* through ``_after_commit``: a billing handler must never create a customer for a tenant @@ -67,15 +66,20 @@ def __init__( self.entitlements = entitlements or UnlimitedEntitlements() self.pending: list[Callable[[], Awaitable[Any]]] = [] - # ── side effects ──────────────────────────────────────────── - - def _after_commit(self, *events: Event, invalidate: Sequence[str | None] = ()) -> None: + def _after_commit( + self, + *events: Event, + invalidate: Sequence[str | None] = (), + callback: Callable[[], Awaitable[None]] | None = None, + ) -> None: async def run() -> None: for key in invalidate: await self.invalidate(key) if self.bus is not None: for event in events: await self.bus.publish(event) + if callback is not None: # last: may wait on an SMTP server + await callback() on_commit = getattr(self.db, "on_commit", None) if on_commit is not None: diff --git a/modules/tenants/tenants/templates/invitation.txt b/modules/tenants/tenants/templates/invitation.txt new file mode 100644 index 000000000..ce9c5ea50 --- /dev/null +++ b/modules/tenants/tenants/templates/invitation.txt @@ -0,0 +1,4 @@ +You have been invited to join $tenant_name as $role. + +Accept your invitation: +$accept_url diff --git a/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx new file mode 100644 index 000000000..bf56acbf5 --- /dev/null +++ b/modules/tenants/tests-js/test_tenants_InviteForm.test.tsx @@ -0,0 +1,51 @@ +import '@testing-library/jest-dom/vitest'; +import { configureI18n } from '@simple-module-py/i18n'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { afterEach, expect, test, vi } from 'vitest'; +import { InviteForm } from '../tenants/components/InviteForm'; + +configureI18n({ + locale: 'en', + messages: { + 'tenants.members.invite_email_label': 'Email', + 'tenants.members.invite_submit': 'Send invitation', + 'tenants.members.invite_link_copy': 'Copy link', + 'tenants.members.invite_emailed': 'Invitation emailed to {email}', + 'tenants.members.invite_share_link': 'Not emailed — share this link', + }, +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +test.each([true, false])('keeps the link and copy button when emailed=%s', async (emailed) => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + accept_url: '/tenants/invitations/accept?token=secret', + email: 'friend@example.com', + emailed, + }), + { status: 201 }, + ), + ); + const onInvited = vi.fn(); + render(); + await userEvent.type(screen.getByLabelText('Email'), 'friend@example.com'); + await userEvent.click(screen.getByRole('button', { name: 'Send invitation' })); + expect( + await screen.findByText( + emailed ? 'Invitation emailed to friend@example.com' : 'Not emailed — share this link', + ), + ).toBeInTheDocument(); + expect( + screen.getByText( + new URL('/tenants/invitations/accept?token=secret', window.location.origin).toString(), + ), + ).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Copy link' })).toBeInTheDocument(); + expect(onInvited).toHaveBeenCalledOnce(); +}); diff --git a/modules/tenants/tests/test_tenants_invitation_mail.py b/modules/tenants/tests/test_tenants_invitation_mail.py new file mode 100644 index 000000000..a0a526c48 --- /dev/null +++ b/modules/tenants/tests/test_tenants_invitation_mail.py @@ -0,0 +1,186 @@ +"""Invitation mail delivery and API feedback.""" + +import logging + +import pytest +from simple_module_core.durable_events import DurableContext, event_key +from simple_module_core.events import EventBus +from simple_module_core.mail import ConsoleMailSender, install_mail_sender +from tenants.contracts.events import InvitationCreated +from tenants.contracts.schemas import InvitationCreate +from tenants.invitations import InvitationService +from tenants.models import Invitation +from tenants.module import TenantsModule +from tenants.service import TenantService + + +@pytest.fixture +def mail_origin(app, monkeypatch): + monkeypatch.setattr(app.state.tenants.settings, "public_base_url", "https://app.example.com") + + +async def _issue(owner): + await owner.post("/api/tenants/", json={"name": "Acme"}) + return await owner.post( + "/api/tenants/current/invitations", json={"email": "friend@x.io", "role": "admin"} + ) + + +async def test_tenants_after_commit_mail(user_client, captured_mail, mail_origin, monkeypatch, app): + monkeypatch.setattr(app.state.sm.event_bus, "durable_transport", None) + async with user_client("owner@x.io") as (owner, _): + response = await _issue(owner) + assert response.status_code == 201 + assert response.json()["emailed"] is True + assert len(captured_mail.messages) == 1 + message = captured_mail.messages[0] + assert message.to == "friend@x.io" + assert message.subject == "You're invited to join Acme" + assert response.json()["accept_url"] in message.text + assert "admin" in message.text + + +async def test_tenants_relative_mail_skipped(user_client, captured_mail, app, monkeypatch, caplog): + monkeypatch.setattr(app.state.tenants.settings, "public_base_url", "") + monkeypatch.setattr(app.state.sm.event_bus, "durable_transport", None) + with caplog.at_level(logging.WARNING): + async with user_client("owner@x.io") as (owner, _): + response = await _issue(owner) + assert response.status_code == 201 + assert response.json()["emailed"] is False + assert captured_mail.messages == [] + assert "public_base_url" in caplog.text + + +async def test_tenants_console_reports_not_emailed(user_client, mail_origin): + install_mail_sender(ConsoleMailSender(include_body=False)) + async with user_client("owner@x.io") as (owner, _): + assert (await _issue(owner)).json()["emailed"] is False + + +async def test_tenants_mail_failure_preserves_invitation( + user_client, captured_mail, mail_origin, app, monkeypatch, caplog +): + async def fail(message): + raise RuntimeError("transport failed") + + monkeypatch.setattr(captured_mail, "send", fail) + monkeypatch.setattr(app.state.sm.event_bus, "durable_transport", None) + with caplog.at_level(logging.WARNING): + async with user_client("owner@x.io") as (owner, _): + response = await _issue(owner) + assert response.status_code == 201 + pending = (await owner.get("/api/tenants/current/invitations")).json() + assert pending[0]["id"] == response.json()["id"] + assert "Invitation email failed after commit" in caplog.text + + +async def test_tenants_durable_mail(db_session, captured_mail): + class Transport: + def __init__(self): + self.calls = [] + + async def enqueue(self, event, handler_names, db): + self.calls.append((event, handler_names, db)) + + bus = EventBus() + transport = Transport() + bus.durable_transport = transport + TenantsModule().register_event_handlers(bus, None) + tenants = TenantService(db_session, bus=bus) + from tenants.contracts.schemas import TenantCreate + + tenant = await tenants.create_tenant(TenantCreate(name="Acme"), owner_user_id="owner") + invitation, _, url = await InvitationService(tenants, ttl_hours=72).create( + tenant.id, InvitationCreate(email="friend@x.io"), base_url="https://app.example.com" + ) + assert await db_session.get(Invitation, invitation.id) is not None + assert captured_mail.messages == [] + event, names, db = transport.calls[0] + assert names == ["tenants.send_invitation_email"] + assert db is db_session + subscription = bus.durable.lookup(event_key(InvitationCreated), names[0]) + assert subscription is not None + await subscription.handler(event, DurableContext(db=db_session, attempt=1)) + assert len(captured_mail.messages) == 1 + assert url in captured_mail.messages[0].text + + +async def test_tenants_durable_relative_link_skipped(db_session, captured_mail, caplog): + from tenants.invitation_mail import send_invitation_email + + event = InvitationCreated("tenant", "Acme", "friend@x.io", "member", "/accept?token=secret") + with caplog.at_level(logging.WARNING): + await send_invitation_email(event, DurableContext(db=db_session, attempt=1)) + assert captured_mail.messages == [] + assert "public_base_url" in caplog.text + + +async def test_tenants_durable_failure_propagates(db_session, captured_mail, monkeypatch): + from tenants.invitation_mail import send_invitation_email + + async def fail(message): + raise RuntimeError("transport failed") + + monkeypatch.setattr(captured_mail, "send", fail) + event = InvitationCreated( + "tenant", "Acme", "friend@x.io", "member", "https://app.example.com/accept" + ) + with pytest.raises(RuntimeError, match="transport failed"): + await send_invitation_email(event, DurableContext(db=db_session, attempt=1)) + + +class _RecordingTransport: + def __init__(self): + self.calls = [] + + async def enqueue(self, event, handler_names, db): + self.calls.append((event, handler_names, db)) + + +async def _invite_with(db_session, base_url: str): + from tenants.contracts.schemas import TenantCreate + + bus = EventBus() + bus.durable_transport = transport = _RecordingTransport() + TenantsModule().register_event_handlers(bus, None) + tenants = TenantService(db_session, bus=bus) + tenant = await tenants.create_tenant(TenantCreate(name="Acme"), owner_user_id="owner") + await InvitationService(tenants, ttl_hours=72).create( + tenant.id, InvitationCreate(email="friend@x.io"), base_url=base_url + ) + return transport, tenants + + +async def test_tenants_no_transport_queues_no_mail(db_session): + """Without the mail module the token must not be persisted in the outbox.""" + from simple_module_core.mail import install_mail_resolver + + install_mail_sender(None) + install_mail_resolver(None) + transport, _ = await _invite_with(db_session, "https://app.example.com") + assert transport.calls == [] + + +async def test_tenants_relative_link_is_not_queued(db_session, captured_mail): + transport, _ = await _invite_with(db_session, "") + assert transport.calls == [] + + +async def test_tenants_worker_without_transport_retries(db_session): + """A worker started without Mail must fail the delivery, not mark it sent.""" + from simple_module_core.mail import install_mail_resolver + from tenants.invitation_mail import send_invitation_email + + install_mail_sender(None) + install_mail_resolver(None) + event = InvitationCreated("t", "Acme", "friend@x.io", "member", "https://app.example.com/a") + with pytest.raises(RuntimeError, match="SM_MODULES_ENABLED"): + await send_invitation_email(event, DurableContext(db=db_session, attempt=1)) + + +async def test_tenants_console_backend_queues_no_mail(db_session): + """The console backend only logs: the link is shown instead, no token stored.""" + install_mail_sender(ConsoleMailSender()) + transport, _ = await _invite_with(db_session, "https://app.example.com") + assert transport.calls == [] diff --git a/modules/users/README.md b/modules/users/README.md index 5b9b7709a..9d4ea0ea0 100644 --- a/modules/users/README.md +++ b/modules/users/README.md @@ -18,7 +18,7 @@ Pre-wired into any app scaffolded with `smpy new`. - Bootstrap admin via env vars (`SM_USERS_BOOTSTRAP_EMAIL` + `SM_USERS_BOOTSTRAP_PASSWORD`) — idempotent, only creates if the users table is empty. - `smpy users create-admin` CLI for ad-hoc admin creation. - Inertia pages for login/register/invite-accept/admin-invite. -- Console mailer (logs to stdout) or SMTP mailer (`SM_USERS_MAILER=smtp`). +- Sends verification, reset and invite emails through the framework mail sender (configure the transport in the `mail` module). - Demo mode — one-click, read-only shared **admin** and **standard user** accounts on the sign-in card, for public showcase instances. ## Usage @@ -50,7 +50,7 @@ async def profile(user: CurrentUser): ## Depends on - `simple_module_core`, `simple_module_db`, `simple_module_hosting`, `simple_module_settings`, `simple_module_auth` -- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `aiosmtplib`, `cachetools`, `typer` +- `fastapi-users[sqlalchemy,oauth]>=15,<16`, `cachetools`, `typer` ## Demo mode diff --git a/modules/users/pyproject.toml b/modules/users/pyproject.toml index c0e98299b..1989b5eb6 100644 --- a/modules/users/pyproject.toml +++ b/modules/users/pyproject.toml @@ -26,11 +26,13 @@ dependencies = [ "simple_module_hosting==0.0.37", "simple_module_settings==0.0.37", "simple_module_auth==0.0.37", + # Sends through the framework mail sender; `mail` owns the SMTP transport + # (GH #496), so an upgrade keeps delivering mail. + "simple_module_mail==0.0.37", # Pinned to a narrow range: `deps.py` relies on mutating CookieTransport # fields after construction (see reconfigure_cookie_transport in backend.py). # Bumping the major version requires re-checking those field names. "fastapi-users[sqlalchemy,oauth]>=15,<16", - "aiosmtplib>=3.0", "cachetools>=5.3", "typer>=0.12", ] @@ -63,3 +65,4 @@ simple_module_db = { workspace = true } simple_module_hosting = { workspace = true } simple_module_settings = { workspace = true } simple_module_auth = { workspace = true } +simple_module_mail = { workspace = true } diff --git a/modules/users/tests/_users_app_builders.py b/modules/users/tests/_users_app_builders.py index 3b8132b2a..3cfd1fac1 100644 --- a/modules/users/tests/_users_app_builders.py +++ b/modules/users/tests/_users_app_builders.py @@ -20,7 +20,6 @@ def _users_overrides(allow_signup: bool = False) -> dict[str, tuple[str, str]]: """Values seeded into the ``SettingsStore`` before the app lifespan runs.""" return { "allow_signup": (str(allow_signup).lower(), "bool"), - "mailer": ("console", "string"), "base_url": ("http://testserver", "string"), "cookie_secure": ("false", "bool"), # 32+ bytes to clear pyjwt's InsecureKeyLengthWarning for HMAC-SHA256. diff --git a/modules/users/tests/conftest.py b/modules/users/tests/conftest.py index 74bd60e9d..8721c8b3a 100644 --- a/modules/users/tests/conftest.py +++ b/modules/users/tests/conftest.py @@ -2,7 +2,7 @@ The ``users_app`` fixture builds a full FastAPI app via ``create_app`` but with an in-memory SQLite database, seeded roles, and test-friendly settings -(ConsoleMailer, signup disabled by default, short secrets). +(no mail sender installed, signup disabled by default, short secrets). The ``anon_client`` gives a plain httpx client. The ``admin_client`` gives a client with a signed local-user session cookie diff --git a/modules/users/tests/test_admin_resend_invite.py b/modules/users/tests/test_admin_resend_invite.py index c785611ea..b008c2e7a 100644 --- a/modules/users/tests/test_admin_resend_invite.py +++ b/modules/users/tests/test_admin_resend_invite.py @@ -10,6 +10,7 @@ from __future__ import annotations import logging +import re from datetime import UTC, datetime, timedelta import pytest @@ -174,14 +175,15 @@ async def test_resend_requires_the_manage_permission(self, user_client): class TestInviteTokenClaims: @pytest.mark.anyio - async def test_invite_token_names_the_inviter(self, admin_client, users_app, caplog): + async def test_invite_token_names_the_inviter(self, admin_client, users_app, captured_mail): """The accept-invite card says who invited you; the claim carries it.""" import jwt - with caplog.at_level(logging.INFO, logger="users.mailer"): - await _invite(admin_client, "whosent@example.com") - link = next(r for r in caplog.records if r.getMessage() == "users.invite.email").link - token = link.split("token=", 1)[1] + await _invite(admin_client, "whosent@example.com") + text = next( + m.text for m in captured_mail.to("whosent@example.com") if "invited you" in m.subject + ) + token = re.search(r"token=(\S+)", text).group(1) payload = jwt.decode( token, diff --git a/modules/users/tests/test_auth_screens_props.py b/modules/users/tests/test_auth_screens_props.py index d1b84ff16..b7b576f76 100644 --- a/modules/users/tests/test_auth_screens_props.py +++ b/modules/users/tests/test_auth_screens_props.py @@ -62,7 +62,7 @@ async def test_lifetime_is_reported_in_minutes(self, anon_client): @pytest.mark.anyio async def test_console_mailer_does_not_deliver(self, anon_client): - """The test app runs the ConsoleMailer, so the amber callout must show.""" + """The test app has no delivering mail sender, so the amber callout must show.""" props = _props(await anon_client.get("/users/forgot-password", headers=_INERTIA_HEADERS)) assert props["mailer_delivers"] is False diff --git a/modules/users/tests/test_bulk_invite_message.py b/modules/users/tests/test_bulk_invite_message.py index e1df8c2cc..6aba48cca 100644 --- a/modules/users/tests/test_bulk_invite_message.py +++ b/modules/users/tests/test_bulk_invite_message.py @@ -8,8 +8,6 @@ from __future__ import annotations -import logging - import pytest _URL = "/api/users/admin/invite/bulk" @@ -111,11 +109,15 @@ async def test_bulk_invite_stamps_invited_at(self, admin_client, spy_mailer): assert row["state"] == "invited" -class TestConsoleMailerMessage: +class TestTemplatedMailerMessage: + @pytest.mark.anyio + async def test_mailer_quotes_the_message(self, users_app, captured_mail): + mailer = users_app.state.users.mailer + await mailer.send_invite("x@example.com", "tok", "Alex Doyle", "See you Monday.") + assert "See you Monday." in captured_mail.to("x@example.com")[0].text + @pytest.mark.anyio - async def test_console_mailer_logs_the_message(self, users_app, caplog): + async def test_blank_message_leaves_no_quote(self, users_app, captured_mail): mailer = users_app.state.users.mailer - with caplog.at_level(logging.INFO, logger="users.mailer"): - await mailer.send_invite("x@example.com", "tok", "Alex Doyle", "See you Monday.") - record = next(r for r in caplog.records if r.getMessage() == "users.invite.email") - assert record.message_body == "See you Monday." + await mailer.send_invite("x@example.com", "tok", "Alex Doyle", " \n ") + assert "See you" not in captured_mail.to("x@example.com")[0].text diff --git a/modules/users/tests/test_invite_flow.py b/modules/users/tests/test_invite_flow.py index 5e95b683e..5dd8b534a 100644 --- a/modules/users/tests/test_invite_flow.py +++ b/modules/users/tests/test_invite_flow.py @@ -6,38 +6,34 @@ from __future__ import annotations -import logging +import re import pytest class TestInviteFlow: @pytest.mark.anyio - async def test_full_invite_flow(self, admin_client, anon_client, caplog): + async def test_full_invite_flow(self, admin_client, anon_client, captured_mail): """Admin invites user → token from logs → accept-invite → login.""" # Step 1: Admin sends invite - with caplog.at_level(logging.INFO, logger="users.mailer"): - resp = await admin_client.post( - "/api/users/admin/invite", - json={ - "email": "newbie@example.com", - "full_name": "New User", - "role_names": ["user"], - }, - ) + resp = await admin_client.post( + "/api/users/admin/invite", + json={ + "email": "newbie@example.com", + "full_name": "New User", + "role_names": ["user"], + }, + ) assert resp.status_code == 201, resp.text body = resp.json() assert body["email"] == "newbie@example.com" assert body["is_verified"] is False - # Step 2: Extract token from ConsoleMailer log record - invite_records = [r for r in caplog.records if r.getMessage() == "users.invite.email"] - assert len(invite_records) == 1, ( - f"Expected 1 invite log record, got {len(invite_records)}: {caplog.records}" - ) - link = invite_records[0].link + # Step 2: Extract token from the captured invite mail + sent = [m for m in captured_mail.to("newbie@example.com") if "invited you" in m.subject] + assert len(sent) == 1, f"Expected 1 invite mail, got {captured_mail.messages}" # Link format: http://testserver/users/invite/accept?token= - token = link.split("token=", 1)[1] + token = re.search(r"token=(\S+)", sent[0].text).group(1) assert len(token) > 20 # Step 3: Accept invite with the token and a new password @@ -67,16 +63,21 @@ async def test_accept_invite_with_stale_token_fails(self, anon_client): assert resp.json()["detail"] == "INVITE_BAD_TOKEN" @pytest.mark.anyio - async def test_accept_invite_weak_password_fails(self, admin_client, anon_client, caplog): + async def test_accept_invite_weak_password_fails( + self, admin_client, anon_client, captured_mail + ): """Weak password during accept-invite should return 400.""" - with caplog.at_level(logging.INFO, logger="users.mailer"): - await admin_client.post( - "/api/users/admin/invite", - json={"email": "weakpw@example.com"}, - ) + await admin_client.post( + "/api/users/admin/invite", + json={"email": "weakpw@example.com"}, + ) - invite_records = [r for r in caplog.records if r.getMessage() == "users.invite.email"] - token = invite_records[0].link.split("token=", 1)[1] + token = re.search( + r"token=(\S+)", + next( + m for m in captured_mail.to("weakpw@example.com") if "invited you" in m.subject + ).text, + ).group(1) resp = await anon_client.post( "/api/users/auth/accept-invite", diff --git a/modules/users/tests/test_invite_reuse.py b/modules/users/tests/test_invite_reuse.py index a4e2eed8a..54c933795 100644 --- a/modules/users/tests/test_invite_reuse.py +++ b/modules/users/tests/test_invite_reuse.py @@ -15,27 +15,25 @@ from __future__ import annotations -import logging +import re import pytest -async def _send_invite_and_grab_token(admin_client, anon_client, caplog, email: str) -> str: - """Issue an invite and pull the token out of the ConsoleMailer log line.""" - with caplog.at_level(logging.INFO, logger="users.mailer"): - resp = await admin_client.post( - "/api/users/admin/invite", - json={"email": email, "role_names": ["user"]}, - ) +async def _send_invite_and_grab_token(admin_client, anon_client, captured_mail, email: str) -> str: + """Issue an invite and pull the token out of the captured invite mail.""" + resp = await admin_client.post( + "/api/users/admin/invite", + json={"email": email, "role_names": ["user"]}, + ) assert resp.status_code == 201, resp.text - records = [r for r in caplog.records if r.getMessage() == "users.invite.email"] - assert records, "ConsoleMailer didn't log an invite.email record" - link = records[-1].link - return link.split("token=", 1)[1] + sent = [m for m in captured_mail.to(email) if "invited you" in m.subject] + assert sent, "no invite mail was sent" + return re.search(r"token=(\S+)", sent[-1].text).group(1) @pytest.mark.anyio -async def test_invite_token_is_single_use(admin_client, anon_client, caplog): +async def test_invite_token_is_single_use(admin_client, anon_client, captured_mail): """Accepting the same invite token twice must fail the second time. Verify tokens in fastapi-users flip ``is_verified`` on the user; the @@ -44,7 +42,7 @@ async def test_invite_token_is_single_use(admin_client, anon_client, caplog): forgot to re-check state would let a stolen link be replayed. """ token = await _send_invite_and_grab_token( - admin_client, anon_client, caplog, "single@example.com" + admin_client, anon_client, captured_mail, "single@example.com" ) first = await anon_client.post( @@ -63,7 +61,9 @@ async def test_invite_token_is_single_use(admin_client, anon_client, caplog): @pytest.mark.anyio -async def test_reset_token_invalidated_after_password_change(admin_client, anon_client, caplog): +async def test_reset_token_invalidated_after_password_change( + admin_client, anon_client, captured_mail +): """A reset-password token must no longer work once the user's hash changes. fastapi-users binds reset tokens to ``user.hashed_password`` so any change @@ -73,7 +73,7 @@ async def test_reset_token_invalidated_after_password_change(admin_client, anon_ """ # Step 1: create a user via invite, log them in with a known password. token = await _send_invite_and_grab_token( - admin_client, anon_client, caplog, "resetme@example.com" + admin_client, anon_client, captured_mail, "resetme@example.com" ) await anon_client.post( "/api/users/auth/accept-invite", @@ -105,10 +105,10 @@ async def test_reset_token_invalidated_after_password_change(admin_client, anon_ @pytest.mark.anyio -async def test_disabled_user_cannot_accept_their_invite(admin_client, anon_client, caplog): +async def test_disabled_user_cannot_accept_their_invite(admin_client, anon_client, captured_mail): """Inviting + disabling before acceptance must keep the user out.""" token = await _send_invite_and_grab_token( - admin_client, anon_client, caplog, "blocked@example.com" + admin_client, anon_client, captured_mail, "blocked@example.com" ) # Admin disables the freshly-invited user before they accept. diff --git a/modules/users/tests/test_mailer.py b/modules/users/tests/test_mailer.py index aaa97c1d3..6119fc3cc 100644 --- a/modules/users/tests/test_mailer.py +++ b/modules/users/tests/test_mailer.py @@ -1,130 +1,69 @@ -"""Tests for the ConsoleMailer.""" +"""Tests for the built-in TemplatedMailer.""" from __future__ import annotations -import logging - import pytest +from users.mailer.templated import TemplatedMailer @pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) @pytest.mark.anyio -async def test_send_verification_logs_link(url, caplog): - from users.mailer.console import ConsoleMailer - - mailer = ConsoleMailer(base_url=url) - token = "verify-tok-123" - - with caplog.at_level(logging.INFO, logger="users.mailer"): - await mailer.send_verification("user@example.com", token) +async def test_send_verification_mails_link(url, captured_mail): + await TemplatedMailer(base_url=url).send_verification("user@example.com", "verify-tok-123") - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.getMessage() == "users.verify.email" - assert record.to == "user@example.com" - expected_link = f"{url.rstrip('/')}/users/verify?token={token}" - assert record.link == expected_link + (msg,) = captured_mail.to("user@example.com") + assert f"{url}/users/verify?token=verify-tok-123" in msg.text @pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) @pytest.mark.anyio -async def test_send_password_reset_logs_link(url, caplog): - from users.mailer.console import ConsoleMailer - - mailer = ConsoleMailer(base_url=url) - token = "reset-tok-456" - - with caplog.at_level(logging.INFO, logger="users.mailer"): - await mailer.send_password_reset("user@example.com", token) +async def test_send_password_reset_mails_link(url, captured_mail): + await TemplatedMailer(base_url=url).send_password_reset("user@example.com", "reset-tok-456") - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.getMessage() == "users.reset.email" - assert record.to == "user@example.com" - expected_link = f"{url.rstrip('/')}/users/reset-password?token={token}" - assert record.link == expected_link + (msg,) = captured_mail.to("user@example.com") + assert f"{url}/users/reset-password?token=reset-tok-456" in msg.text @pytest.mark.parametrize("url", ["http://localhost:8000", "https://app.example.com"]) @pytest.mark.anyio -async def test_send_invite_logs_link(url, caplog): - from users.mailer.console import ConsoleMailer - - mailer = ConsoleMailer(base_url=url) - token = "invite-tok-789" - - with caplog.at_level(logging.INFO, logger="users.mailer"): - await mailer.send_invite("newuser@example.com", token, "Alice Admin") +async def test_send_invite_mails_link(url, captured_mail): + await TemplatedMailer(base_url=url).send_invite( + "newuser@example.com", "invite-tok-789", "Alice Admin" + ) - assert len(caplog.records) == 1 - record = caplog.records[0] - assert record.getMessage() == "users.invite.email" - assert record.to == "newuser@example.com" - expected_link = f"{url.rstrip('/')}/users/invite/accept?token={token}" - assert record.link == expected_link - assert record.invited_by == "Alice Admin" + (msg,) = captured_mail.to("newuser@example.com") + assert f"{url}/users/invite/accept?token=invite-tok-789" in msg.text + assert "Alice Admin" in msg.subject @pytest.mark.anyio -async def test_base_url_trailing_slash_stripped(caplog): - """ConsoleMailer strips trailing slash from base_url in all links.""" - from users.mailer.console import ConsoleMailer - - mailer = ConsoleMailer(base_url="http://localhost:8000/") - - with caplog.at_level(logging.INFO, logger="users.mailer"): - await mailer.send_verification("a@b.com", "tok") +async def test_base_url_trailing_slash_stripped(captured_mail): + await TemplatedMailer(base_url="http://localhost:8000/").send_verification("a@b.com", "tok") - link = caplog.records[0].link - assert not link.startswith("http://localhost:8000//") - assert link == "http://localhost:8000/users/verify?token=tok" + text = captured_mail.messages[0].text + assert "http://localhost:8000//" not in text + assert "http://localhost:8000/users/verify?token=tok" in text @pytest.mark.anyio -async def test_console_logs_configured_app_name(caplog): - """A supplied provider brands the console log; otherwise the default.""" - from users.mailer.console import ConsoleMailer +async def test_emails_carry_the_app_name(captured_mail): + """Subjects + bodies include the live app name (and keep the link).""" + mailer = TemplatedMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme") - branded = ConsoleMailer(base_url="http://localhost:8000", app_name_provider=lambda: "Acme") - default = ConsoleMailer(base_url="http://localhost:8000") - - with caplog.at_level(logging.INFO, logger="users.mailer"): - await branded.send_invite("a@b.com", "tok", "Alice") - await default.send_verification("a@b.com", "tok") + await mailer.send_invite("new@x.com", "tok", "Alice") + await mailer.send_verification("v@x.com", "vtok") + await mailer.send_password_reset("r@x.com", "rtok") - assert caplog.records[0].app_name == "Acme" - assert caplog.records[1].app_name == "SimpleModule" + invite, verify, reset = captured_mail.messages + assert "Acme" in invite.subject and "Alice" in invite.subject + assert "Acme" in invite.text + assert "http://localhost:8000/users/invite/accept?token=tok" in invite.text + assert "Acme" in verify.subject and "Acme" in verify.text + assert "Acme" in reset.subject and "Acme" in reset.text @pytest.mark.anyio -async def test_smtp_emails_carry_the_app_name(monkeypatch): - """SMTP subjects + bodies include the live app name (and keep the link).""" - from users.mailer.smtp import SmtpMailer - - captured: list[dict[str, str]] = [] - - async def fake_send(self, to: str, subject: str, body: str) -> None: - captured.append({"to": to, "subject": subject, "body": body}) - - monkeypatch.setattr(SmtpMailer, "_send", fake_send) - mailer = SmtpMailer( - host="smtp.test", - port=25, - username="", - password="", - from_address="from@test", - use_tls=False, - base_url="http://localhost:8000", - app_name_provider=lambda: "Acme", - ) - - await mailer.send_invite("new@x.com", "tok", "Alice") - await mailer.send_verification("v@x.com", "vtok") - await mailer.send_password_reset("r@x.com", "rtok") +async def test_default_app_name_when_no_provider(captured_mail): + await TemplatedMailer(base_url="http://localhost:8000").send_verification("a@b.com", "tok") - invite, verify, reset = captured - assert "Acme" in invite["subject"] and "Alice" in invite["subject"] - assert "Acme" in invite["body"] - assert "http://localhost:8000/users/invite/accept?token=tok" in invite["body"] - assert "Acme" in verify["subject"] and "Acme" in verify["body"] - assert "Acme" in reset["subject"] and "Acme" in reset["body"] + assert "SimpleModule" in captured_mail.messages[0].subject diff --git a/modules/users/tests/test_settings.py b/modules/users/tests/test_settings.py index 40f1e211f..ee51ff349 100644 --- a/modules/users/tests/test_settings.py +++ b/modules/users/tests/test_settings.py @@ -19,12 +19,6 @@ def test_require_verification_default_true(self): s = UsersSettings() assert s.require_verification is True - def test_mailer_default_console(self): - from users.settings import UsersSettings - - s = UsersSettings() - assert s.mailer == "console" - def test_cookie_name_default(self): from users.settings import UsersSettings @@ -79,19 +73,6 @@ async def test_allow_signup_override_from_db(self, db_session): cfg = await hydrate_settings(UsersSettings, store, "users") assert cfg.allow_signup is True - @pytest.mark.asyncio - async def test_mailer_override_from_db(self, db_session): - from settings.hydrate import hydrate_settings - from settings.service import SettingService - from settings.store import SettingsStore - from users.settings import UsersSettings - - store = SettingsStore(SettingService(db_session)) - await store.set_override("users", "mailer", "smtp", "string") - - cfg = await hydrate_settings(UsersSettings, store, "users") - assert cfg.mailer == "smtp" - @pytest.mark.asyncio async def test_base_url_override_from_db(self, db_session): from settings.hydrate import hydrate_settings @@ -108,32 +89,10 @@ async def test_base_url_override_from_db(self, db_session): def test_env_vars_are_ignored(self, monkeypatch): """Setting SM_USERS_* must not affect UsersSettings() after the migration.""" monkeypatch.setenv("SM_USERS_ALLOW_SIGNUP", "true") - monkeypatch.setenv("SM_USERS_MAILER", "smtp") from users.settings import UsersSettings s = UsersSettings() assert s.allow_signup is False - assert s.mailer == "console" - - -class TestUsersSettingsValidation: - def test_mailer_pattern_rejects_invalid(self): - from users.settings import UsersSettings - - with pytest.raises(ValidationError, match="mailer"): - UsersSettings(mailer="foo") - - def test_mailer_pattern_accepts_console(self): - from users.settings import UsersSettings - - s = UsersSettings(mailer="console") - assert s.mailer == "console" - - def test_mailer_pattern_accepts_smtp(self): - from users.settings import UsersSettings - - s = UsersSettings(mailer="smtp") - assert s.mailer == "smtp" class TestTokenSecretProductionGuard: diff --git a/modules/users/tests/test_users_mailer_core_sender.py b/modules/users/tests/test_users_mailer_core_sender.py new file mode 100644 index 000000000..205fe56de --- /dev/null +++ b/modules/users/tests/test_users_mailer_core_sender.py @@ -0,0 +1,100 @@ +"""The users mailer sends through the framework mail sender, looked up per send.""" + +from __future__ import annotations + +import pytest +from simple_module_core.mail import install_mail_sender +from simple_module_test.mail import CapturedMail + +_BULK = "/api/users/admin/invite/bulk" + + +def _invites(captured_mail, address: str): + """Invite mails only: creating the account also sends a verification mail.""" + return [m for m in captured_mail.to(address) if "invited you" in m.subject] + + +@pytest.mark.anyio +async def test_invite_sends_one_message_with_link_and_subject(admin_client, captured_mail): + resp = await admin_client.post( + "/api/users/admin/invite", json={"email": "inv@example.com", "role_names": []} + ) + assert resp.status_code == 201, resp.text + + (msg,) = _invites(captured_mail, "inv@example.com") + assert "/users/invite/accept?token=" in msg.text + assert msg.subject.endswith("invited you to SimpleModule") + + +@pytest.mark.anyio +async def test_reset_sends_one_message_with_link_and_subject(users_app, captured_mail): + await users_app.state.users.mailer.send_password_reset("r@example.com", "rtok") + + (msg,) = captured_mail.to("r@example.com") + assert "http://testserver/users/reset-password?token=rtok" in msg.text + assert msg.subject == "Reset your SimpleModule password" + + +@pytest.mark.anyio +async def test_verification_sends_one_message_with_link_and_subject(users_app, captured_mail): + await users_app.state.users.mailer.send_verification("v@example.com", "vtok") + + (msg,) = captured_mail.to("v@example.com") + assert "http://testserver/users/verify?token=vtok" in msg.text + assert msg.subject == "Verify your email for SimpleModule" + + +@pytest.mark.anyio +async def test_delivers_email_follows_the_installed_sender(users_app, captured_mail): + mailer = users_app.state.users.mailer + assert mailer.delivers_email is True + + install_mail_sender(CapturedMail(delivers_email=False)) + assert mailer.delivers_email is False + + +@pytest.mark.anyio +async def test_sender_is_resolved_at_send_time(users_app, captured_mail): + mailer = users_app.state.users.mailer + later = CapturedMail() + install_mail_sender(later) + + await mailer.send_verification("late@example.com", "tok") + + assert later.to("late@example.com") and not captured_mail.messages + + +@pytest.mark.anyio +async def test_non_delivering_sender_makes_resend_return_the_link(admin_client, captured_mail): + install_mail_sender(CapturedMail(delivers_email=False)) + created = await admin_client.post( + "/api/users/admin/invite", json={"email": "nd@example.com", "role_names": []} + ) + resp = await admin_client.post(f"/api/users/admin/{created.json()['id']}/resend-invite") + + assert resp.status_code == 202, resp.text + body = resp.json() + assert body["status"] == "link" + assert "/users/invite/accept?token=" in (body["link"] or "") + + +@pytest.mark.anyio +async def test_delivering_sender_makes_resend_mail(admin_client, captured_mail): + created = await admin_client.post( + "/api/users/admin/invite", json={"email": "d@example.com", "role_names": []} + ) + resp = await admin_client.post(f"/api/users/admin/{created.json()['id']}/resend-invite") + + assert resp.json()["status"] == "sent" + assert len(_invites(captured_mail, "d@example.com")) == 2 + + +@pytest.mark.anyio +async def test_non_delivering_sender_makes_bulk_invite_return_links(admin_client, captured_mail): + install_mail_sender(CapturedMail(delivers_email=False)) + resp = await admin_client.post(_BULK, json={"emails": ["b1@example.com"], "role_names": []}) + + (result,) = resp.json()["results"] + assert result["status"] == "link" + assert "/users/invite/accept?token=" in result["link"] + assert not captured_mail.messages diff --git a/modules/users/users/admin/views.py b/modules/users/users/admin/views.py index 5d1990851..c964a708a 100644 --- a/modules/users/users/admin/views.py +++ b/modules/users/users/admin/views.py @@ -29,6 +29,8 @@ _PAGE_ADMIN_EDIT = "Users/Users/Edit" _SECONDS_PER_DAY = 60 * 60 * 24 +_MAILER_SMTP = "smtp" +_MAILER_CONSOLE = "console" async def _roles_payload(app) -> list[dict[str, str]]: @@ -121,16 +123,17 @@ async def admin_add_people_page( users_state = getattr(request.app.state, "users", None) mailer = getattr(users_state, "mailer", None) settings = getattr(users_state, "settings", None) + delivers = mailer_delivers(mailer) return await inertia.render( _PAGE_ADMIN_ADD, { "roles": await _roles_payload(request.app), # Drives the copy-link panel: when nothing can be delivered, the # invite mode has to hand the link back instead. - "mailer_delivers": mailer_delivers(mailer), + "mailer_delivers": delivers, # Named, not just "off": "Mailer is console" tells an admin which # setting to go and change, which "mail is disabled" does not. - "mailer_name": getattr(settings, "mailer", "console"), + "mailer_name": _MAILER_SMTP if delivers else _MAILER_CONSOLE, # The footer note states the expiry rather than hardcoding "7 # days", so shortening the lifetime cannot leave the page lying. "invite_expiry_days": ( diff --git a/modules/users/users/health.py b/modules/users/users/health.py deleted file mode 100644 index 7af1f420d..000000000 --- a/modules/users/users/health.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Health check for the configured mailer. - -Doubles as the "Test connection" action on the module-settings screen: an -admin who has just typed SMTP credentials needs a way to find out they are -wrong that is cheaper than triggering a password reset and waiting. -""" - -from __future__ import annotations - -from fastapi import FastAPI -from simple_module_core.health import HealthCheckResult, HealthStatus - -CHECK_MAILER = "users.mailer" - - -def build_mailer_check(app: FastAPI): - """Return an async check closing over *app* so it re-reads live settings. - - Bound to the app rather than a mailer instance because settings are - hydrated from the DB and can change after boot — a check pinned to the - boot-time mailer would keep testing credentials the admin has replaced. - """ - - async def check() -> HealthCheckResult: - services = getattr(app.state, "users", None) - mailer = getattr(services, "mailer", None) - if mailer is None: - return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail="No mailer configured") - - verify = getattr(mailer, "verify_connection", None) - if verify is None: - # The console mailer writes links to the log; there is nothing to - # reach, so it is healthy by construction rather than untested. - return HealthCheckResult( - status=HealthStatus.HEALTHY, - detail=f"{type(mailer).__name__} needs no connection", - ) - - try: - await verify() - except Exception as exc: - # The reason matters more than the traceback: "authentication - # failed" and "connection refused" call for different fixes. - return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc)) - return HealthCheckResult(status=HealthStatus.HEALTHY, detail="SMTP reachable") - - return check diff --git a/modules/users/users/mailer/__init__.py b/modules/users/users/mailer/__init__.py index ba076787c..0a008e22d 100644 --- a/modules/users/users/mailer/__init__.py +++ b/modules/users/users/mailer/__init__.py @@ -1,4 +1,4 @@ -"""Mailer interface and factory — pick console/smtp from settings.""" +"""Mailer interface and factory — built-in templated mailer.""" from __future__ import annotations @@ -41,7 +41,7 @@ def mailer_delivers(mailer: object | None) -> bool: Four screens branch on this — the reset-sent card, add-people, resend invite and bulk invite — and each had its own copy of the same two-part guard. It is one question with one answer: no mailer at all delivers - nothing, and the console mailer only writes the link to the log, so both + nothing, and a console sender only writes the link to the log, so both have to hand the link back instead of promising an inbox. Takes the mailer rather than the settings because ``delivers_email`` is the mailer's own claim; a third-party mailer that never set the flag is taken at its word. @@ -53,21 +53,6 @@ def build_mailer( settings: UsersSettings, app_name_provider: AppNameProvider | None = None, ) -> Mailer: - provider = app_name_provider or default_app_name - if settings.mailer == "smtp": - from users.mailer.smtp import SmtpMailer + from users.mailer.templated import TemplatedMailer - return SmtpMailer( - host=settings.smtp_host, - port=settings.smtp_port, - username=settings.smtp_username, - password=settings.smtp_password, - from_address=settings.smtp_from, - use_tls=settings.smtp_tls, - base_url=settings.base_url, - app_name_provider=provider, - ) - - from users.mailer.console import ConsoleMailer - - return ConsoleMailer(base_url=settings.base_url, app_name_provider=provider) + return TemplatedMailer(base_url=settings.base_url, app_name_provider=app_name_provider) diff --git a/modules/users/users/mailer/console.py b/modules/users/users/mailer/console.py deleted file mode 100644 index 511527c65..000000000 --- a/modules/users/users/mailer/console.py +++ /dev/null @@ -1,61 +0,0 @@ -"""Console mailer — logs tokenized links for local development.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from users.mailer import AppNameProvider - -logger = logging.getLogger("users.mailer") - - -class ConsoleMailer: - delivers_email = False - """Nothing leaves the process — links only reach the log. - - Callers that need the recipient to actually receive something (the bulk - invite screen) read this to decide whether to surface the one-time link - in the UI instead. Absence of the attribute means "assume it delivers", - so a third-party mailer never leaks tokens by omission. - """ - - def __init__(self, base_url: str, app_name_provider: AppNameProvider | None = None) -> None: - self._base = base_url.rstrip("/") - from users.mailer import default_app_name - - self._app_name = app_name_provider or default_app_name - - async def send_verification(self, email: str, token: str) -> None: - link = f"{self._base}/users/verify?token={token}" - logger.info( - "users.verify.email", extra={"to": email, "link": link, "app_name": self._app_name()} - ) - - async def send_password_reset(self, email: str, token: str) -> None: - link = f"{self._base}/users/reset-password?token={token}" - logger.info( - "users.reset.email", extra={"to": email, "link": link, "app_name": self._app_name()} - ) - - async def send_invite( - self, - email: str, - token: str, - invited_by_name: str, - message: str | None = None, - ) -> None: - link = f"{self._base}/users/invite/accept?token={token}" - logger.info( - "users.invite.email", - extra={ - "to": email, - "link": link, - "invited_by": invited_by_name, - # Not "message": ``LogRecord`` already owns that attribute and - # ``logging`` raises KeyError rather than shadowing it. - "message_body": message, - "app_name": self._app_name(), - }, - ) diff --git a/modules/users/users/mailer/smtp.py b/modules/users/users/mailer/smtp.py deleted file mode 100644 index 6ec315a15..000000000 --- a/modules/users/users/mailer/smtp.py +++ /dev/null @@ -1,123 +0,0 @@ -"""SMTP mailer — sends emails via aiosmtplib with Jinja2 templates.""" - -from __future__ import annotations - -import contextlib -import importlib.resources -from email.message import EmailMessage -from typing import TYPE_CHECKING - -import aiosmtplib -import jinja2 - -if TYPE_CHECKING: - from users.mailer import AppNameProvider - - -def _load_template_env() -> jinja2.Environment: - """Build a Jinja2 Environment pointed at the bundled templates directory.""" - templates_path = importlib.resources.files(__package__) / "templates" - return jinja2.Environment( - loader=jinja2.FileSystemLoader(str(templates_path)), - autoescape=False, - ) - - -# Resolve the template directory at import time — deterministic, async-safe, -# and the filesystem path is known by then anyway. -_template_env: jinja2.Environment = _load_template_env() - - -class SmtpMailer: - def __init__( - self, - host: str, - port: int, - username: str, - password: str, - from_address: str, - use_tls: bool, - base_url: str, - app_name_provider: AppNameProvider | None = None, - ) -> None: - self._host = host - self._port = port - self._username = username - self._password = password - self._from = from_address - self._use_tls = use_tls - self._base = base_url.rstrip("/") - from users.mailer import default_app_name - - self._app_name = app_name_provider or default_app_name - - async def send_verification(self, email: str, token: str) -> None: - app = self._app_name() - link = f"{self._base}/users/verify?token={token}" - template = _template_env.get_template("verify_email.txt") - body = template.render(link=link, app_name=app) - await self._send(email, f"Verify your email for {app}", body) - - async def send_password_reset(self, email: str, token: str) -> None: - app = self._app_name() - link = f"{self._base}/users/reset-password?token={token}" - template = _template_env.get_template("reset_password.txt") - body = template.render(link=link, app_name=app) - await self._send(email, f"Reset your {app} password", body) - - async def send_invite( - self, - email: str, - token: str, - invited_by_name: str, - message: str | None = None, - ) -> None: - app = self._app_name() - link = f"{self._base}/users/invite/accept?token={token}" - template = _template_env.get_template("invite.txt") - body = template.render( - link=link, - invited_by_name=invited_by_name, - app_name=app, - # Stripped here rather than in the template: a message of nothing - # but whitespace should leave no quoted block behind. - message=(message or "").strip() or None, - ) - await self._send(email, f"{invited_by_name} invited you to {app}", body) - - async def verify_connection(self) -> None: - """Open an SMTP session and authenticate, then hang up. - - Deliberately stops short of sending anything: an admin checking their - mailer config should not put a stray message in someone's inbox. This - catches the failures that actually happen — wrong host or port, TLS - mismatch, bad credentials — and raises whatever aiosmtplib raises so - the caller can show the real reason. - """ - client = aiosmtplib.SMTP(hostname=self._host, port=self._port, use_tls=self._use_tls) - await client.connect() - try: - if self._username: - await client.login(self._username, self._password or "") - finally: - # A failure hanging up says nothing about whether the credentials - # work, which is the only question being asked — so it must not - # mask the login error this block is unwinding. - with contextlib.suppress(Exception): - await client.quit() - - async def _send(self, to: str, subject: str, body: str) -> None: - message = EmailMessage() - message["From"] = self._from - message["To"] = to - message["Subject"] = subject - message.set_content(body) - - await aiosmtplib.send( - message, - hostname=self._host, - port=self._port, - username=self._username or None, - password=self._password or None, - use_tls=self._use_tls, - ) diff --git a/modules/users/users/mailer/templated.py b/modules/users/users/mailer/templated.py new file mode 100644 index 000000000..b6b92c73a --- /dev/null +++ b/modules/users/users/mailer/templated.py @@ -0,0 +1,77 @@ +"""Built-in mailer: renders the bundled templates and hands them to the mail sender. + +Transport (SMTP, console, ...) belongs to the ``mail`` module. The sender is +looked up at send time so a settings change there takes effect without a +restart, and ``delivers_email`` reads it live for the same reason. +""" + +from __future__ import annotations + +import importlib.resources +from typing import TYPE_CHECKING, Any + +import jinja2 +from simple_module_core.mail import MailMessage, current_mail_sender + +if TYPE_CHECKING: + from users.mailer import AppNameProvider + +_TPL_VERIFY = "verify_email.txt" +_TPL_RESET = "reset_password.txt" +_TPL_INVITE = "invite.txt" + + +# The bundled templates directory; plain text, so no autoescape. +_template_env = jinja2.Environment( + loader=jinja2.FileSystemLoader(str(importlib.resources.files(__package__) / "templates")), + autoescape=False, +) + + +class TemplatedMailer: + def __init__(self, base_url: str, app_name_provider: AppNameProvider | None = None) -> None: + self._base = base_url.rstrip("/") + from users.mailer import default_app_name + + self._app_name = app_name_provider or default_app_name + + @property + def delivers_email(self) -> bool: + """Whether the installed sender puts mail in front of a person.""" + return current_mail_sender().delivers_email + + async def send_verification(self, email: str, token: str) -> None: + app = self._app_name() + link = f"{self._base}/users/verify?token={token}" + await self._send(email, f"Verify your email for {app}", _TPL_VERIFY, link=link) + + async def send_password_reset(self, email: str, token: str) -> None: + app = self._app_name() + link = f"{self._base}/users/reset-password?token={token}" + await self._send(email, f"Reset your {app} password", _TPL_RESET, link=link) + + async def send_invite( + self, + email: str, + token: str, + invited_by_name: str, + message: str | None = None, + ) -> None: + app = self._app_name() + link = f"{self._base}/users/invite/accept?token={token}" + await self._send( + email, + f"{invited_by_name} invited you to {app}", + _TPL_INVITE, + link=link, + invited_by_name=invited_by_name, + # Stripped here rather than in the template: a message of nothing + # but whitespace should leave no quoted block behind. + message=(message or "").strip() or None, + ) + + async def _send(self, to: str, subject: str, template_name: str, **context: Any) -> None: + body = _template_env.get_template(template_name).render( + app_name=self._app_name(), **context + ) + await current_mail_sender().send(MailMessage(to=to, subject=subject, text=body)) diff --git a/modules/users/users/settings.py b/modules/users/users/settings.py index 9d8bd571f..83bb1d5ae 100644 --- a/modules/users/users/settings.py +++ b/modules/users/users/settings.py @@ -106,15 +106,8 @@ def _lowercase_samesite(cls, value: object) -> object: """``"Strict"`` worked when this was a plain ``str`` (Starlette lowercases it).""" return value.lower() if isinstance(value, str) else value - # Mailer - mailer: str = Field(default="console", pattern="^(console|smtp)$") + # Mail transport (SMTP, console) lives in the ``mail`` module. base_url: str = "http://localhost:8000" - smtp_host: str = "" - smtp_port: int = 587 - smtp_username: str = "" - smtp_password: str = "" - smtp_from: str = "no-reply@localhost" - smtp_tls: bool = True # Login lockout (GH #530, ``users.auth_local.lockout``). Failures within the # window: ``login_rate_limit_failures`` from one IP for one username diff --git a/modules/users/users/startup.py b/modules/users/users/startup.py index 6516c81ed..c392de4e3 100644 --- a/modules/users/users/startup.py +++ b/modules/users/users/startup.py @@ -11,11 +11,11 @@ have changed in the admin UI; nothing in ``module.py`` may. Keeping the two apart makes that ordering visible instead of a comment. -Two helpers are public rather than underscored: ``register_mailer_health_check`` -and ``apply_login_redirect_fallback`` are self-contained *decisions* rather than -boot sequencing, and they were split out separately (#331) before the whole body -moved here. They keep their names and signatures so the seam stays where that -change put it. +``apply_login_redirect_fallback`` is public rather than underscored: it is a +self-contained *decision* rather than boot sequencing, and it was split out +separately (#331) before the whole body moved here. It keeps its name and +signature so the seam stays where that change put it. The mailer health check +(``mail.mailer``) now belongs to the ``mail`` module. """ from __future__ import annotations @@ -31,7 +31,6 @@ __all__ = [ "apply_login_redirect_fallback", - "register_mailer_health_check", "run_startup", ] @@ -53,7 +52,6 @@ async def run_startup(module: UsersModule, app: FastAPI) -> None: s = state.settings _build_mailer(app, state, s) - register_mailer_health_check(app, module.meta.name) _build_limiters(state, s) _build_oauth_clients(state, s) apply_login_redirect_fallback(app, s, module.meta.name, DEFAULT_LOGIN_REDIRECT_URL) @@ -89,30 +87,6 @@ def _app_name() -> str: state.mailer = build_mailer(s, _app_name) -def register_mailer_health_check(app: FastAPI, module_name: str) -> None: - """Add the mailer check to the health registry. - - Registered at startup rather than in ``register_health_checks`` because the - check needs the app to re-read DB-hydrated settings on every run. The owner - is passed explicitly since the boot-time ``set_owner`` window has long - closed by then. - """ - from simple_module_core.health import HealthCheck - - from users.health import CHECK_MAILER, build_mailer_check - - app.state.sm.health_registry.add( - HealthCheck( - name=CHECK_MAILER, - check=build_mailer_check(app), - module=module_name, - # On demand only: this authenticates against the mail provider, - # which must not happen on a readiness-probe timer. - probe=False, - ) - ) - - def _build_limiters(state, s) -> None: from users.auth_local.rate_limit import ThroughputLimiter diff --git a/packages/i18n/src/generated-resources.ts b/packages/i18n/src/generated-resources.ts index 3dac45709..c11dfa611 100644 --- a/packages/i18n/src/generated-resources.ts +++ b/packages/i18n/src/generated-resources.ts @@ -885,10 +885,12 @@ export default { 'tenants.members.head_title': '', 'tenants.members.invite_email_label': '', 'tenants.members.invite_email_placeholder': '', + 'tenants.members.invite_emailed': '', 'tenants.members.invite_link_copy': '', 'tenants.members.invite_link_description': '', 'tenants.members.invite_link_title': '', 'tenants.members.invite_role_label': '', + 'tenants.members.invite_share_link': '', 'tenants.members.invite_submit': '', 'tenants.members.invite_submitting': '', 'tenants.members.invite_title': '', diff --git a/packages/i18n/src/keys.generated.ts b/packages/i18n/src/keys.generated.ts index 0e46e8a4e..9c08f1c56 100644 --- a/packages/i18n/src/keys.generated.ts +++ b/packages/i18n/src/keys.generated.ts @@ -1136,10 +1136,12 @@ export const keys = { head_title: 'tenants.members.head_title', invite_email_label: 'tenants.members.invite_email_label', invite_email_placeholder: 'tenants.members.invite_email_placeholder', + invite_emailed: 'tenants.members.invite_emailed', invite_link_copy: 'tenants.members.invite_link_copy', invite_link_description: 'tenants.members.invite_link_description', invite_link_title: 'tenants.members.invite_link_title', invite_role_label: 'tenants.members.invite_role_label', + invite_share_link: 'tenants.members.invite_share_link', invite_submit: 'tenants.members.invite_submit', invite_submitting: 'tenants.members.invite_submitting', invite_title: 'tenants.members.invite_title', diff --git a/pyproject.toml b/pyproject.toml index d2cd43663..58cf2826b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,7 @@ extra-paths = [ "modules/branding", "modules/tenants", "modules/api_tokens", + "modules/mail", "host", "scripts", ] @@ -128,7 +129,7 @@ invalid-assignment = "ignore" [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/inertia/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "modules/branding/tests", "modules/site_lock/tests", "modules/tenants/tests", "modules/api_tokens/tests", "scripts/tests", "tests"] +testpaths = ["framework/cli/tests", "framework/core/tests", "framework/db/tests", "framework/hosting/tests", "framework/inertia/tests", "framework/testing/tests", "host/tests", "modules/auth/tests", "modules/dashboard/tests", "modules/users/tests", "modules/permissions/tests", "modules/background_tasks/tests", "modules/file_storage/tests", "modules/settings/tests", "modules/feature_flags/tests", "modules/keycloak/tests", "modules/audit_log/tests", "modules/branding/tests", "modules/site_lock/tests", "modules/tenants/tests", "modules/api_tokens/tests", "modules/mail/tests", "scripts/tests", "tests"] markers = [ "e2e: end-to-end tests requiring a live browser", "perf: performance benchmarks (opt-in; run via `make bench`)", diff --git a/skills/simple-module-conventions/SKILL.md b/skills/simple-module-conventions/SKILL.md index 137d48d2f..47eeeebcd 100644 --- a/skills/simple-module-conventions/SKILL.md +++ b/skills/simple-module-conventions/SKILL.md @@ -107,6 +107,10 @@ Projects using the `ty` type checker should globally suppress `unresolved-attrib The host CSP blocks any origin you don't declare — a font CDN or tile server your pages load from will silently fail in every host. Declare it in the hook: `registry.add("style-src", "https://rsms.me")`. Fetch directives only (never `default-src` / `base-uri` / `form-action` / `frame-ancestors`); single-token sources, validated at boot. +### 11. Send mail through `simple_module_core.mail`, never SMTP directly + +Build a `MailMessage(to, subject, text)` and send it with `current_mail_sender()` (request code) or `await resolve_mail_sender(db)` (durable handlers, which run in the outbox worker without an app). With no transport installed both return a logging sender, so never guard on "is mail configured"; read `sender.delivers_email` when a screen must hand the link back instead. The `mail` module owns SMTP and its settings. In tests, request the `captured_mail` fixture. + ## Related skills - **simple-module-database** — per-module Base, mixins, session lifecycle diff --git a/skills/simple-module-testing/SKILL.md b/skills/simple-module-testing/SKILL.md index 309aa5116..6ee72cc32 100644 --- a/skills/simple-module-testing/SKILL.md +++ b/skills/simple-module-testing/SKILL.md @@ -73,7 +73,7 @@ make test-e2e # in another terminal Env vars they read (defaults in parens): `E2E_BASE_URL` (`http://localhost:8000`), `E2E_USERNAME` (`admin@example.com`), `E2E_PASSWORD` (`admin`), `E2E_USER_ID` (optional — enables the password-reset test). -Token-minting helpers in `tests/e2e/conftest.py` use the same `SM_USERS_VERIFICATION_TOKEN_SECRET` as the server, so you don't need to scrape `ConsoleMailer` stdout to grab a verify link. +Token-minting helpers in `tests/e2e/conftest.py` use the same `SM_USERS_VERIFICATION_TOKEN_SECRET` as the server, so you don't need to scrape the console mail log to grab a verify link. ## Module-test layout