<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/tests/test_mail_is_not_a_relay.py, branch main</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=main</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-23T17:01:37Z</updated>
<entry>
<title>fix(hub): an invitation no longer holds back its invitee's sign-up code</title>
<updated>2026-09-23T17:01:37Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-23T17:01:37Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=3a27a5cd3ae1e752b17d27844cee3d18a35d4498'/>
<id>urn:sha1:3a27a5cd3ae1e752b17d27844cee3d18a35d4498</id>
<content type='text'>
The per-recipient cooldown was shared by every purpose, so the code a
person asked for by registering within two minutes of an invitation
link was refused, silently. The cooldown is now per family (invitations
vs the account's own steps); the daily cap still counts everything.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>style: ruff's own fixes, mechanically applied</title>
<updated>2026-09-19T12:24:13Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-19T12:24:13Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=86188385cbdae1ee90c1dca7a7b9db2edef1ecd4'/>
<id>urn:sha1:86188385cbdae1ee90c1dca7a7b9db2edef1ecd4</id>
<content type='text'>
`ruff check .` had gone unrun long enough to report 568 errors, which is the
same as having no linter: the next real finding would have been invisible in the
noise. This is the 521 it fixes by itself, in 173 files, and nothing else — the
98 it cannot fix are the next commit.

What actually changed: import sorting (225), imports nobody used (87, none of
them a re-export — no `__init__.py` is touched, which was the one way this could
have broken an import elsewhere), `datetime.timezone.utc` to `datetime.UTC` (69)
and `asyncio.TimeoutError` to `TimeoutError` (18), both plain aliases on the 3.12
this project requires, `Optional[X]` to `X | None` (24), and f-strings with
nothing to interpolate (19).

Checked rather than assumed: every module in the three packages still imports,
and the suite is 2893 passed — the same count, test for test, as the merge
before it.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat(hub): usernames are at least 8 characters at registration</title>
<updated>2026-09-15T00:21:01Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-15T00:16:39Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=bdefcd025604f2c3009fe5e0cc01213c2ba62a6a'/>
<id>urn:sha1:bdefcd025604f2c3009fe5e0cc01213c2ba62a6a</id>
<content type='text'>
Existing shorter accounts keep signing in. Test usernames padded to match.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01XuNrwLf5EFWCMHzfoEvnpm
</content>
</entry>
<entry>
<title>feat(hub): the mail state is a panel section, and a ceiling falling is an event</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T12:21:12Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=2114a54eb6335f97b0c276c4f1f224d45f46fd1a'/>
<id>urn:sha1:2114a54eb6335f97b0c276c4f1f224d45f46fd1a</id>
<content type='text'>
The figure was a line beside the settings form, which is where it is changed
and not where it is watched. It sits with the other live figures under
Statistics now — four cards and, above them, a banner saying which of the two
ceilings has fallen. The two states are not the same to whoever is reading:
one means newcomers are turned away, the other means somebody locked out of
their account cannot get back in. The settings block keeps a line pointing at
it.

And an operator no longer has to be looking. When a global ceiling is reached
the administrators are notified — in `mail.py`, in its own session, never
raising, because this runs while a request is being refused and an alert that
fails must not turn a refusal into a 500. Once per hour, keyed on a row
rather than a flag in memory: a flood is what spends the budget, so one alert
per refusal would bury the message under its own cause, and a hub that is
refusing mail is a hub somebody is about to restart.

`/v1/admin/mail` gains `general_exhausted` and `all_exhausted` rather than
leaving the panel to compare two numbers.

Labels in all ten catalogues; `.warn-msg` for the middle state, on the
`--warn` token both themes already define.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>fix(hub): the mail allowance is written down, and recovery keeps a share</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T11:47:49Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=e671b931fd594a39fc840916c81b5d4b1f1e3227'/>
<id>urn:sha1:e671b931fd594a39fc840916c81b5d4b1f1e3227</id>
<content type='text'>
Two dicts in `mail.py` held the budget, so every deploy handed out a fresh
one — and this hub is deployed several times a day. A bound a restart forgets
is not a bound, for the reason the denylist is persisted rather than held in
memory (S3). It is a `mail_quota` table now, one row per counter, the
recipient hashed so the table does not become a list of plaintext addresses.

The counting moves with it, into an async `reserve` that has a session, and
`send_off_loop` is the one door it stands in. `_send` keeps the purpose
allow-list: that half needs no state, and it is what stops anything which
puts a message on the wire from naming a reason this hub does not send for.
The caller owns the commit, so a request that fails afterwards is not charged
for mail nobody received.

`hourly_reserved_for_recovery` is new. A flood of sign-ups used to be able to
spend the whole hour and lock out the person waiting on a passphrase reset;
registration and address changes may now spend only the unreserved share.

Values changed as agreed: 10 messages a day to one recipient, 300 s between
two reset codes. The address-change ceiling and its cooldown were two bounds
on one thing — 3 a day and 60 s apart — and collapse into one 48-hour delay.
Asking again for the address already pending is exempt: it reaches no new
recipient, that recipient is bounded anyway, and without the exemption a typo
locked the account out of correcting it for two days.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
<entry>
<title>fix(hub): the mail server is not a relay</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T10:14:15Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=6260825bf6d8340549de53905de3bd0b84d97d0a'/>
<id>urn:sha1:6260825bf6d8340549de53905de3bd0b84d97d0a</id>
<content type='text'>
The previous commit metered the paths that send mail. It was not enough, and
saying it was would have been wrong: a 60-second cooldown per account still
allows one stranger a minute — 1440 a day — and registration is open, so
"per account" is a bound an attacker buys more of. And there was a third door
nobody had counted.

  POST  /v1/users/register            an address nobody has verified
  PATCH /v1/users/me                  an address nobody has verified, signed in
  POST  /v1/users/password/reset      only the address already on file
  POST  /v1/groups/{id}/invite-notify only a registered member's address

The widest was the register *resend* branch: no token, no captcha, and the
username and address are the caller's own from a moment ago — registering a
victim's address once bought the right to mail them at the endpoint's rate
limit for as long as the account stayed pending.

So the bound moves into `mail.py`, where every message passes one function.
`purpose` is keyword-required and checked against a closed list, so a helper
that names anything else does not send and one that names nothing is a
TypeError rather than an unrestricted send. Under it:

  - a bound per **recipient**, across every purpose, account and endpoint —
    what a person being mail-bombed actually experiences, and the only bound
    that describes it. Keyed on a hash, because this would otherwise be the
    one place in the hub holding plaintext addresses in memory (S2)
  - an instance-wide hourly ceiling, which cannot be bought with more accounts
  - a cooldown on the resend branch, a cooldown and a daily ceiling on the
    address change, and the IP-log entry that endpoint never wrote — alone
    among the ones that mail

The ceiling on address changes counts IP-log rows, not EmailVerification: the
handler deletes this account's unverified rows before writing a new one, so
counting those counts one, always. Which is what the first version of it did.

Refusals never carry the address: that line goes to the journal.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
</feed>
