simple-module-mail 0.0.38__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mail/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Mail module."""
mail/constants.py ADDED
@@ -0,0 +1,24 @@
1
+ """Every literal the mail module depends on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ MODULE_NAME = "Mail"
6
+ MODULE_PACKAGE = "mail"
7
+
8
+ # Dependency names must match the other modules' ``ModuleMeta.name``.
9
+ MODULE_SETTINGS = "Settings"
10
+
11
+ BACKEND_CONSOLE = "console"
12
+ BACKEND_SMTP = "smtp"
13
+
14
+ # On-demand health check; also drives the settings page's "Test connection".
15
+ CHECK_MAILER = "mail.mailer"
16
+
17
+ DEFAULT_FROM = "no-reply@localhost"
18
+ DEFAULT_SMTP_PORT = 587
19
+ MIN_PORT = 1
20
+ MAX_PORT = 65535
21
+
22
+ #: Seconds per SMTP operation (aiosmtplib defaults to 60). A send can run in a
23
+ #: request's after-commit hook, so a hung server must not hold it for a minute.
24
+ SMTP_TIMEOUT_SECONDS = 15.0
mail/module.py ADDED
@@ -0,0 +1,75 @@
1
+ """Mail module — the app's outgoing-mail transport, configured from the DB."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from simple_module_core.health import HealthCheck, HealthRegistry
8
+ from simple_module_core.mail import install_mail_resolver, install_mail_sender
9
+ from simple_module_core.module import ModuleBase, ModuleMeta
10
+
11
+ from mail import constants as c
12
+
13
+ if TYPE_CHECKING:
14
+ from fastapi import FastAPI
15
+ from simple_module_core.events import EventBus
16
+
17
+
18
+ class MailModule(ModuleBase):
19
+ meta = ModuleMeta(
20
+ name=c.MODULE_NAME,
21
+ # ``Settings``: the runtime re-reads overrides through its SettingsStore.
22
+ depends_on=[c.MODULE_SETTINGS],
23
+ )
24
+
25
+ def register_settings(self, app: FastAPI) -> None:
26
+ from simple_module_core.module_settings import register_module_settings
27
+
28
+ from mail.settings import MailSettings
29
+ from mail.state import MailState
30
+
31
+ register_module_settings(
32
+ app, c.MODULE_PACKAGE, MailSettings, lambda s: MailState(settings=s)
33
+ )
34
+
35
+ def register_health_checks(self, registry: HealthRegistry) -> None:
36
+ from mail.runtime import check_smtp
37
+
38
+ registry.add(
39
+ HealthCheck(
40
+ name=c.CHECK_MAILER,
41
+ check=check_smtp,
42
+ # On demand only: this authenticates against the mail provider,
43
+ # which must not happen on a readiness-probe timer.
44
+ probe=False,
45
+ )
46
+ )
47
+
48
+ def register_event_handlers(self, bus: EventBus, app: FastAPI | None = None) -> None:
49
+ # Before the ``app is None`` return: the outbox worker builds modules
50
+ # without an app and still has to send durable mail.
51
+ from mail.runtime import resolve_sender_from_db
52
+
53
+ install_mail_resolver(resolve_sender_from_db)
54
+ if app is None:
55
+ return
56
+
57
+ from settings.contracts.events import SettingsReloaded
58
+
59
+ from mail.runtime import install_app_sender
60
+
61
+ async def _reinstall(event: SettingsReloaded) -> None:
62
+ if event.package != c.MODULE_PACKAGE:
63
+ return
64
+ install_app_sender(app)
65
+
66
+ bus.subscribe(SettingsReloaded, _reinstall)
67
+
68
+ async def on_startup(self, app: FastAPI) -> None:
69
+ from mail.runtime import install_app_sender
70
+
71
+ install_app_sender(app)
72
+
73
+ async def on_shutdown(self, app: FastAPI) -> None:
74
+ install_mail_sender(None)
75
+ install_mail_resolver(None)
mail/py.typed ADDED
File without changes
mail/runtime.py ADDED
@@ -0,0 +1,63 @@
1
+ """Resolver and health check: the parts that work without an app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Any
7
+
8
+ from settings.service import SettingService
9
+ from settings.store import SettingsStore
10
+ from simple_module_core.environments import NON_PROD_ENVIRONMENTS
11
+ from simple_module_core.health import HealthCheckResult, HealthStatus
12
+ from simple_module_core.mail import MailSender, current_mail_sender, install_mail_sender
13
+ from simple_module_core.settings_hydrate import hydrate_settings
14
+
15
+ from mail import constants as c
16
+ from mail.sender import build_sender
17
+ from mail.settings import MailSettings
18
+
19
+
20
+ async def resolve_sender_from_db(db: Any) -> MailSender:
21
+ """Build a sender from the stored settings; used where none is installed.
22
+
23
+ The outbox worker builds modules without an app, so it has only a session.
24
+ """
25
+ store = SettingsStore(SettingService(db))
26
+ settings = await hydrate_settings(MailSettings, store, c.MODULE_PACKAGE, lenient=True)
27
+ return build_sender(settings, log_bodies=_worker_is_non_prod())
28
+
29
+
30
+ def _worker_is_non_prod() -> bool:
31
+ # No app (and so no host settings) in the worker: read the process's
32
+ # environment the way background_tasks' own checks do.
33
+ return os.environ.get("SM_ENVIRONMENT", "development") in NON_PROD_ENVIRONMENTS
34
+
35
+
36
+ def install_app_sender(app: Any) -> None:
37
+ """Install a sender built from *app*'s current mail settings.
38
+
39
+ Bodies are logged only in development or testing (host ``environment``).
40
+ """
41
+ log_bodies = app.state.sm.settings.environment in NON_PROD_ENVIRONMENTS
42
+ install_mail_sender(build_sender(app.state.mail.settings, log_bodies=log_bodies))
43
+
44
+
45
+ async def check_smtp() -> HealthCheckResult:
46
+ """Verify the installed sender, re-read on every run.
47
+
48
+ Settings can change after boot, so a check pinned to the boot-time sender
49
+ would keep testing credentials the admin has replaced.
50
+ """
51
+ sender = current_mail_sender()
52
+ verify = getattr(sender, "verify_connection", None)
53
+ if verify is None:
54
+ return HealthCheckResult(
55
+ status=HealthStatus.HEALTHY,
56
+ detail=f"{type(sender).__name__} needs no connection",
57
+ )
58
+ try:
59
+ await verify()
60
+ except Exception as exc:
61
+ # The reason matters more than the traceback.
62
+ return HealthCheckResult(status=HealthStatus.UNHEALTHY, detail=str(exc))
63
+ return HealthCheckResult(status=HealthStatus.HEALTHY, detail="SMTP reachable")
mail/sender.py ADDED
@@ -0,0 +1,94 @@
1
+ """Mail senders and the settings -> sender mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ from email.message import EmailMessage
7
+
8
+ import aiosmtplib
9
+ from simple_module_core.mail import ConsoleMailSender, MailMessage, MailSender
10
+
11
+ from mail import constants as c
12
+ from mail.settings import MailSettings
13
+
14
+
15
+ class SmtpMailSender:
16
+ """Delivers through an SMTP server with aiosmtplib."""
17
+
18
+ delivers_email = True
19
+
20
+ def __init__(
21
+ self,
22
+ host: str,
23
+ port: int,
24
+ username: str,
25
+ password: str,
26
+ from_address: str,
27
+ use_tls: bool,
28
+ ) -> None:
29
+ self._host = host
30
+ self._port = port
31
+ self._username = username
32
+ self._password = password
33
+ self._from = from_address
34
+ self._use_tls = use_tls
35
+
36
+ async def send(self, message: MailMessage) -> None:
37
+ email = EmailMessage()
38
+ email["From"] = self._from
39
+ email["To"] = message.to
40
+ email["Subject"] = message.subject
41
+ email.set_content(message.text)
42
+ if message.html:
43
+ email.add_alternative(message.html, subtype="html")
44
+
45
+ await aiosmtplib.send(
46
+ email,
47
+ hostname=self._host,
48
+ port=self._port,
49
+ username=self._username or None,
50
+ password=self._password or None,
51
+ use_tls=self._use_tls,
52
+ timeout=c.SMTP_TIMEOUT_SECONDS,
53
+ )
54
+
55
+ async def verify_connection(self) -> None:
56
+ """Open an SMTP session and authenticate, then hang up.
57
+
58
+ Deliberately stops short of sending anything: an admin checking their
59
+ config should not put a stray message in someone's inbox. Raises
60
+ whatever aiosmtplib raises so the caller can show the real reason.
61
+ """
62
+ client = aiosmtplib.SMTP(
63
+ hostname=self._host,
64
+ port=self._port,
65
+ use_tls=self._use_tls,
66
+ timeout=c.SMTP_TIMEOUT_SECONDS,
67
+ )
68
+ await client.connect()
69
+ try:
70
+ if self._username:
71
+ await client.login(self._username, self._password or "")
72
+ finally:
73
+ # A failure hanging up must not mask the login error being unwound.
74
+ with contextlib.suppress(Exception):
75
+ await client.quit()
76
+
77
+
78
+ def build_sender(settings: MailSettings, *, log_bodies: bool = True) -> MailSender:
79
+ """``smtp`` -> :class:`SmtpMailSender`; ``console`` -> a logging sender.
80
+
81
+ *log_bodies* is false outside development and testing: a console backend
82
+ left in place in production must not write reset and invite tokens to the
83
+ log, so there it logs only recipient and subject.
84
+ """
85
+ if settings.backend == c.BACKEND_SMTP:
86
+ return SmtpMailSender(
87
+ host=settings.smtp_host,
88
+ port=settings.smtp_port,
89
+ username=settings.smtp_username,
90
+ password=settings.smtp_password,
91
+ from_address=settings.smtp_from,
92
+ use_tls=settings.smtp_tls,
93
+ )
94
+ return ConsoleMailSender(include_body=log_bodies)
mail/settings.py ADDED
@@ -0,0 +1,30 @@
1
+ """Mail module settings — DB-backed via ``register_module_settings``.
2
+
3
+ ``smtp_password`` is masked by the settings UI because its name matches the
4
+ secret-field pattern (``simple_module_core.secret_fields.is_secret_field``).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Literal
10
+
11
+ from pydantic import Field
12
+ from pydantic_settings import SettingsConfigDict
13
+ from simple_module_core.settings_base import DbBackedSettings
14
+
15
+ from mail import constants as c
16
+
17
+
18
+ class MailSettings(DbBackedSettings):
19
+ """Which transport sends mail, and the SMTP account it uses."""
20
+
21
+ # ``DbBackedSettings`` so the DB is the only source (GH #283).
22
+ model_config = SettingsConfigDict(extra="ignore")
23
+
24
+ backend: Literal["console", "smtp"] = c.BACKEND_CONSOLE
25
+ smtp_host: str = ""
26
+ smtp_port: int = Field(default=c.DEFAULT_SMTP_PORT, ge=c.MIN_PORT, le=c.MAX_PORT)
27
+ smtp_username: str = ""
28
+ smtp_password: str = ""
29
+ smtp_from: str = c.DEFAULT_FROM
30
+ smtp_tls: bool = True
mail/state.py ADDED
@@ -0,0 +1,14 @@
1
+ """Module-owned state attached to ``app.state.mail``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from mail.settings import MailSettings
8
+
9
+
10
+ @dataclass
11
+ class MailState:
12
+ """``settings`` is reassigned in place on a hot reload, so stay mutable."""
13
+
14
+ settings: MailSettings
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.5
2
+ Name: simple_module_mail
3
+ Version: 0.0.38
4
+ Summary: Outgoing mail (SMTP or console) for simple_module apps, configured from the DB-backed settings
5
+ Project-URL: Homepage, https://github.com/antosubash/simple_module_python
6
+ Project-URL: Repository, https://github.com/antosubash/simple_module_python
7
+ Project-URL: Issues, https://github.com/antosubash/simple_module_python/issues
8
+ Project-URL: Changelog, https://github.com/antosubash/simple_module_python/blob/main/CHANGELOG.md
9
+ Author-email: Anto Subash <antosubash@live.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: email,mail,simple-module,smtp
13
+ Requires-Python: >=3.12
14
+ Requires-Dist: aiosmtplib>=3.0
15
+ Requires-Dist: simple-module-core==0.0.38
16
+ Requires-Dist: simple-module-settings==0.0.38
17
+ Description-Content-Type: text/markdown
18
+
19
+ # simple_module_mail
20
+
21
+ Outgoing mail for `simple_module` apps. The module owns the transport (SMTP via
22
+ `aiosmtplib`, or a console backend that logs each message) and its settings, and
23
+ installs a process-level sender that any module can use through
24
+ `simple_module_core.mail` without knowing whether mail is configured.
25
+
26
+ The default backend is `console`: messages are logged, nothing is delivered.
27
+ Switch to `smtp` and fill in the server details to deliver real email.
28
+
29
+ ## Install
30
+
31
+ Add the package to your host and re-sync. Discovery picks it up from the
32
+ `simple_module` entry point, no host code changes:
33
+
34
+ ```toml
35
+ # host/pyproject.toml
36
+ dependencies = ["simple_module_mail"]
37
+ ```
38
+
39
+ ```bash
40
+ uv sync --all-packages
41
+ ```
42
+
43
+ ## Usage
44
+
45
+ 1. Sign in as an admin and open **Settings -> Modules -> Mail**.
46
+ 2. Set `backend` to `smtp` and fill `smtp_host`, `smtp_port`, `smtp_username`,
47
+ `smtp_password` (masked in the UI), `smtp_from` and `smtp_tls`. Saving
48
+ rebuilds the sender live, no restart needed.
49
+ 3. Use **Test connection** to run the `mail.mailer` health check. It logs in to
50
+ the server and hangs up without sending anything.
51
+
52
+ From code, send through the core interface:
53
+
54
+ ```python
55
+ from simple_module_core.mail import MailMessage, current_mail_sender
56
+
57
+ await current_mail_sender().send(
58
+ MailMessage(to="a@example.com", subject="Hello", text="Plain body")
59
+ )
60
+ ```
61
+
62
+ Code that may run in the outbox worker (no `app`) calls
63
+ `await resolve_mail_sender(db)` instead; the module installs a resolver that
64
+ builds the sender from the stored settings. `sender.delivers_email` is `False`
65
+ for the console backend, so screens can fall back to showing a link.
66
+
67
+ Settings stored by earlier versions as `users.mailer` / `users.smtp_*` are moved
68
+ to `mail.backend` / `mail.smtp_*` by a data migration.
@@ -0,0 +1,13 @@
1
+ mail/__init__.py,sha256=Y-bRGfB1ShpdMEa3VnYILwicDfKX2FRg0U_s3-vA_CA,19
2
+ mail/constants.py,sha256=YSGPqHmbq_QdXUjVFWGp_yFkh4zTFkFKJWJlE-ZsEag,667
3
+ mail/module.py,sha256=syTNxf_AFrgIejUDZQbiOcMRVEyG7PI9NH13TeFZbrY,2485
4
+ mail/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ mail/runtime.py,sha256=rhsqUxAl4Avd_4TO18997PkZ-1wO85ghBPlH-UFKfjs,2461
6
+ mail/sender.py,sha256=uMSfJU82PF9GYabKMNU9VqjO-PQ3KTML8uYN2zhBFio,3050
7
+ mail/settings.py,sha256=EGEc5uxwr0kDIewA-EQFP-nL0TVHrB1n-ECF-_iN4Bw,985
8
+ mail/state.py,sha256=WIhsa4dsO9nk8HvL812m4D1AJgVJ9WnRsQpn19-ZLtE,306
9
+ simple_module_mail-0.0.38.dist-info/METADATA,sha256=_PMaL8ZIrgeA8Ae5AMMTA9iWjgQOzYQlW5gHD_4u5Bc,2591
10
+ simple_module_mail-0.0.38.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
11
+ simple_module_mail-0.0.38.dist-info/entry_points.txt,sha256=O3Bwcev1zoGXKR9CuwpTquAbn53euXpw6suXARrpyic,46
12
+ simple_module_mail-0.0.38.dist-info/licenses/LICENSE,sha256=UIp30ue1HZit7tMmSK0SS3swJBqOcLLnLJn5LY5YdNE,1036
13
+ simple_module_mail-0.0.38.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [simple_module]
2
+ mail = mail.module:MailModule
@@ -0,0 +1,19 @@
1
+ MIT License
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy
4
+ of this software and associated documentation files (the "Software"), to deal
5
+ in the Software without restriction, including without limitation the rights
6
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7
+ copies of the Software, and to permit persons to whom the Software is
8
+ furnished to do so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.