From 0f28d5f9ca26745a7814fe16f2ad2a53ab3ecdf4 Mon Sep 17 00:00:00 2001 From: joaovitoriasilva <8648976+joaovitoriasilva@users.noreply.github.com> Date: Fri, 6 Nov 2026 22:35:46 +0000 Subject: [PATCH] feat(providers): make the mail send deadline public Hosts that size a lease or timeout around a send need the same bound SmtpMail enforces. MAIL_SEND_DEADLINE_SECONDS now lives in jasil.providers; the SMTP backend and the composition root's max_runtime_seconds warning read it from there. Closes #57 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 ++++ docs/api-stability.md | 6 +++--- docs/durable-jobs.md | 4 ++-- docs/providers-and-backends.md | 4 ++++ jasil/backends/mail_smtp.py | 28 +++++++++++++--------------- jasil/container.py | 6 +++--- jasil/providers.py | 5 +++++ tests/test_container.py | 5 +++-- tests/test_mail.py | 8 ++++---- 9 files changed, 41 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21ee188..62b1593 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ What counts as a breaking change — and what does not — is defined in [API st ## [Unreleased] +### Added + +- **`jasil.providers.MAIL_SEND_DEADLINE_SECONDS`** is public: the upper bound on one SMTP `send` (90 seconds, DNS lookups excepted). Import it to size a lease or timeout around a send; the value may change in a minor release. `jasil.container.SMTP_SEND_DEADLINE_SECONDS`, which was not covered, is removed. ([#57](https://github.com/endurain-project/jasil/issues/57)) + ## [0.17.0] - 2026-11-04 This release fixes the first audit write on a fresh SQLite database failing with `database is locked`. Migration `rev0010` creates the `jasil_instance` row, and `ensure_instance_id` is now public. diff --git a/docs/api-stability.md b/docs/api-stability.md index 7c54c8b..2723065 100644 --- a/docs/api-stability.md +++ b/docs/api-stability.md @@ -19,7 +19,7 @@ modules: | Module | Surface | |---|---| -| `jasil.providers` | The capability protocols and their method signatures, the value types they exchange (`GeocodedPlace`, `MailMessage`, `UploadSession`, ...), `AuditSink` and `AuditSinkConflictError`, and `MAX_REALTIME_KEY_LENGTH`. | +| `jasil.providers` | The capability protocols and their method signatures, the value types they exchange (`GeocodedPlace`, `MailMessage`, `UploadSession`, ...), `AuditSink` and `AuditSinkConflictError`, `MAX_REALTIME_KEY_LENGTH`, and `MAIL_SEND_DEADLINE_SECONDS` (its value may change in a minor release). | | `jasil.events` | `Event`, `new_event`, `META_REQUEST_ID`, `INITIAL_SCHEMA_VERSION`, `MAX_EVENT_ID_LENGTH`, `MAX_EVENT_TYPE_LENGTH`, `MAX_SOURCE_LENGTH`. | | `jasil.event_versioning` | `VersionedPayload`, `parse_payload`, `UnsupportedEventVersionError`. | | `jasil.profile` | `DeploymentProfile`, `JobWorkerMode`, `parse_profile`, `parse_job_worker_mode`, `classify_state_uri` with its `StateBackendKind`, `DeploymentTopology` (`multi_process`, `multi_node`, `multi_process_causes`) and `resolve_topology`. | @@ -142,8 +142,8 @@ wiring and tests: - The limit constants and helpers a public module imports from `jasil._core`: `MAX_AUDIT_*`, `MAX_JOB_BATCH_SIZE`, `MAX_PARTITION_DAYS_AHEAD` and `MAX_RETENTION_DAYS` in `jasil.settings`, `MAX_DASHBOARD_*` and `check_limit` in - `jasil.admin`, `MAX_KEY_LENGTH` in `jasil.providers`, and `SMTP_PORTS`, - `SMTP_SEND_DEADLINE_SECONDS` and `resolve_reverse_endpoint` in `jasil.container`. + `jasil.admin`, `MAX_KEY_LENGTH` in `jasil.providers`, and `SMTP_PORTS` and + `resolve_reverse_endpoint` in `jasil.container`. Their values are described in the configuration page; the names are not covered. - `jasil.testing.DEFAULT_NOW`, `jasil.jobs.registry.normalize_queue_selector` and `JobHandler`, and the `profile`, `web_workers` and `job_worker_mode` fields of diff --git a/docs/durable-jobs.md b/docs/durable-jobs.md index e44875f..f2b2a08 100644 --- a/docs/durable-jobs.md +++ b/docs/durable-jobs.md @@ -561,8 +561,8 @@ failure. - **Delivery is at-least-once.** A retry after a lost acknowledgement sends the message twice. - **Size `jobs.max_runtime_seconds` for the whole handler, not one send.** One - SMTP send can take up to 90 seconds, and the DNS lookups before it are not - counted against that deadline. A handler that sends N messages can take N times + SMTP send can take up to `jasil.providers.MAIL_SEND_DEADLINE_SECONDS` (90 + seconds), and the DNS lookups before it are not counted against that deadline. A handler that sends N messages can take N times that. The worker's heartbeat keeps the job's lease alive while it runs, so the lease itself no longer needs to be that long; the cap is `max_runtime_seconds` (default 30 minutes). Past it the job is abandoned and a diff --git a/docs/providers-and-backends.md b/docs/providers-and-backends.md index 0ce97b8..8d1810c 100644 --- a/docs/providers-and-backends.md +++ b/docs/providers-and-backends.md @@ -532,6 +532,10 @@ CR/LF in a header value). `MemoryMail` validates through the same builder as `MailError` messages carry a failure category and SMTP status code only — never an address, subject, body or server text. +One `SmtpMail` send is bounded by `MAIL_SEND_DEADLINE_SECONDS` (90 seconds; DNS +lookups are not counted). Import it to size a lease or timeout around a send +rather than copying the value, which may change in a minor release. + The sender is `from_address`, independent of the SMTP `username`: relays such as Brevo log you in as a generated identity and reject a `From` that is not a verified sender. The `From` header keeps any display name; the envelope sender is diff --git a/jasil/backends/mail_smtp.py b/jasil/backends/mail_smtp.py index 5e34c9d..29deb93 100644 --- a/jasil/backends/mail_smtp.py +++ b/jasil/backends/mail_smtp.py @@ -27,24 +27,22 @@ import jasil._core.network as network from jasil._core.job_context import lease_lost from jasil._core.mail_message import build_mime, parse_address -from jasil.providers import ClockProvider, MailError, MailMessage +from jasil.providers import MAIL_SEND_DEADLINE_SECONDS, ClockProvider, MailError, MailMessage logger = logging.getLogger(__name__) # Applied to every socket operation of a send (seconds). _TIMEOUT_SECONDS = 30 -# Ceiling on one ``send`` (public: the composition root compares the job lease to -# it). It bounds one message, not one job: a handler sending N messages can take N -# times this, plus the DNS lookups it cannot interrupt, and its lease must cover -# all of that or the reaper requeues it mid-delivery. A socket timeout alone -# cannot enforce it: every read restarts the timeout, so a server dripping its -# reply a byte at a time never trips one. A watchdog shuts the socket down when it expires, and -# keeps doing so until the send ends: the socket may not exist yet at the -# deadline (a slow connect), and a single shutdown would then be missed. The -# watchdog holds a duplicate of the raw socket, because during a TLS handshake -# ``connection.sock`` is the pre-wrap socket, which the wrap has already detached. -# A DNS lookup is the one step it cannot interrupt. -SEND_DEADLINE_SECONDS = 90 +# ``MAIL_SEND_DEADLINE_SECONDS`` is the ceiling on one ``send``. It bounds one +# message, not one job: a handler sending N messages can take N times this, plus +# the DNS lookups it cannot interrupt. A socket timeout alone cannot enforce it: +# every read restarts the timeout, so a server dripping its reply a byte at a time +# never trips one. A watchdog shuts the socket down when it expires, and keeps +# doing so until the send ends: the socket may not exist yet at the deadline (a +# slow connect), and a single shutdown would then be missed. The watchdog holds a +# duplicate of the raw socket, because during a TLS handshake ``connection.sock`` +# is the pre-wrap socket, which the wrap has already detached. A DNS lookup is the +# one step it cannot interrupt. _WATCH_INTERVAL_SECONDS = 0.1 _DEADLINE_EXCEEDED = "SMTP send exceeded its overall deadline" @@ -244,7 +242,7 @@ def _arm(connection: smtplib.SMTP, deadline: float) -> None: @staticmethod def _watch(connection: smtplib.SMTP, expired: threading.Event, finished: threading.Event) -> None: """Watchdog: at the deadline, flag it and keep unblocking reads until done.""" - if finished.wait(SEND_DEADLINE_SECONDS): + if finished.wait(MAIL_SEND_DEADLINE_SECONDS): return expired.set() while not finished.is_set(): @@ -307,7 +305,7 @@ def _connect(self, connection: smtplib.SMTP, deadline: float) -> tuple[int, byte def _deliver(self, mime: EmailMessage) -> dict[str, tuple[int, bytes]]: """Connect, secure, authenticate and send; returns refused recipients.""" context = self._context - deadline = time.monotonic() + SEND_DEADLINE_SECONDS + deadline = time.monotonic() + MAIL_SEND_DEADLINE_SECONDS # Built without a host so nothing is dialed before the watchdog is armed: # the greeting is a read like any other, and can be dripped as slowly. connection: smtplib.SMTP diff --git a/jasil/container.py b/jasil/container.py index 508f858..62847a3 100644 --- a/jasil/container.py +++ b/jasil/container.py @@ -39,7 +39,6 @@ from jasil.backends.lock_noop import NoopLock from jasil.backends.lock_pg import PgAdvisoryLock from jasil.backends.mail_memory import MemoryMail, NullMail -from jasil.backends.mail_smtp import SEND_DEADLINE_SECONDS as SMTP_SEND_DEADLINE_SECONDS from jasil.backends.mail_smtp import SmtpMail from jasil.backends.realtime_inprocess import InProcessRealtime from jasil.backends.realtime_none import NullRealtime @@ -49,6 +48,7 @@ from jasil.backends.storage_none import NullStorage from jasil.profile import DeploymentProfile from jasil.providers import ( + MAIL_SEND_DEADLINE_SECONDS, AuditSink, ClockProvider, EventBusProvider, @@ -527,7 +527,7 @@ def _build_mail(settings: JasilSettings, clock: ClockProvider) -> MailProvider: return MemoryMail(clock, mail.from_address) if not mail.secure: logger.warning("Mail over smtp:// is CLEARTEXT (mail.secure=False); use only on a trusted private network") - if settings.jobs.enabled and settings.jobs.max_runtime_seconds <= SMTP_SEND_DEADLINE_SECONDS: + if settings.jobs.enabled and settings.jobs.max_runtime_seconds <= MAIL_SEND_DEADLINE_SECONDS: # A running job keeps its lease, but only up to ``max_runtime_seconds``: past # it the job is abandoned and a send in flight is delivered by a worker that # no longer owns the job. Only the floor is checkable here: a handler @@ -536,7 +536,7 @@ def _build_mail(settings: JasilSettings, clock: ClockProvider) -> MailProvider: "jobs.max_runtime_seconds (%d) is not above the SMTP send deadline (%d s, per " "message); a slow send can be abandoned mid-delivery and sent again by a retry", settings.jobs.max_runtime_seconds, - SMTP_SEND_DEADLINE_SECONDS, + MAIL_SEND_DEADLINE_SECONDS, ) resolved_port = parts.port or SMTP_PORTS[scheme] logger.info("Mail enabled via %s%s", scheme, " (authenticated)" if mail.username else "") diff --git a/jasil/providers.py b/jasil/providers.py index 1b5c513..da1d834 100644 --- a/jasil/providers.py +++ b/jasil/providers.py @@ -21,6 +21,11 @@ #: (code points, not bytes). MAX_REALTIME_KEY_LENGTH = MAX_KEY_LENGTH +#: Upper bound on one :meth:`MailProvider.send` through the built-in SMTP backend, in seconds. DNS lookups are not +#: counted against it, so a host sizing a lease or timeout around a send adds a margin. Import it rather than copy +#: the value, which may change in a minor release. +MAIL_SEND_DEADLINE_SECONDS = 90 + class StateBackendUnavailableError(RuntimeError): """Raised by a :class:`StateProvider` when its backing store is unreachable. diff --git a/tests/test_container.py b/tests/test_container.py index 82b841d..95ee349 100644 --- a/tests/test_container.py +++ b/tests/test_container.py @@ -32,6 +32,7 @@ from jasil.backends.storage_none import NullStorage from jasil.backends.storage_s3 import S3Storage from jasil.profile import DeploymentProfile, JobWorkerMode +from jasil.providers import MAIL_SEND_DEADLINE_SECONDS #: Taken before the autouse fixture replaces it, for the tests of client ownership. REAL_GET_SHARED_CLIENT = redis_clients.get_shared_client @@ -737,7 +738,7 @@ def _build(**mail): def test_a_runtime_cap_not_above_the_smtp_deadline_warns(self, caplog): """A running job keeps its lease only up to ``max_runtime_seconds``.""" - jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=60) + jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=MAIL_SEND_DEADLINE_SECONDS) cfg = _settings( mail=settings.MailSettings(uri="smtps://mail.example.com", from_address="a@example.com"), jobs=jobs ) @@ -747,7 +748,7 @@ def test_a_runtime_cap_not_above_the_smtp_deadline_warns(self, caplog): assert "max_runtime_seconds" in caplog.text def test_a_longer_runtime_cap_does_not_warn(self, caplog): - jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=1800) + jobs = settings.JobSettings(enabled=True, lease_seconds=60, max_runtime_seconds=MAIL_SEND_DEADLINE_SECONDS + 1) cfg = _settings( mail=settings.MailSettings(uri="smtps://mail.example.com", from_address="a@example.com"), jobs=jobs ) diff --git a/tests/test_mail.py b/tests/test_mail.py index 685cbe4..4a15558 100644 --- a/tests/test_mail.py +++ b/tests/test_mail.py @@ -25,7 +25,7 @@ from jasil._core.mail_message import build_mime from jasil.backends.mail_memory import MemoryMail, NullMail from jasil.backends.mail_smtp import SmtpMail -from jasil.providers import MailError, MailMessage, MailNotConfiguredError, MailProvider +from jasil.providers import MAIL_SEND_DEADLINE_SECONDS, MailError, MailMessage, MailNotConfiguredError, MailProvider # Windows: shutdown() does not wake a thread blocked in recv(), so the watchdog cannot interrupt a read there; only the per-read timeout bounds one. CANNOT_INTERRUPT_A_READ = pytest.mark.skipif( @@ -534,7 +534,7 @@ def test_cleartext_does_not_dial_another_address_after_the_deadline(self, monkey def connect(self, host, port): tried.append(host) - clock[0] += mail_smtp.SEND_DEADLINE_SECONDS + 1 # a failure that took longer than the whole send may + clock[0] += MAIL_SEND_DEADLINE_SECONDS + 1 # a failure that took longer than the whole send may raise OSError("slow failure") monkeypatch.setattr(FakeSMTP, "connect", connect) @@ -815,7 +815,7 @@ def _start(connection): def test_it_releases_a_read_through_the_duplicate_it_watches(self, monkeypatch, blocked_read): """``watched`` is a duplicate of the raw socket; shutting it down reaches a TLS-wrapped read too.""" sock, released = blocked_read - monkeypatch.setattr(mail_smtp, "SEND_DEADLINE_SECONDS", 0) + monkeypatch.setattr(mail_smtp, "MAIL_SEND_DEADLINE_SECONDS", 0) connection = SimpleNamespace(watched=sock.dup(), sock=None) expired, finished, watchdog = self._start(connection) try: @@ -830,7 +830,7 @@ def test_it_releases_a_read_through_the_duplicate_it_watches(self, monkeypatch, def test_a_socket_that_appears_after_the_deadline_is_shut_down_too(self, monkeypatch, blocked_read): """A slow connect hands the socket over only after the deadline; one shutdown attempt would miss it.""" sock, released = blocked_read - monkeypatch.setattr(mail_smtp, "SEND_DEADLINE_SECONDS", 0) + monkeypatch.setattr(mail_smtp, "MAIL_SEND_DEADLINE_SECONDS", 0) connection = SimpleNamespace(watched=None, sock=None) expired, finished, watchdog = self._start(connection) try: