Repository navigation
Conversation
Mounting the endpoint whenever `mas` was set gave every MAS deployment a way to send email on its behalf as soon as it upgraded. It's now opt-in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
The servlet read the sender's level from `m.room.power_levels` by hand, which misses room creators: in a version 12 room they outrank every level without being listed in it, so a creator was refused whenever the room's `invite` level was above zero. `get_user_power_level` handles creators and rooms without power levels as Synapse's own invite check does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
Guests can't invite anyone by Matrix ID, so they can't invite by email either. An invited guest can only get into a room by asking to join, which needs the join rule `knock`: Element Web offers knocking only there, not on `knock_restricted`. And a room forbidden to guests never lets one in. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
Every invite is an email sent from this server's domain, so the limit counts emails rather than requests, and charges a request's emails all or nothing. The module API has no rate limiter, so this uses Synapse's own `Ratelimiter` through `api._hs`, as the room list patch does. It's per worker and resets on restart. Addresses are deduplicated ignoring case first, so a repeated address is neither counted twice nor sent to MAS twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
MAS names them in the invite email, so the invitee knows which room they're asked to join and who asked. Room members set both, so a value that isn't a non-empty string is left out, and MAS falls back to its generic wording. This also moves the request to `POST /api/admin/v1/invite-guests`, which is what MAS calls the endpoint that takes these fields. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
Every MAS error reached the inviter as a 500. MAS's 400 is about the request the inviter made, such as a malformed address, so it passes through with MAS's message, and its 404 means MAS has guest invites turned off. Anything else, including a refused token request, a timeout or MAS being unreachable, is a 502: none of it is the inviter's to fix. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
Covers the `email_invites` options, what the endpoint checks and answers, and a deployment checklist, including the advice to invite colleagues by Matrix ID rather than email. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CZdGaj9bJcn5QeeUw8YXoF
da4f448 to
2370ccc
Compare
| for email in unique_emails.values(): | ||
| localpart = self._config.user_id_prefix + "".join( | ||
| secrets.choice(string.ascii_lowercase + string.digits) | ||
| for _ in range(32) | ||
| ) | ||
| invites.append({"email": email, "username": localpart}) |
There was a problem hiding this comment.
This duplicates a small little bit of the guest_registration_servlet to generate a random localpart.
It drops the retry on localpart collision. 32 random characters should be more than random enough to never feasibly collide.
It also drops the existence check. While it could be added here as another layer, the new guest email invites architecture means MAS will check for existence when creating the invite, then MAS will check again when the invited guest follows the invite link to actually create the user account.
…nvites Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpxhUvvbRYpQSxhidPnnP7
| max_emails: int = 20 | ||
| per_inviter_per_hour: int = 50 |
There was a problem hiding this comment.
These defaults seem okay 🤷
Basically just numbers to prevent users from generating email / endpoint spam.
Either way, they are configurable to meet a deployments actual usage.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LpxhUvvbRYpQSxhidPnnP7
| async def test_mas_bad_request(self) -> None: | ||
| module, module_api, _ = self.create_module() | ||
| module_api.http_client.post_json_get_json.side_effect = HttpResponseException( | ||
| 400, "Bad Request", b'{"errors":[{"title":"Invalid email address"}]}' | ||
| ) | ||
|
|
||
| status, response = await self.render(module) | ||
|
|
||
| self.assertEqual((status, response), (400, {"msg": "Invalid email address"})) | ||
|
|
||
| async def test_mas_bad_request_unknown_body(self) -> None: | ||
| module, module_api, _ = self.create_module() | ||
| module_api.http_client.post_json_get_json.side_effect = HttpResponseException( | ||
| 400, "Bad Request", b"<html>Bad Request</html>" | ||
| ) | ||
|
|
||
| status, response = await self.render(module) | ||
|
|
||
| self.assertEqual((status, response), (400, {"msg": "MAS refused the invites"})) | ||
|
|
||
| async def test_mas_invites_disabled(self) -> None: | ||
| module, module_api, _ = self.create_module() | ||
| module_api.http_client.post_json_get_json.side_effect = HttpResponseException( | ||
| 404, "Not Found", b'{"errors":[{"title":"Not found"}]}' | ||
| ) | ||
|
|
||
| with self.assertLogs("synapse.contrib", "WARNING"): | ||
| status, response = await self.render(module) | ||
|
|
||
| self.assertEqual(status, 404) |
There was a problem hiding this comment.
I could easily be persuaded to drop these 3 tests.
They add a small amount of value in exercising all the code paths.
They are also super tiny, so easy enough to keep.
|
CI failing is due to a failure on main. |
There was a problem hiding this comment.
🟡 Changes recommended
Room validation, rate-limit enforcement, and upstream response validation have unresolved correctness and security issues.
4 open findings
What changed in this PR
Adds an opt-in endpoint for inviting room guests by email through MAS, including validation, authorization, rate limiting, configuration, documentation, and tests.
Changes:
- Adds
POST /_synapse/client/invite_guests. - Integrates MAS guest-invite scheduling and error handling.
- Adds configuration, documentation, and test coverage.
| File | Description |
|---|---|
README.md |
Documents configuration and endpoint behavior. |
config.py |
Defines email-invite configuration. |
guest_invite_servlet.py |
Implements validation, authorization, and scheduling. |
guest_module.py |
Parses configuration and registers the endpoint. |
mas_admin_client.py |
Calls MAS’s guest-invite API. |
test_guest_invite_servlet.py |
Tests endpoint behavior and failures. |
test_guest_module.py |
Tests configuration parsing. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| scheduled = response.get("scheduled") | ||
| if not isinstance(scheduled, int): | ||
| raise ValueError("MAS guest invite response missing `scheduled` field") |
There was a problem hiding this comment.
While this is technically more correct, it is adding a lot more complexity to validate a value we are receiving directly from MAS (not a user, config, or anything outside our control).
I'm inclined to leave it as is
Each invite now carries the username template `<user_id_prefix>{random}`,
which MAS fills in when the guest signs up. The guest's localpart is then
chosen once, when the account is created.
Before, each invite pinned a localpart of its own. Only the first one
registered was ever used, because MAS refuses a second account for an
address that is in use.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
MAS serves the invites at `POST /api/admin/v1/room-invites`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
The module mints a single-use, passwordless invite code per address with MAS's registration token admin API, then has MAS email them all with `POST /api/admin/v1/user-registration-tokens/invite-emails`. Every code is minted before any email is sent, so an address MAS refuses sends nothing. The module now sets the codes' expiry, from the new `email_invites.invite_lifetime` option. A 404 from MAS is a 502: MAS has no switch for these endpoints any more, so it means MAS is too old or isn't at `mas.admin_api_base_url`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R27QDzJTWSBxkrfbxBxcKF
MAS now takes a registration token's username as a minijinja template, with
`random` as its variable. The old `{random}` placeholder is refused when
minting a token by a MAS with
element-hq/matrix-authentication-service#6026's template check.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145EoXnCPYo2bnhuGTohX55



Part of https://github.com/element-hq/backend-internal/issues/292
Adds
POST /_synapse/client/invite_guests, which invites a list of email addresses to join a room as guests. For each address, the module mints a single-use, passwordless invite code with MAS'sPOST /api/admin/v1/user-registration-tokens, pinned to that address, the room, and a guest username template. Once every code is minted, it has MAS email them all withPOST /api/admin/v1/user-registration-tokens/invite-emails(element-hq/matrix-authentication-service#6026). An address MAS refuses fails the request before any email is sent.The email names the room and the inviter. The invitee signs up through the link as a new guest, or signs in with an existing account, and lands on the room. There they ask to join, and a member approves them.
The endpoint currently sends no room invite event. The design for how that will work is still undecided and will come later.
The functionality added by this PR is off by default.
email_invites.enabledturns it on.Synapse's own email-invite controls don't apply yet:
block_non_admin_invites, and the 3pid spam-checker and third-party-rules callbacks. A follow-up PR (#306) adds them.