- {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