Skip to content

Commit 05dabda

Browse files
authored
feat(mail): mail module + core sender; tenant invitations emailed (#496) (#562)
* feat(core): mail message, sender protocol and process sender (#496) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(core): fallback mail sender logs no body (one-time tokens) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * docs: CHANGELOG and spec notes for #496 Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(mail): add the mail module (SMTP/console transport, settings, health check) (#496) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * chore(mail): keep the check named mailer, enable Mail in e2e/perf CI, cite revision Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(users): send mail through the framework mail sender (#496) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * build(users): depend on simple_module_mail (#496) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(mail): console backend logs no bodies outside development Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * test(settings): mailer keys and check now live in the mail module Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * feat(tenants): email invitations through the mail sender, outbox when available (#496) Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * chore(tenants): keep service.py under the 300-line cap Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * docs: captured_mail fixture; settings hints name the mail keys Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * docs: mail convention in CLAUDE.md and the conventions skill Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * refactor(optimize): dedupe mail sender install, template rendering and after-commit wiring Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix: address code review findings (round 1, pass 1) Queue invitation mail only when a transport is installed and the link is absolute, so the one-time token never sits in the outbox for nothing; a worker without the mail module fails the delivery (retry) instead of marking it sent. Document the token-in-outbox trade-off. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix: address code review findings (round 1, pass 2) SMTP operations time out after 15s, and the tenants invitation send runs after cache invalidation and event publish in the after-commit hook. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix(qa): mail.smtp_port must be 1-65535; invite form clears the previous result on resubmit Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * fix: address code review findings (round 2, pass 1) Queue invitation mail only for a sender that delivers; docs name the mail.mailer check and the mail settings section. Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4 * style: format the InviteForm test Claude-Session: https://claude.ai/code/session_01M9neheZZEe3sVpDi2S3zT4
1 parent bfc5f6a commit 05dabda

81 files changed

Lines changed: 2054 additions & 571 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/pr.yml‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -235,7 +235,7 @@ jobs:
235235
# Branding is included so the admin section's "edited on its own page,
236236
# never in the generic module editor" rule is actually exercised — the
237237
# server-side half of that is a 409 guard, which is worth CI coverage.
238-
SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog","Branding"]'
238+
SM_MODULES_ENABLED: '["Auth","Users","Mail","Dashboard","Permissions","Settings","BackgroundTasks","FileStorage","FeatureFlags","AuditLog","Branding"]'
239239
# Every test signs in from scratch, all from 127.0.0.1: the login page,
240240
# the login POST and the landing page are anonymous public-route hits,
241241
# ~120 a minute for the whole suite — right at the default
@@ -368,7 +368,7 @@ jobs:
368368
# Keycloak excluded (SM020: one auth provider). BackgroundTasks
369369
# excluded because its DB-hydrated settings reject a localhost broker
370370
# under SM_ENVIRONMENT=production.
371-
SM_MODULES_ENABLED: '["Auth","Users","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]'
371+
SM_MODULES_ENABLED: '["Auth","Users","Mail","Dashboard","Permissions","Settings","FileStorage","FeatureFlags","AuditLog","Branding"]'
372372
run: |
373373
uv run --project host uvicorn host.main:app --port 8000 > api.log 2>&1 &
374374
echo $! > api.pid

‎.github/workflows/release.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ jobs:
125125
- simple_module_feature_flags
126126
- simple_module_file_storage
127127
- simple_module_keycloak
128+
- simple_module_mail
128129
- simple_module_permissions
129130
- simple_module_settings
130131
- simple_module_site_lock

‎CHANGELOG.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,25 @@ All notable changes to this project are documented in this file. The format is b
1111
1212
## [Unreleased]
1313

14+
### Added
15+
- New `mail` module (`simple_module_mail`) owns outgoing mail: the SMTP/console
16+
transport, its settings (`/admin/settings/`, package `mail`) and the on-demand
17+
`mail.mailer` health check behind "Test mailer connection". Modules send through
18+
`simple_module_core.mail` (`current_mail_sender()` / `resolve_mail_sender(db)`),
19+
which falls back to logging when no transport is installed (#496).
20+
- Tenant invitations are now emailed (#496). With `background_tasks` installed
21+
the email goes through the durable outbox (`tenants.send_invitation_email`,
22+
retried); otherwise it is sent after the request commits. A link is only
23+
emailed when `public_base_url` is set. The invite API response gains
24+
`emailed`, and the invite form says whether the link was emailed.
25+
- `captured_mail` pytest fixture in `simple_module_test` records sent mail.
26+
1427
### Changed
28+
- **Behaviour change:** the `users` mailer settings (`mailer`, `smtp_*`) moved to
29+
the `mail` module as `backend` and `smtp_*`; a data migration (`d7b3a9e5c1f2`)
30+
moves stored values (#496). `simple_module_users` now depends on `simple_module_mail`, and
31+
the `users.mailer` health check is now `mail.mailer`. The `users.Mailer`
32+
protocol is unchanged.
1533
- `ty` now checks `invalid-argument-type` and `invalid-assignment` in shipped
1634
code (tests stay exempt), and the ~150 mypy-style `# type: ignore[...]`
1735
comments, which suppressed nothing under ty, are gone (#507). Remaining

‎CLAUDE.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -345,6 +345,11 @@ development and testing (`session_cookie_secure`).
345345
heavy imports (`torch`, `cv2`, …) inside the task body (`SM028`). Enqueue from
346346
a transaction with `background_tasks.enqueue_after_commit(db, task, …)`, never
347347
`.delay()`. Long tasks call `heartbeat()`. `docs/modules/background_tasks.md`.
348+
- **Mail**: build a `MailMessage` and send through `simple_module_core.mail`
349+
(`current_mail_sender()` in request code, `await resolve_mail_sender(db)` in
350+
durable handlers, which have no app). The `mail` module owns SMTP and its
351+
settings; without it the sender only logs (`delivers_email` is false). Tests
352+
use the `captured_mail` fixture.
348353
- **Audit links / design packs**: `register_audit_links` takes
349354
`AuditLink(entity_type=Model.__name__, …)` (class name, not table name, or it
350355
silently never matches); `register_design_packs` slugs must match

‎README.md‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -299,15 +299,10 @@ Set `SM_USERS_ALLOW_SIGNUP=true` and restart the server. The `/users/register` p
299299

300300
### Switching to SMTP
301301

302+
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:
303+
302304
```
303-
SM_USERS_MAILER=smtp
304305
SM_USERS_BASE_URL=https://your-domain.com
305-
SM_USERS_SMTP_HOST=smtp.example.com
306-
SM_USERS_SMTP_PORT=587
307-
SM_USERS_SMTP_USERNAME=no-reply@example.com
308-
SM_USERS_SMTP_PASSWORD=secret
309-
SM_USERS_SMTP_FROM=no-reply@example.com
310-
SM_USERS_SMTP_TLS=true
311306
```
312307

313308
## Architecture

‎docs/.vitepress/config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ export default defineConfig({
189189
{ text: "background_tasks", link: "/modules/background_tasks" },
190190
{ text: "audit_log", link: "/modules/audit_log" },
191191
{ text: "site_lock", link: "/modules/site_lock" },
192+
{ text: "mail", link: "/modules/mail" },
192193
{ text: "tenants", link: "/modules/tenants" },
193194
{ text: "dashboard", link: "/modules/dashboard" },
194195
],

‎docs/framework/settings.md‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,7 @@ from pydantic_settings import BaseSettings
4646

4747
class UsersEnv(BaseSettings):
4848
allow_signup: bool = False
49-
mailer: str = "console"
5049
base_url: str | None = None
51-
smtp_host: str | None = None
52-
smtp_port: int = 587
53-
smtp_username: str | None = None
54-
smtp_password: str | None = None
5550

5651
class Config:
5752
env_prefix = "SM_USERS_"
@@ -65,7 +60,7 @@ from dataclasses import dataclass
6560
@dataclass
6661
class UsersState:
6762
settings: UsersEnv
68-
mailer: Mailer # picked based on settings.mailer
63+
mailer: Mailer # sends through the mail module's sender
6964
principal_serializer: Callable[[User], dict]
7065
```
7166

‎docs/guide/configuration.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ These are **DB-backed host settings** (under `host` in `/admin/settings/`), not
4949

5050
## Users module
5151

52-
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/`.
52+
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`.
5353

5454
| Variable | Default | Notes |
5555
|---|---|---|
@@ -58,7 +58,7 @@ Only the first-boot **bootstrap seed** is read from the env (prefix `SM_USERS_*`
5858
| `SM_USERS_BOOTSTRAP_USER_EMAIL` | unset | Optional second, non-admin seed user. |
5959
| `SM_USERS_BOOTSTRAP_USER_PASSWORD` | unset | Paired with the user email above. |
6060

61-
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`).
61+
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.
6262

6363
## Background tasks (Celery)
6464

‎docs/modules/index.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Bundled modules
22

3-
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.
3+
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.
44

55
| Module | Depends on | What it provides |
66
|---|---|---|
@@ -18,6 +18,7 @@ simple_module_python ships with fourteen first-party modules. Each is a regular
1818
| [`dashboard`](/modules/dashboard) | `users` | Authenticated landing page with system overview (user counts, module list, health checks). |
1919
| [`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. |
2020
| [`site_lock`](/modules/site_lock) | `settings`, `auth` | Optional site-wide shared-password gate for staging / pre-launch sites. Off by default. |
21+
| [`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`. |
2122

2223
## How modules are wired in
2324

‎docs/modules/mail.md‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# mail
2+
3+
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.
4+
5+
## ModuleMeta
6+
7+
| Field | Value |
8+
|---|---|
9+
| `name` | `Mail` |
10+
| `route_prefix` | *(none)* |
11+
| `view_prefix` | *(none)* |
12+
| `depends_on` | `["Settings"]` |
13+
14+
The module has no tables, routes or pages. It is configured on the generic settings page (**Settings -> Modules -> Mail**).
15+
16+
## Settings
17+
18+
DB-backed (`MailSettings`, package `mail`), hot-reloaded without a restart.
19+
20+
| Field | Default | Notes |
21+
|---|---|---|
22+
| `backend` | `console` | `console` logs the whole message (development); `smtp` delivers |
23+
| `smtp_host` | `""` | |
24+
| `smtp_port` | `587` | |
25+
| `smtp_username` | `""` | blank means no authentication |
26+
| `smtp_password` | `""` | masked in the settings UI |
27+
| `smtp_from` | `no-reply@localhost` | |
28+
| `smtp_tls` | `true` | implicit TLS (`use_tls`) |
29+
30+
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.
31+
32+
## Sending mail from a module
33+
34+
```python
35+
from simple_module_core.mail import MailMessage, current_mail_sender
36+
37+
sender = current_mail_sender()
38+
await sender.send(MailMessage(to="a@example.com", subject="Hi", text="Plain body"))
39+
```
40+
41+
- `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).
42+
- `sender.delivers_email` is `False` for console senders, so a screen can show a link instead of promising an inbox.
43+
- 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.
44+
45+
## Lifecycle
46+
47+
| Hook | What it does |
48+
|---|---|
49+
| `register_settings` | `register_module_settings` mounts `app.state.mail` |
50+
| `register_health_checks` | adds the on-demand `mail.mailer` check |
51+
| `register_event_handlers` | installs the resolver; on `SettingsReloaded(package="mail")` rebuilds and reinstalls the sender |
52+
| `on_startup` | installs the sender built from the hydrated settings |
53+
| `on_shutdown` | uninstalls the sender and the resolver |
54+
55+
## Health check
56+
57+
`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.
58+
59+
## Tests
60+
61+
`simple_module_test` provides a `captured_mail` fixture that installs a recording sender for the test and restores the previous state afterwards.

0 commit comments

Comments
 (0)