diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-12 10:08:36 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-12 16:36:54 +0200 |
| commit | 02f061ee2c1824734bf63c91d39b47848926f59c (patch) | |
| tree | 8c3261860876b73fa2eec82a4b648ded2873261b /packages/meshbay-hub/src/meshbay_hub/mail.py | |
| parent | 7c3a1d6fd765ef0421d0f3d85e512e10ea2f6f87 (diff) | |
| download | meshbay-02f061ee2c1824734bf63c91d39b47848926f59c.tar.gz | |
fix(hub): no mail from the event loop, and a ceiling on every path that sends it
`users.py` and `admin.py` under the availability lens. `admin.py` needed
nothing — its moderator/admin line is drawn explicitly, self-modification is
refused, and every list it serves is bounded. `users.py` had four findings and
one of them is the worst of this whole pass.
AV9 `mail._send` is `smtplib` with a ten-second timeout, called straight
from four async handlers. That wait is not one request's, it is the
instance's: nothing else served, no node socket read, no WebRTC offer
relayed, until the MTA answers. Reachable by any signed-in user at
request rate through the endpoint below. It has no symptom a test
catches — everything simply works slowly, for everyone, whenever the
mail server is having a bad day.
AV10 `PATCH /v1/users/me` is the third path that makes the hub send mail
and the only one with neither a rate limit nor a captcha, while
`register` and `password/reset-request` have both. The address is any
string the caller types and the duplicate check only rejects one
already held by an account here, so every address *not* registered on
this hub was a valid target: a relay for verification codes with the
hub's own reputation attached. A rate limit counting by IP bounds a
caller and not an inbox, so the floor under it is a cooldown per
account — the same for a reset request, whose cost also lands in a
mailbox that is not the asker's.
AV11 `default_tab:` accepted any suffix on a `{key:path}` route with an
unbounded Text value and no cap on rows: one account could write
without limit into a table shared with everyone. The suffix is a group
id, which is what the SPA writes, so it is checked as one. A key over
64 characters was also a 500 rather than a 400 — the column is
String(64), which PostgreSQL enforces and SQLite does not, so it would
have appeared in production and in no test.
AV12 `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and
no floor under `offset`, while every list in `admin.py` carries
`le=200`. The group directory takes no authentication at all.
Two shapes recur and are now named in §13.5b: a limit written on one of
several equivalent paths, and a bound that counts the wrong thing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/mail.py')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/mail.py | 18 |
1 files changed, 18 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/mail.py b/packages/meshbay-hub/src/meshbay_hub/mail.py index 4f776bf..8373204 100644 --- a/packages/meshbay-hub/src/meshbay_hub/mail.py +++ b/packages/meshbay-hub/src/meshbay_hub/mail.py @@ -5,6 +5,7 @@ Postfix listens on loopback only (inet_interfaces = loopback-only), so no authentication is needed. See docs/MAIL-SERVER.md for the full setup. """ +import asyncio import logging import smtplib from email.message import EmailMessage @@ -22,6 +23,7 @@ def configure(hub_id: str) -> None: def _send(msg: EmailMessage) -> bool: + """Blocking. Every caller in an async handler must use `send_off_loop`.""" try: with smtplib.SMTP("localhost", 25, timeout=10) as s: s.send_message(msg) @@ -31,6 +33,22 @@ def _send(msg: EmailMessage) -> bool: return False +async def send_off_loop(fn, *args, **kwargs) -> None: + """Run one of the `send_*` functions below in a worker thread. + + `smtplib` is synchronous and this one waits up to ten seconds. Called + directly from an async handler — which is what all four call sites did — + that ten seconds is not one request's, it is **the whole hub's**: no other + request is served, no node socket is read, no WebRTC offer is relayed, + for as long as the MTA takes to answer. An unreachable mail server made + the instance stop responding to everyone, and one of the three paths that + reaches it (`PATCH /v1/users/me`) had no rate limit at all. + + So the cost of a slow MTA is one request now, not the instance. + """ + await asyncio.to_thread(fn, *args, **kwargs) + + def send_verification_code(to: str, code: str, recovery_key: str | None = None) -> None: """ Registration verification e-mail. When `recovery_key` is given it is |