<feed xmlns='http://www.w3.org/2005/Atom'>
<title>meshbay.git/packages/meshbay-hub/src/meshbay_hub/mail.py, branch 0.17</title>
<subtitle>MeshBay — read-only public mirror</subtitle>
<id>https://git.meshbay.org/meshbay.git/atom?h=0.17</id>
<link rel='self' href='https://git.meshbay.org/meshbay.git/atom?h=0.17'/>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/'/>
<updated>2026-09-30T09:49:56Z</updated>
<entry>
<title>fix: only the owner decides who hosts a group, and nobody is made a member unasked</title>
<updated>2026-09-30T09:49:56Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-30T09:49:56Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4'/>
<id>urn:sha1:d3ad243c4ae3a273f623bd5fc631e3266aa4d0e4</id>
<content type='text'>
- hub: a node may host a group only if its account owns it or the owner
  approved that node (new `group_hosts`). Membership was the ceiling, and
  every member holds the group key, so any member's node could register as a
  host and be the one clients kept. A node claiming a group it may not host
  is recorded as a request; the owner is notified once and approves or
  refuses it (GET/POST/DELETE /v1/groups/{id}/hosts[/{node_id}]), which
  takes effect on a connected node at once.
- hub: an owner adding a username creates an invitation (new
  `group_invitations`), accepted or declined by the invitee
  (/v1/groups/invitations, /{id}/invitation/accept|decline). Until then the
  group is not listed, not dialled, not searched and not in any token.
  Invitation links, open joins and group creation still make members
  directly: they are the account's own act.
- hub: the MNP token names only the group it is minted for (group_id is now
  required), so a node operator no longer learns a member's other groups.
- SPA: invitations on the home page; invited people and host requests in
  the group's settings; the transport sends group_id. Ten catalogues.
- Browser probes for both screens, run in Chrome and Firefox.
- Design §5.2, §7.2, §7.3, AV32, AV33; protocol §6.3; user guide.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<entry>
<title>feat: invitation links no longer bound to an e-mail address</title>
<updated>2026-09-25T15:10:15Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-25T15:10:15Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=90c69477d5f701158112b3c294eff26312f89da6'/>
<id>urn:sha1:90c69477d5f701158112b3c294eff26312f89da6</id>
<content type='text'>
A link is redeemable by whoever opens it first, so it can be sent by any
messaging app. The address is optional (mail + label only); a link lives
7 days, fixed. Adds a Share button; see MESHBAY_DESIGN.md §3.4.

Co-Authored-By: Claude Opus 5.5 &lt;noreply@anthropic.com&gt;
</content>
</entry>
<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>feat(hub): invitation-link tickets bound to a verified address</title>
<updated>2026-09-23T15:46:48Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-23T15:46:48Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=998f9c69308ee88fac36cfb77dfb6d07c6fa926a'/>
<id>urn:sha1:998f9c69308ee88fac36cfb77dfb6d07c6fa926a</id>
<content type='text'>
group_invite_links holds sha256(ticket) and the invitee's address blind
index; redeeming grants membership to that account only. Owner-only
create/list/cancel (a node token may create, never mail), 20 outstanding
per group, optional mail written by the hub itself and capped at 10 per
sender per day (mail.invite_link_daily_cap). MESHBAY_DESIGN.md §3.4 now
carries the whole link design.

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>docs: cite MESHBAY_DESIGN.md and a section instead of the merged notes</title>
<updated>2026-09-19T00:12:47Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-19T00:12:47Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=20a824118c09af15d6c338db4c9480ffe5cbcdb6'/>
<id>urn:sha1:20a824118c09af15d6c338db4c9480ffe5cbcdb6</id>
<content type='text'>
The per-feature design notes were merged into docs/MESHBAY_DESIGN.md and
deleted from the tree on 2026-09-11, but ~230 comments across the three
packages still named them — usually written `docs/musicbay.md §3.2`, as
though the file were still in docs/. A reader had to know §16 existed to
resolve any of them. They now name the section directly.

Every mapping comes from §16, the concordance, which already records where
each old section landed: musicbay -&gt; §9.8, mediacenter -&gt; §9.7 for the
Videos app and §6.5 where the subject is derived data, photos -&gt; §9.9,
auth-confirm -&gt; §3.6, refactoring-search -&gt; §9.11, invite-pairing-v1 -&gt;
§3.4, per-node-identity-v1 -&gt; §3.2, captcha -&gt; §7.7, chat-sender-keys -&gt;
§4.5, apps/refactor-groups -&gt; §9.1–§9.4, desktop-client-v1 per section.
Bare citations of the same documents (`draft-v6 §2.11`, `§4.8`, `§3.4`)
are retargeted too: those collide with real section numbers in the design
document, so leaving them would have been worse than the named form.

Four cases the concordance does not cover, each decided rather than guessed:

  Sub-item references into documents that no longer exist — mediacenter's
  `§3.3 row 4`, `§3.4b/c`, `apps.md §3b` — name rows and sub-items §9.7 and
  §9.2 do not reproduce. The module-level citation stays; the sub-item
  pointer is dropped.

  The V-findings keep their labels but lose the dead `§10.1/` prefix.
  §13.8 lists V1–V13 as per-application open items, which is not what the
  labels mean in these comments, so pointing them at §13.8 would have been
  a false citation.

  `apps.md §5`'s virtualization requirement has no counterpart anywhere in
  the design document. The requirement is stated in the comment itself, so
  the citation is dropped rather than aimed at a section that does not say
  it.

  Comments that attributed a *sentence* to an old note — musicbay's "several
  thousand files" example, its "what got measured" note, its measured
  ~11%/~26% cover-art figures, the "original no root, whole shared tree"
  call — state the fact without attribution now. §9.8 does not contain those
  sentences and citing it for them would have been wrong.

CLAUDE.md's "a reference to a document that no longer exists" row now says
the concordance is for git history and out-of-tree material; the code cites
sections directly.

Verified: 2851 passed, 4 skipped. The 12 errors in the run are the Firefox
leg of test_sticky_header.py's browser harness, which is broken at the
browser level on this machine — headless Firefox (snap) dies with
`[GFX1-]: RenderCompositorSWGL failed mapping default framebuffer`, renders
nothing, and the probe exits `{"error": "no measurement"}` after its full
90s wait. Chrome runs the same 12 assertions in 3.2s and passes. Nothing
here can affect it: every changed line in style.css is inside a comment.
Also checked: ast.parse on every changed .py, `node --check` on every
changed .js, the /* */ balance in style.css, and that no changed line
exceeds the width its file already used.

Co-Authored-By: Claude Opus 5 &lt;noreply@anthropic.com&gt;
</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>
<entry>
<title>fix(hub): no mail from the event loop, and a ceiling on every path that sends it</title>
<updated>2026-09-12T14:36:54Z</updated>
<author>
<name>Christophe Besson</name>
<email>cbesson@gmail.com</email>
</author>
<published>2026-09-12T08:08:36Z</published>
<link rel='alternate' type='text/html' href='https://git.meshbay.org/meshbay.git/commit/?id=02f061ee2c1824734bf63c91d39b47848926f59c'/>
<id>urn:sha1:02f061ee2c1824734bf63c91d39b47848926f59c</id>
<content type='text'>
`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 &lt;noreply@anthropic.com&gt;
Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
</content>
</entry>
</feed>
