Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cc3b592
feat(core): mail message, sender protocol and process sender (#496)
antosubash Oct 20, 2026
d6ac809
fix(core): fallback mail sender logs no body (one-time tokens)
antosubash Oct 20, 2026
5a78725
docs: CHANGELOG and spec notes for #496
antosubash Oct 20, 2026
5787a84
feat(mail): add the mail module (SMTP/console transport, settings, he…
antosubash Oct 20, 2026
89ca44f
chore(mail): keep the check named mailer, enable Mail in e2e/perf CI,…
antosubash Oct 20, 2026
0920145
feat(users): send mail through the framework mail sender (#496)
antosubash Oct 20, 2026
4b570ab
build(users): depend on simple_module_mail (#496)
antosubash Oct 20, 2026
797a9f1
fix(mail): console backend logs no bodies outside development
antosubash Oct 20, 2026
407c517
test(settings): mailer keys and check now live in the mail module
antosubash Oct 20, 2026
c6fa199
feat(tenants): email invitations through the mail sender, outbox when…
antosubash Oct 20, 2026
508cfa7
chore(tenants): keep service.py under the 300-line cap
antosubash Oct 20, 2026
f26fdf1
docs: captured_mail fixture; settings hints name the mail keys
antosubash Oct 20, 2026
1c11e8c
docs: mail convention in CLAUDE.md and the conventions skill
antosubash Oct 20, 2026
52b4ed6
refactor(optimize): dedupe mail sender install, template rendering an…
antosubash Oct 20, 2026
1f8d324
fix: address code review findings (round 1, pass 1)
antosubash Oct 20, 2026
0083171
fix: address code review findings (round 1, pass 2)
antosubash Oct 20, 2026
fbd23e6
fix(qa): mail.smtp_port must be 1-65535; invite form clears the previ…
antosubash Oct 20, 2026
374a427
fix: address code review findings (round 2, pass 1)
antosubash Oct 20, 2026
7a3cc52
style: format the InviteForm test
antosubash Oct 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 2 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
],
Expand Down
7 changes: 1 addition & 6 deletions docs/framework/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
Expand All @@ -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]
```

Expand Down
4 changes: 2 additions & 2 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand All @@ -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)

Expand Down
3 changes: 2 additions & 1 deletion docs/modules/index.md
Original file line number Diff line number Diff line change
@@ -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 |
|---|---|---|
Expand All @@ -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

Expand Down
61 changes: 61 additions & 0 deletions docs/modules/mail.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions docs/modules/tenants.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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

Expand Down
13 changes: 3 additions & 10 deletions docs/modules/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/env-vars.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down
Loading
Loading