""" MeshBay Hub — email sending via localhost Postfix. Postfix listens on loopback only (inet_interfaces = loopback-only), so no authentication is needed. See docs/MAIL-SERVER.md for the full setup. **No authentication is needed** is exactly why everything below exists. The hub can emit mail from its own domain to anywhere, and three API paths reach that ability — two of them at an address the caller types. Unbounded, that is an open relay wearing the instance's reputation, so the bounds are here, in the one function every send passes through, rather than at the call sites where the next one added would forget them (`AV9`–`AV10`, §13.5b). """ import asyncio import hashlib import logging import smtplib import time from email.message import EmailMessage log = logging.getLogger(__name__) class MailRefused(Exception): """The gate below declined to send. Never carries the address.""" # The complete list of reasons this hub will ever send mail. A `send_*` # function that names anything else does not send — and one that names nothing # is a TypeError, because `purpose` is keyword-required. # # registration confirm an address at sign-up # password_reset a code to the address already on file for that account # invite tell a registered member they were invited to a group # email_change confirm a new address before it replaces the old one # # Only `registration` and `email_change` can reach an address this hub has no # prior relationship with. Both are necessary; both are why the per-destination # bound below is keyed on the recipient rather than on who asked. ALLOWED_PURPOSES = frozenset({ "registration", "password_reset", "invite", "email_change"}) # Per recipient, across every purpose, every account and every IP. This is the # bound that matters: it is what a person being mail-bombed actually # experiences, and no combination of accounts, addresses or endpoints moves it. DESTINATION_COOLDOWN_SECONDS = 120 DESTINATION_DAILY_CAP = 5 # Instance-wide ceiling. Registration is open, so "per account" is a bound an # attacker buys more of; this one cannot be bought. Sized far above a real # hub's traffic — meshbay.org sends single-digit mails a day — and low enough # that being used as a relay is not worth the trouble. HOURLY_BUDGET = 200 # Single-process state, like `_connected_nodes` and `_node_groups` next door: # the hub serves on one worker (`server.workers` defaults to 1) and the # signaling registries already require it. A restart clears these, which costs # at most one burst and is not a security decision — unlike the denylist, # which persists for exactly that reason (S3). _destinations: dict[str, tuple[float, int, float]] = {} # key → (last, day count, day start) _hour: tuple[float, int] = (0.0, 0) # (window start, count) def _destination_key(address: str) -> str: """A stable handle for one recipient that is not the address itself. Hashed because this dict is the one place in the hub that would otherwise hold a list of plaintext addresses in memory — the rest of the codebase goes to the trouble of encrypting them at rest (S2). """ return hashlib.sha256(address.strip().lower().encode()).hexdigest()[:32] def _budget_or_refuse(purpose: str, address: str) -> None: """Raise MailRefused unless this hub may send this, to this person, now.""" global _hour if purpose not in ALLOWED_PURPOSES: raise MailRefused(f"not a purpose this hub sends mail for: {purpose!r}") now = time.monotonic() start, count = _hour if now - start >= 3600: start, count = now, 0 if count >= HOURLY_BUDGET: _hour = (start, count) raise MailRefused("the hub's hourly mail budget is spent") key = _destination_key(address) last, sent_today, day_start = _destinations.get(key, (0.0, 0, now)) if now - day_start >= 86400: sent_today, day_start = 0, now if last and now - last < DESTINATION_COOLDOWN_SECONDS: raise MailRefused("too soon since the last mail to this recipient") if sent_today >= DESTINATION_DAILY_CAP: raise MailRefused("this recipient has had its daily allowance") if len(_destinations) > 10_000: for k, (_, _, started) in list(_destinations.items()): if now - started >= 86400: _destinations.pop(k, None) _destinations[key] = (now, sent_today + 1, day_start) _hour = (start, count + 1) _hub_domain: str = "meshbay.org" _hub_url: str = "https://meshbay.org" def configure(hub_id: str) -> None: global _hub_domain, _hub_url _hub_domain = hub_id _hub_url = f"https://{hub_id}" def _send(msg: EmailMessage, *, purpose: str) -> bool: """Blocking. Every caller in an async handler must use `send_off_loop`. The gate is here rather than in `send_off_loop` so that it cannot be stepped around: a new `send_*` helper, a script, a test — everything that puts a message on the wire comes through this function, and `purpose` is keyword-required so forgetting it is a TypeError rather than an unrestricted send. """ try: _budget_or_refuse(purpose, msg["To"] or "") except MailRefused as refusal: # Never the address: this line goes to the journal. log.warning("Mail refused (%s): %s", purpose, refusal) return False try: with smtplib.SMTP("localhost", 25, timeout=10) as s: s.send_message(msg) return True except Exception: log.exception("Failed to send email to %s", _mask_email(msg["To"] or "")) 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 appended to the body so the recipient's mailbox becomes the backup for it (docs/auth-confirm.md §4.4). `recovery_key` is a **pass-through**: it is generated on the client, never stored anywhere on the hub, and never logged — only whether one was present. """ body = ( f"Your verification code is: {code}\n" "\n" "Enter this code to verify your email address.\n" "This code expires in 24 hours.\n" ) if recovery_key: body += ( "\n" "---- Account recovery key ----\n" "\n" "Keep this message. If you ever forget your passphrase, this key is\n" "what restores your access to your groups. It is not stored on the\n" f"server and nobody at {_hub_domain} can recover it for you.\n" "\n" f" {recovery_key}\n" ) body += ( "\n" "If you did not create a MeshBay account, ignore this email.\n" "\n" f"{_hub_url}\n" ) msg = EmailMessage() msg["From"] = f"noreply@{_hub_domain}" msg["To"] = to msg["Subject"] = f"MeshBay — Your verification code: {code}" msg.set_content(body) _send(msg, purpose="registration") log.info("Verification code sent to %s (recovery_key=%s)", _mask_email(to), bool(recovery_key)) def send_email_change_code(to: str, code: str) -> None: msg = EmailMessage() msg["From"] = f"noreply@{_hub_domain}" msg["To"] = to msg["Subject"] = f"MeshBay — Confirm your new email: {code}" msg.set_content( f"Your verification code is: {code}\n" "\n" "Enter this code to confirm your new email address.\n" "This code expires in 24 hours.\n" "\n" "If you did not request this change, ignore this email.\n" "\n" f"{_hub_url}\n" ) _send(msg, purpose="email_change") log.info("Email change code sent to %s", _mask_email(to)) def send_password_reset_code(to: str, code: str) -> None: """ Passphrase-reset code (docs/auth-confirm.md §4.2). This only re-opens hub login; it recovers no group content — that needs the recovery key. """ msg = EmailMessage() msg["From"] = f"noreply@{_hub_domain}" msg["To"] = to msg["Subject"] = f"MeshBay — Passphrase reset code: {code}" msg.set_content( f"Your passphrase reset code is: {code}\n" "\n" "Enter it to set a new passphrase. This code expires in 1 hour.\n" "\n" "This restores your sign-in only. If you also have your recovery key,\n" "you can restore access to your groups in the same step.\n" "\n" "If you did not request this, ignore this email — your account is\n" "unchanged.\n" "\n" f"{_hub_url}\n" ) _send(msg, purpose="password_reset") log.info("Passphrase reset code sent to %s", _mask_email(to)) def send_invite_notification( to: str, code: str, inviter: str, group_name: str, ) -> None: msg = EmailMessage() msg["From"] = f"noreply@{_hub_domain}" msg["To"] = to msg["Subject"] = f"MeshBay — {inviter} invited you to {group_name}" msg.set_content( f"{inviter} invited you to the group \"{group_name}\" on MeshBay.\n" "\n" f"Your one-time code is: {code}\n" "\n" "Open the group and enter this code when prompted.\n" "The code works once and expires in 7 days.\n" "\n" f"{_hub_url}\n" ) _send(msg, purpose="invite") log.info("Invite notification sent to %s", _mask_email(to)) def _mask_email(email: str) -> str: local, _, domain = email.partition("@") if len(local) <= 2: return f"{'*' * len(local)}@{domain}" return f"{local[0]}{'*' * (len(local) - 2)}{local[-1]}@{domain}"