Send an invitation mail to an admitted address - #30
Merged
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
This was referenced Sep 1, 2026
JeroenDeDauw
force-pushed
the
feature/invitation-emails
branch
from
September 1, 2026 17:58
345bb56 to
7639c51
Compare
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
force-pushed
the
feature/invitation-emails
branch
from
September 2, 2026 15:58
7639c51 to
669f328
Compare
JeroenDeDauw
marked this pull request as ready for review
September 2, 2026 17:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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}/invitationmails an invitation to the address of allowlistentry
{id}and records when. Each call sends another mail and replaces the recorded timestamp. Itrequires 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
MemberAccesschannel with theaddress hashed and who asked for it.
200{ "id": 7, "invited": "2026-09-01T16:40:00Z" }404entry_not_found400not_an_address, since a domain rule has no recipient409code_login_off, since the mail says to log in with a code, which a wiki whose code routeis off does not offer. Both
allowlistedandopencount as on, and an unrecognised settingreads as off like everywhere else
500invitation_not_sent, and nothing is recordedThe 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 mailedrather 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;
MediaWikiInvitationMailerTestpins both.Recording it
A nullable
mae_invitedonmemberaccess_entry, surfaced asinvitedwherever an entry isserialised.
It ships twice, because
addExtensionTableleaves an install that already has the table untouched:the updated table definition reaches fresh installs, and an abstract schema change registered with
addExtensionFieldreaches the installs already running the extension, which would otherwise reada column they do not have. CI runs
update.phpon every run after restoring its cached install, sothe patch applies there and the cache key needs no bump.
AGENTS.mdsaid the schema had never shipped and to change a table in place. It has shipped, sothat 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 thatSpyEmailerrefuses the way a server does,quoting the address. The same leak in
MediaWikiMemberBlockeris#31.
Also in the diff
README: an Invitations subsection, the API table row and error codes, the
update.phpandschema-patch paragraphs, a feature bullet and a release-notes bullet. Two REST test helpers moved
onto
RestApiTestCase, andExtensionManifestTestnow checks that every REST route names acallable factory.
Verification
Beyond the suite:
update.phpon a database already holding the table added the column, and theendpoint 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
in the mail. The strongest candidate here, but beyond what the recipient needs to log in.
route, so the mail can only say to ignore it.
reasonsFor()between the two mailers: four lines, copied.parseBodyDataoverride ontoMemberAccessApiHandler, where it wouldreplace three identical copies. It changes what the body-taking endpoints answer to an empty
body, so it wants a commit of its own.
MemberAccessApiHandlerthe allowlist-entry JSON shape, whichListEntriesApiandAddEntriesApieach spell out — this branch had to addinvitedto both. Worth doing; not here.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.