Skip to content

Send an invitation mail to an admitted address - #30

Merged
JeroenDeDauw merged 2 commits into
masterfrom
feature/invitation-emails
Sep 2, 2026
Merged

Send an invitation mail to an admitted address#30
JeroenDeDauw merged 2 commits into
masterfrom
feature/invitation-emails

Conversation

@JeroenDeDauw

@JeroenDeDauw JeroenDeDauw commented Sep 1, 2026

Copy link
Copy Markdown
Member

Adding an address to the allowlist grants access and sends nothing, while the admin panel's wording
around it reads as though a mail goes out. Adding an entry stays "grant access"; inviting becomes a
separate, re-sendable act. This is the extension half — the endpoint, the mail, and the record of
when one last went. The admin panel half is a separate ProAdmin pull request.

The endpoint

POST /member-access/v0/entries/{id}/invitation mails an invitation to the address of allowlist
entry {id} and records when. Each call sends another mail and replaces the recorded timestamp. It
requires the manage right and CSRF token like every other write, and nothing throttles it: the
right bounds who may send, not how often. The send is logged on the MemberAccess channel with the
address hashed and who asked for it.

  • 200 { "id": 7, "invited": "2026-09-01T16:40:00Z" }
  • 404 entry_not_found
  • 400 not_an_address, since a domain rule has no recipient
  • 409 code_login_off, since the mail says to log in with a code, which a wiki whose code route
    is off does not offer. Both allowlisted and open count as on, and an unrecognised setting
    reads as off like everywhere else
  • 500 invitation_not_sent, and nothing is recorded

The mail goes out inside the request rather than deferred: the endpoint handles one entry, and the
caller needs the outcome in the response.

The mail

A formatted part and a plain one carrying the same content, in the wiki's content language, from
the sender the login code uses. It names the wiki, the login page — its canonical URL, from
SpecialPage::getTitleFor( 'Userlogin' ) — the address to log in with, and that a code is mailed
rather than a password set, and closes with a line for someone who was not expecting it. Never a
username, never who invited them.

The code mail carries no link on purpose. An invitation is useless without one, so this mail links
the login page and gives the link the URL itself as its text, so nothing hides where it goes. The
address and the URL reach the messages as plaintext parameters, out of the preprocessor's reach,
and the template escapes what it interpolates; MediaWikiInvitationMailerTest pins both.

Recording it

A nullable mae_invited on memberaccess_entry, surfaced as invited wherever an entry is
serialised.

It ships twice, because addExtensionTable leaves an install that already has the table untouched:
the updated table definition reaches fresh installs, and an abstract schema change registered with
addExtensionField reaches the installs already running the extension, which would otherwise read
a column they do not have. CI runs update.php on every run after restoring its cached install, so
the patch applies there and the cache key needs no bump.

AGENTS.md said the schema had never shipped and to change a table in place. It has shipped, so
that section now describes both halves; the README's Development section carries the generator
commands.

An older leak, fixed first

The first commit fixes something older than this branch. The code mailer logged the rendered status
of a failed send beside the hash of the address, and a mail server refusing an address quotes it
back, so it has been able to write member addresses into the log since it shipped. It now logs the
status's message keys, as the invitation mailer does. The regression test is the existing one in
MediaWikiCodeMailerTest: it only bites now that SpyEmailer refuses the way a server does,
quoting the address. The same leak in MediaWikiMemberBlocker is
#31.

Also in the diff

README: an Invitations subsection, the API table row and error codes, the update.php and
schema-patch paragraphs, a feature bullet and a release-notes bullet. Two REST test helpers moved
onto RestApiTestCase, and ExtensionManifestTest now checks that every REST route names a
callable factory.

Verification

Beyond the suite: update.php on a database already holding the table added the column, and the
endpoint was driven against a dev wiki through its production wiring, with both mails arriving in
Mailcatcher and checked as rendered HTML and as plain text.

Considered, omitted

  • Naming the inviter's role ("an administrator of {{SITENAME}} added your address") as a trust cue
    in the mail. The strongest candidate here, but beyond what the recipient needs to log in.
  • Telling a recipient who does not want in how to get off the list. The extension offers no such
    route, so the mail can only say to ignore it.
  • Sharing reasonsFor() between the two mailers: four lines, copied.
  • Hoisting the empty-body parseBodyData override onto MemberAccessApiHandler, where it would
    replace three identical copies. It changes what the body-taking endpoints answer to an empty
    body, so it wants a commit of its own.
  • Giving MemberAccessApiHandler the allowlist-entry JSON shape, which ListEntriesApi and
    AddEntriesApi each spell out — this branch had to add invited to both. Worth doing; not here.
  • Reading the entry from the primary before inviting, for a replicated install where an entry added
    a moment ago is not on the replica yet. MediaWiki's ChronologyProtector already holds a client's
    next request until the replica has caught up with its last write.

AI-authored — Claude Code, Opus 5 (max) implementing under Fable 5 (max) orchestration; detailed spec from @JeroenDeDauw, no redirection; diff not yet human-reviewed; PHPUnit, PHPCS and PHPStan locally, CI green, the mail checked in Mailcatcher, the new tests checked against mutations and a full revert, fresh-context pr-review, review-tests and text-review passes applied.

@codecov-commenter

codecov-commenter commented Sep 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.81013% with 24 lines in your changes missing coverage. Please review.
✅ Project coverage is 86.58%. Comparing base (2e3e4b6) to head (669f328).

Files with missing lines Patch % Lines
src/MemberAccessExtension.php 12.00% 22 Missing ⚠️
src/Application/AllowlistValue.php 0.00% 2 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff              @@
##             master      #30      +/-   ##
============================================
- Coverage     86.74%   86.58%   -0.17%     
- Complexity      647      677      +30     
============================================
  Files            71       75       +4     
  Lines          2226     2378     +152     
============================================
+ Hits           1931     2059     +128     
- Misses          295      319      +24     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

JeroenDeDauw and others added 2 commits September 2, 2026 17:53
A mail server refusing an address quotes it back, and the code mailer put the
rendered status of a failed send in the log, beside the hash that is there so
the address is not. It now logs the status's message keys instead.

The spy emailer refuses the way a server does, quoting the recipient, which is
what lets the existing test that the log carries no address fail without this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adding an address to the allowlist grants access and sends nothing, while the admin panel's wording
around it reads as though a mail goes out. Adding an entry stays "grant access"; inviting becomes a
separate, re-sendable act. This is the extension half — the endpoint, the mail, and the record of
when one last went. The admin panel half is a separate ProAdmin pull request.

## The endpoint

`POST /member-access/v0/entries/{id}/invitation` mails an invitation to the address of allowlist
entry `{id}` and records when. Each call sends another mail and replaces the recorded timestamp. It
requires the manage right and CSRF token like every other write, and nothing throttles it: the
right bounds who may send, not how often. The send is logged on the `MemberAccess` channel with the
address hashed and who asked for it.

* `200` `{ "id": 7, "invited": "2026-09-01T16:40:00Z" }`
* `404` `entry_not_found`
* `400` `not_an_address`, since a domain rule has no recipient
* `409` `code_login_off`, since the mail says to log in with a code, which a wiki whose code route
  is off does not offer. Both `allowlisted` and `open` count as on, and an unrecognised setting
  reads as off like everywhere else
* `500` `invitation_not_sent`, and nothing is recorded

The mail goes out inside the request rather than deferred: the endpoint handles one entry, and the
caller needs the outcome in the response.

## The mail

A formatted part and a plain one carrying the same content, in the wiki's content language, from
the sender the login code uses. It names the wiki, the login page — its canonical URL, from
`SpecialPage::getTitleFor( 'Userlogin' )` — the address to log in with, and that a code is mailed
rather than a password set, and closes with a line for someone who was not expecting it. Never a
username, never who invited them.

The code mail carries no link on purpose. An invitation is useless without one, so this mail links
the login page and gives the link the URL itself as its text, so nothing hides where it goes. The
address and the URL reach the messages as plaintext parameters, out of the preprocessor's reach,
and the template escapes what it interpolates; `MediaWikiInvitationMailerTest` pins both.

## Recording it

A nullable `mae_invited` on `memberaccess_entry`, surfaced as `invited` wherever an entry is
serialised.

It ships twice, because `addExtensionTable` leaves an install that already has the table untouched:
the updated table definition reaches fresh installs, and an abstract schema change registered with
`addExtensionField` reaches the installs already running the extension, which would otherwise read
a column they do not have. CI runs `update.php` on every run after restoring its cached install, so
the patch applies there and the cache key needs no bump.

`AGENTS.md` said the schema had never shipped and to change a table in place. It has shipped, so
that section now describes both halves; the README's Development section carries the generator
commands.

## Also in the diff

README: an Invitations subsection, the API table row and error codes, the `update.php` and
schema-patch paragraphs, a feature bullet and a release-notes bullet. Two REST test helpers moved
onto `RestApiTestCase`, and `ExtensionManifestTest` now checks that every REST route names a
callable factory.

## Verification

Beyond the suite: `update.php` on a database already holding the table added the column, and the
endpoint was driven against a dev wiki through its production wiring, with both mails arriving in
Mailcatcher and checked as rendered HTML and as plain text.

## Considered, omitted

* Naming the inviter's role ("an administrator of {{SITENAME}} added your address") as a trust cue
  in the mail. The strongest candidate here, but beyond what the recipient needs to log in.
* Telling a recipient who does not want in how to get off the list. The extension offers no such
  route, so the mail can only say to ignore it.
* Sharing `reasonsFor()` between the two mailers: four lines, copied.
* Hoisting the empty-body `parseBodyData` override onto `MemberAccessApiHandler`, where it would
  replace three identical copies. It changes what the body-taking endpoints answer to an empty
  body, so it wants a commit of its own.
* Giving `MemberAccessApiHandler` the allowlist-entry JSON shape, which `ListEntriesApi` and
  `AddEntriesApi` each spell out — this branch had to add `invited` to both. Worth doing; not here.
* Reading the entry from the primary before inviting, for a replicated install where an entry added
  a moment ago is not on the replica yet. MediaWiki's ChronologyProtector already holds a client's
  next request until the replica has caught up with its last write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JeroenDeDauw
JeroenDeDauw force-pushed the feature/invitation-emails branch from 7639c51 to 669f328 Compare September 2, 2026 15:58
@JeroenDeDauw
JeroenDeDauw marked this pull request as ready for review September 2, 2026 17:25
@JeroenDeDauw
JeroenDeDauw merged commit b439052 into master Sep 2, 2026
6 checks passed
@JeroenDeDauw
JeroenDeDauw deleted the feature/invitation-emails branch September 2, 2026 17:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants