Skip to content

Add an endpoint to invite guests to a room by email - #304

Open
devonh wants to merge 16 commits into
mainfrom
devonh/room-invite-guests
Open

devonh wants to merge 16 commits into
mainfrom
devonh/room-invite-guests

Conversation

@devonh

@devonh devonh commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

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's POST /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 with POST /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.enabled turns it on.

email_invites:
  enabled: true
  max_emails: 20            # addresses per request
  per_inviter_per_hour: 50  # addresses per user per hour, per worker
  invite_lifetime: 604800   # seconds an invite link stays valid

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.

devonh and others added 8 commits October 4, 2026 16:14
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
Comment on lines +137 to +142
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})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +30 to +31
max_emails: int = 20
per_inviter_per_hour: int = 50

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Comment on lines +353 to +382
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@devonh

devonh commented Oct 9, 2026

Copy link
Copy Markdown
Member Author

CI failing is due to a failure on main.
#308 fixes the issue.

@devonh
devonh marked this pull request as ready for review October 9, 2026 22:28
@devonh
devonh requested a review from a team as a code owner October 9, 2026 22:28
@devonh
devonh requested a review from erikjohnston October 9, 2026 22:28
@erikjohnston
erikjohnston requested a balanced review from Copilot October 13, 2026 08:26

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment on lines +131 to +133
scheduled = response.get("scheduled")
if not isinstance(scheduled, int):
raise ValueError("MAS guest invite response missing `scheduled` field")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

devonh and others added 3 commits October 13, 2026 19:01
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

This branch has not been deployed

No deployments
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.

3 participants