Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ What counts as a breaking change — and what does not — is defined in [API st

- **`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))

- **`MailError` says why a send failed and whether it may have been delivered.** `kind` is a new `jasil.providers.MailFailure` naming the failed step (`auth_failed`, `sender_refused`, `recipient_refused`, `message_rejected`, `unreachable`, `connection_lost`, `deadline`, ...). `delivery_unknown` is `True` only when the connection was lost, or the deadline fired, after the message body started going out. `message_id` is set on every failure after the message was built. All three are keyword-only with defaults, so existing code is unaffected. See [MailProvider](docs/providers-and-backends.md#mailprovider). ([#52](https://github.com/endurain-project/jasil/issues/52), [#53](https://github.com/endurain-project/jasil/issues/53))

### Changed

- `SmtpMail` sends `MAIL FROM`, `RCPT TO` and `DATA` itself instead of through `smtplib`'s `send_message`, so it knows which step failed. The conversation on the wire is unchanged, including the `SIZE` declaration and success with a warning when only some recipients are refused. A refused `MAIL FROM` is now reported as a sender refusal, no longer as a rejected message.

## [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.
Expand Down
7 changes: 5 additions & 2 deletions docs/api-stability.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ modules:

### Provider protocols

`MailProvider.send` returns the `Message-ID` as a `str`, and `MailError` /
`MailNotConfiguredError` are covered. Adding a field to `MailMessage` with a
`MailProvider.send` returns the `Message-ID` as a `str`, and `MailError` (with
`status`, `permanent`, `kind`, `delivery_unknown` and `message_id`) /
`MailNotConfiguredError` are covered. `MailFailure` is covered like
`MailProblemCode`: members may be added in a minor release, and a member is never
renamed or reused. Which `kind` a given failure maps to is documented, not frozen. Adding a field to `MailMessage` with a
default is additive.

`RealtimeProvider`'s four methods and its contract are covered: best-effort,
Expand Down
5 changes: 4 additions & 1 deletion docs/durable-jobs.md
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,10 @@ failure.
`MailError`, so a job on a deployment with no `mail.uri` uses its whole retry
budget before dead-lettering. Catch it in the handler if that is not wanted.
- **Delivery is at-least-once.** A retry after a lost acknowledgement sends the
message twice.
message twice. A `MailError` with `delivery_unknown` set lost its connection
after the body went out, so the message may already have been delivered; a
handler that must not send twice catches it, records `message_id`, and does
not retry.
- **Size `jobs.max_runtime_seconds` for the whole handler, not one send.** One
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
Expand Down
27 changes: 27 additions & 0 deletions docs/providers-and-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,33 @@ 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.

A `MailError` also tells a host what to do next without parsing its message:

| Attribute | Meaning |
|---|---|
| `status` / `permanent` | The SMTP status code, and whether it is a 5xx. |
| `kind` | A `MailFailure` naming the step that failed (table below). |
| `delivery_unknown` | `True` when the connection was lost, or the send deadline fired, after the body started going out. The server may have accepted the message, so a retry may deliver it twice; reconcile with the relay's logs instead. `False` means the message was not accepted. |
| `message_id` | The `Message-ID` of the message that failed, set on every failure after the message was built. Look it up in the relay's or provider's logs. It is never part of `str(error)`. |

| `MailFailure` | Raised when | Typical reaction |
|---|---|---|
| `auth_failed` | The server refused the credentials. | Fix the configuration; alert an operator. |
| `unsupported` | The server lacks STARTTLS, AUTH, or an AUTH mechanism `smtplib` implements. | Fix the configuration. |
| `sender_refused` | `MAIL FROM` was refused. | When `permanent`, stop using that sender; a 4xx is usually rate limiting. |
| `recipient_refused` | Every recipient was refused, or the server closed the session (421) during `RCPT`. | When `permanent`, record it against the recipient. |
| `message_rejected` | The server refused the message at `DATA` or at its end (content, size, policy). | When `permanent`, do not retry the same content. |
| `unreachable` | DNS, a refused or failed connect, a greeting other than 220, or a cleartext relay that no longer resolves to a private address. | Retry later. |
| `tls_failed` | The TLS handshake failed. | Check the relay's certificate and name. |
| `connection_lost` | The connection broke after the session was established. | Retry, unless `delivery_unknown`. |
| `deadline` | The send ran past `MAIL_SEND_DEADLINE_SECONDS`. | Retry, unless `delivery_unknown`. |
| `lease_lost` | The durable job lost its lease; nothing was sent. | None: another worker owns the job. |
| `settings_unavailable` | Runtime mail settings could not be loaded or verified. | Retry later. |
| `not_configured` | No backend is configured (`MailNotConfiguredError`). | Configure `mail.uri`. |
| `other` | Anything else, and a `MailError` raised without a kind. | Treat by `status`. |

Members may be added in a minor release; a member is never renamed or reused.

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.
Expand Down
2 changes: 2 additions & 0 deletions jasil/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
EventBusProvider,
LockProvider,
MailError,
MailFailure,
MailMessage,
MailNotConfiguredError,
MailProvider,
Expand Down Expand Up @@ -70,6 +71,7 @@
"JobWorkerMode",
"LockProvider",
"MailError",
"MailFailure",
"MailMessage",
"MailNotConfiguredError",
"MailProvider",
Expand Down
5 changes: 3 additions & 2 deletions jasil/_reloading.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
GeocodedPlace,
GeocodingProvider,
MailError,
MailFailure,
MailMessage,
MailNotConfiguredError,
MailProvider,
Expand Down Expand Up @@ -181,9 +182,9 @@ def send(self, message: MailMessage) -> str:
outcome = self._backend.resolve()
except SourceUnavailableError:
# Not chained: the source's exception is the host's, and its text may carry a secret.
raise MailError("Mail settings could not be loaded") from None
raise MailError("Mail settings could not be loaded", kind=MailFailure.SETTINGS_UNAVAILABLE) from None
if outcome.transient:
raise MailError("Mail settings could not be verified; retry")
raise MailError("Mail settings could not be verified; retry", kind=MailFailure.SETTINGS_UNAVAILABLE)
if outcome.backend is None:
# The reason carries no setting's value (see ``ReloadingBackend``).
raise MailNotConfiguredError(f"Mail settings are invalid: {outcome.rejection}")
Expand Down
4 changes: 2 additions & 2 deletions jasil/backends/mail_memory.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from jasil._core.job_context import lease_lost
from jasil._core.mail_message import build_mime
from jasil.providers import ClockProvider, MailError, MailMessage, MailNotConfiguredError
from jasil.providers import ClockProvider, MailError, MailFailure, MailMessage, MailNotConfiguredError

# Used only when ``memory://`` is configured without a ``from_address``.
_DEFAULT_SENDER = "jasil@localhost"
Expand Down Expand Up @@ -67,7 +67,7 @@ def send(self, message: MailMessage) -> str:
:class:`~jasil.backends.mail_smtp.SmtpMail` would send nothing.
"""
if lease_lost():
raise MailError("Job lease lost; send abandoned")
raise MailError("Job lease lost; send abandoned", kind=MailFailure.LEASE_LOST)
mime = build_mime(message, sender=self._from_address, now=self._clock.now())
# Recipients copied as sent: the caller's list may be reused or mutated
# after ``send`` returns, which would rewrite what the test asserts on. A
Expand Down
Loading
Loading