1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
|
"""
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
from datetime import datetime, timedelta, timezone
from email.message import EmailMessage
log = logging.getLogger(__name__)
_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}"
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 bound that
# matters is keyed on the recipient rather than on who asked.
ALLOWED_PURPOSES = frozenset({
"registration", "password_reset", "invite", "email_change"})
# The two a person is actively waiting on. They may spend the whole hourly
# budget; the other two may not spend the reserved share of it, so a flood of
# sign-ups cannot lock out someone trying to recover their passphrase.
RECOVERY_PURPOSES = frozenset({"password_reset", "invite"})
def destination_key(address: str) -> str:
"""A stable handle for one recipient that is not the address itself.
Hashed because this table would otherwise be the one place in the hub
holding a list of plaintext addresses — the rest of the codebase goes to
the trouble of encrypting them at rest (S2).
"""
return "dest:" + hashlib.sha256(
address.strip().lower().encode()).hexdigest()[:32]
async def _take(db, key: str, window: timedelta, ceiling: int,
cooldown: timedelta | None) -> None:
"""Charge one send against a counter, or raise MailRefused.
Rows live in `mail_quota` rather than in a module dict, so the allowance
survives the restart that a deploy is. The caller owns the commit — which
means a send that then fails to commit is not charged, and that is the
right way round: the alternative charges for mail nobody received.
"""
from meshbay_hub.db.models import MailQuota
now = datetime.now(timezone.utc)
row = await db.get(MailQuota, key)
if row is None:
row = MailQuota(key=key, window_start=now, count=0, last_sent=None)
db.add(row)
started = row.window_start
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
if now - started >= window:
row.window_start, row.count = now, 0
if cooldown is not None and row.last_sent is not None:
last = row.last_sent
if last.tzinfo is None:
last = last.replace(tzinfo=timezone.utc)
if now - last < cooldown:
raise MailRefused("too soon since the last message to this recipient")
if row.count >= ceiling:
raise MailRefused("allowance spent for this window")
row.count += 1
row.last_sent = now
async def reserve(db, purpose: str, address: str) -> None:
"""Charge one send, or raise MailRefused. The caller owns the commit."""
from meshbay_hub import hub_settings
if purpose not in ALLOWED_PURPOSES:
raise MailRefused(f"not a purpose this hub sends mail for: {purpose!r}")
limits = await hub_settings.mail_limits(db)
# The instance ceiling first, and the recovery share is subtracted for the
# purposes that are not one. Registration is open, so a limit counted per
# account or per IP is one an attacker buys more of; this one is not.
budget = limits["hourly_budget"]
if purpose not in RECOVERY_PURPOSES:
budget = max(0, budget - limits["hourly_reserved_for_recovery"])
await _take(db, "hour", timedelta(hours=1), budget, None)
# Then the recipient: across every purpose, account and endpoint. This is
# what a person being mail-bombed actually experiences, and the only bound
# that describes it.
await _take(
db, destination_key(address), timedelta(days=1),
limits["destination_daily_cap"],
timedelta(seconds=limits["destination_cooldown_seconds"]))
async def status(db) -> dict:
"""What the operator sees in the panel: is the hub still sending?"""
from meshbay_hub import hub_settings
from meshbay_hub.db.models import MailQuota
from sqlalchemy import func, select
limits = await hub_settings.mail_limits(db)
row = await db.get(MailQuota, "hour")
used = 0
window_start = None
if row is not None:
started = row.window_start
if started.tzinfo is None:
started = started.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) - started < timedelta(hours=1):
used, window_start = row.count, started.isoformat()
recipients = await db.scalar(
select(func.count()).select_from(MailQuota)
.where(MailQuota.key.like("dest:%"))) or 0
general = max(0, limits["hourly_budget"] - limits["hourly_reserved_for_recovery"])
return {
"hourly_budget": limits["hourly_budget"],
"hourly_used": used,
"hour_started_at": window_start,
"reserved_for_recovery": limits["hourly_reserved_for_recovery"],
# What a sign-up would meet right now, which is the number an operator
# is actually asking about when they open this panel.
"general_remaining": max(0, general - used),
"recovery_remaining": max(0, limits["hourly_budget"] - used),
"recipients_tracked": recipients,
}
async def purge_expired_quota(db) -> int:
"""Drop counters whose window has passed. Returns how many went."""
from meshbay_hub.db.models import MailQuota
from sqlalchemy import delete
cutoff = datetime.now(timezone.utc) - timedelta(days=1)
result = await db.execute(
delete(MailQuota).where(MailQuota.window_start < cutoff))
await db.commit()
return result.rowcount or 0
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.
"""
if purpose not in ALLOWED_PURPOSES:
# The structural half of the gate, and the half that needs no state:
# it is here rather than only in `reserve` so that nothing which puts a
# message on the wire — a new helper, a script, a test — can name a
# reason this hub does not send mail for. The counting half is
# `reserve`, which needs a database session and so cannot live here.
log.warning("Mail refused: %r is not a purpose this hub sends for",
purpose)
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(db, fn, *args, purpose: str, **kwargs) -> bool:
"""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.
It is also the one door: `reserve` charges the send against the recipient's
allowance and the instance's before anything is handed to the thread, and
the recipient is `args[0]` because that is the first parameter of every
`send_*` function below. Returns whether the message went. The caller owns
the commit, so a request that fails afterwards is not charged for mail
nobody received.
"""
try:
await reserve(db, purpose, args[0])
except MailRefused as refusal:
# Never the address: this line goes to the journal.
log.warning("Mail refused (%s): %s", purpose, refusal)
return False
await asyncio.to_thread(fn, *args, **kwargs)
return True
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}"
|