Skip to content

Invite email addresses to rooms - #6026

Draft
devonh wants to merge 10 commits into
devonh/username-templatesfrom
devonh/invite-guests
Draft

devonh wants to merge 10 commits into
devonh/username-templatesfrom
devonh/invite-guests

Conversation

@devonh

@devonh devonh commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

TODO:
This description is still LLM generated. I will personally reword it before marking this PR as ready for review.

Lets an admin API client email the invite link of a registration token. It's built for the restricted-guests Synapse module, whose invite_guests endpoint (element-hq/element-modules#304) checks the inviter and the room, mints one token per invitee, then has MAS email them all.

Replaces #6003.

Minting: POST /api/admin/v1/user-registration-tokens

It now takes a room_id, and checks what a token pins before minting it:

  • a pinned email must parse;
  • a username template (a username containing {, rendered with minijinja; see Templated username for registration tokens #6051) must render with a sample random, and the result must pass the registration policy. Otherwise it's a 400 carrying the render error. A template isn't checked for being taken;
  • a pinned email or username must pass the registration policy (400, naming every violation), so no token is minted that its registration form would refuse;
  • a pinned username that isn't a template must not be taken (409).

The module mints each invite as:

POST /api/admin/v1/user-registration-tokens
{
  "email": "bob@partner.example",
  "username": "guest-{{ random }}",
  "passwordless": true,
  "usage_limit": 1,
  "expires_at": "2026-10-23T12:00:00Z",
  "room_id": "!abc:example.com"
}

Sending: POST /api/admin/v1/user-registration-tokens/invite-emails

POST /api/admin/v1/user-registration-tokens/invite-emails
{
  "invites": [
    { "registration_token_id": "01J…" },
    { "registration_token_id": "01K…", "email": "carol@example.com" }
  ],
  "room_name": "Project X",
  "inviter": "@alice:example.com",
  "inviter_name": "Alice"
}
→ 202 {"scheduled": 2}
  • Who can call it: admin API clients (urn:mas:admin), as for every admin endpoint.
  • Recipient: a token that pins an email is sent to that email; email may be given, but must match it, ignoring case. A token without one needs email, so a multi-use token can be sent to several addresses.
  • Validation, before anything is scheduled: every token exists (404) and is valid (400), every address parses, and room_name is only given if every token has a room. A token and address given more than once, ignoring case, are sent once. The batch commits as a whole.
  • Untrusted text: the room name and the inviter's display name come from room state. They're stripped of control and bidi characters and truncated to 100 characters before they reach a job. The inviter's Matrix ID is kept whole or left out, never cut.
  • At send time: the job looks the token up again, and sends nothing if it has been revoked, used up or has expired since.

The link is /register?token=…. For a token with a room, the email names the room and the inviter, and after signing up, or signing in with an existing account, the invitee is sent to the room in their client, where they ask to join. For a token without a room, the email invites them to the server.

Every guest invite is an invite registration: its form offers no upstream providers, and it skips the registration rate limit.

There is no configuration: the caller sets each token's expiry, and room_invites.client_room_url (#6025) is still where invitees are sent.

Notes for review

  • Only MAS's users are checked for a taken username at mint. POST /register checks the homeserver when the guest signs up.
  • Sends are spaced 100 ms apart within a request, so a batch doesn't burst against the SMTP server.
  • The email is English only for now.
  • The token API returns each token's invite_url, so the client that mints a token can read its link. Emailing it through MAS doesn't change who can see it.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Deploying matrix-authentication-service-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1a7956a
Status: ✅  Deploy successful!
Preview URL: https://d90b457f.matrix-authentication-service-docs.pages.dev
Branch Preview URL: https://devonh-invite-guests.matrix-authentication-service-docs.pages.dev

View logs

@devonh devonh changed the title Devonh/invite guests Invite guests to rooms by email Oct 5, 2026
@devonh
devonh added this pull request to stack #6027 October 5, 2026 20:48
@devonh
devonh force-pushed the devonh/invite-guests branch from 9036a44 to ba74383 Compare October 9, 2026 20:10
@sandhose
sandhose force-pushed the devonh/invite-guests branch from 46adaf5 to 0e6125c Compare October 13, 2026 09:32
@sandhose
sandhose removed this pull request from stack #6027 October 13, 2026 09:32
@sandhose
sandhose added this pull request to stack #6046 October 13, 2026 09:32
@sandhose
sandhose force-pushed the devonh/invite-guests branch from 0e6125c to 957df9a Compare October 13, 2026 12:02
@sandhose
sandhose removed this pull request from stack #6046 October 13, 2026 12:02
@sandhose
sandhose added this pull request to stack #6047 October 13, 2026 12:02
@sandhose
sandhose force-pushed the devonh/invite-guests branch from 957df9a to 4ff9aba Compare October 13, 2026 13:35
@sandhose sandhose added the Z-Build-Workflow Add this label to trigger a build workflow for this pull request label Oct 13, 2026
@github-actions github-actions Bot removed the Z-Build-Workflow Add this label to trigger a build workflow for this pull request label Oct 13, 2026
@github-actions

Copy link
Copy Markdown
Contributor

A build for this PR at commit a2424d8 has been created through the Z-Build-Workflow label by sandhose.

Docker image is available at:

  • ghcr.io/element-hq/matrix-authentication-service:pr-6026
  • ghcr.io/element-hq/matrix-authentication-service:sha-a2424d8

Pre-built binaries are available through the workflow run artifacts.

@devonh
devonh removed this pull request from stack #6047 October 14, 2026 22:01
@devonh
devonh changed the base branch from devonh/open-room to devonh/username-templates October 14, 2026 22:01
@devonh
devonh added this pull request to stack #6052 October 14, 2026 22:01
@devonh devonh changed the title Invite guests to rooms by email Invite email addresses to rooms Oct 14, 2026
@sandhose sandhose added the Z-Build-Workflow Add this label to trigger a build workflow for this pull request label Oct 15, 2026
@github-actions github-actions Bot removed the Z-Build-Workflow Add this label to trigger a build workflow for this pull request label Oct 15, 2026
@github-actions

Copy link
Copy Markdown
Contributor

A build for this PR at commit 4b7d820 has been created through the Z-Build-Workflow label by sandhose.

Docker image is available at:

  • ghcr.io/element-hq/matrix-authentication-service:pr-6026
  • ghcr.io/element-hq/matrix-authentication-service:sha-4b7d820

Pre-built binaries are available through the workflow run artifacts.

@sandhose
sandhose force-pushed the devonh/invite-guests branch from 4034706 to 7fb5563 Compare October 16, 2026 09:29
devonh and others added 8 commits October 16, 2026 18:45
`SendGuestInviteEmailJob` sends one guest invite email for a
registration token, so that a bad address or a transient SMTP failure
retries that one email rather than a whole batch. It takes both the
recipient and the invite link from the token, so the link can't be sent
to anyone but the address the token is pinned to. A token revoked,
expired or used by the time the job runs gets no email.

The link registers with the token, then opens the room through the
`OpenRoom` post-auth action. A browser already signed in to MAS goes
straight to the room.

The email names the room and who sent the invite, when the job has
them, and tells the recipient they are asking to join, since someone in
the room still has to approve them. Each combination has a message of
its own, rather than one message passed into another, which would
escape the names twice in HTML. Its subject names neither, as both are
chosen by the sender.

Mints a single-use, passwordless registration token for each guest,
pinned to their address and username and expiring after
`guest_invites.invite_lifetime`, and schedules their invite emails. The
links are only ever mailed to the guests, so the response carries a
count rather than the tokens.

Everything is validated before anything is minted, and the batch is
committed as a whole, so a rejected request sends no email at all. Each
invite goes through the registration policy as `POST /register` will
check it, so an invite the guest form would refuse is a 400 naming the
invite and every violation. The policy sees an empty requester, as the
request comes from the inviter's homeserver rather than the guest. A
username that is already taken is refused up front, rather than emailed
out as a link nobody can register with. Addresses are deduplicated
ignoring case before the usernames are checked, keeping the first entry,
so a repeated address never counts as a username given twice.

The caller sends the room name and the inviter on the inviter's behalf,
so the endpoint doesn't trust them. The room name and the inviter's
display name are stripped of control characters, including the
bidirectional controls `char::is_control` misses, and truncated before
they are stored in the job payload. The inviter's Matrix ID is never
truncated, so it can't show a server it isn't from: it is kept whole if
it is valid under the historical grammar, and left out otherwise.

The room ID is checked as `/open-room` checks it, and is only recorded
on the span once it is valid.

The endpoint answers 404 while guest invites are disabled. The check is
in the handler, as the admin router doesn't see the configuration.
`guest_invites.enabled` turns it on, and requires `client_room_url`.
`/open-room` keeps using `client_room_url` while the endpoint is
disabled, so that links already sent keep working.

`invite_lifetime` is a positive number of seconds that fits in a `u32`,
so MAS refuses a zero or negative lifetime at startup, and adding it to
the current time can't overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R27QDzJTWSBxkrfbxBxcKF
Nothing in the endpoint, its email or its config is specific to who is
invited, so they take neutral names: `POST /api/admin/v1/room-invites`
with the `room-invite` OpenAPI tag, `SendRoomInviteEmailJob` on the
`send-room-invite-email` queue, the `emails/room_invite.*` templates and
their `mas.emails.room_invite.*` strings, and the `room_invites_enabled`
and `room_invite_lifetime` site config fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
The room invites endpoint stores each invite's room on the registration
token it mints, so the email job no longer carries the room, and the
email links to `/register?token=…`, built as the admin API's
`invite_url` is. Registering, or opening the link again once signed in,
reaches the room through the token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
An invite's `username` may be a template with one `{random}`, which the
minted token stores as is and registering fills in. The endpoint checks
a template by filling `{random}` with letters and digits and running the
registration policy on the result, so a template the policy refuses is a
400 when inviting rather than an invite that can't register. A template
with more than one `{random}` is a 400. Every invite may carry the same
template, and the name it fills in to is only known when registering, so
the repeated-username and username-taken checks apply to literal
usernames only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RkxQWj8RAKd7iZ1JQtXdxm
The token admin API can now mint a token that opens a room, as the
`room-invites` endpoint's tokens do, and shows the room on every token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lkm4JwQcd72UvZuWVLMg2e
A pinned email must parse and a username template must have one
`{random}`. Both must pass the registration policy, and a pinned
username which isn't a template must not be taken, so that a token
isn't minted that nobody can register with.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R27QDzJTWSBxkrfbxBxcKF
`POST /api/admin/v1/room-invites` minted a token for each address and
emailed it in one call. It becomes
`POST /api/admin/v1/user-registration-tokens/invite-emails`, which
emails the links of tokens minted through the token API: to the token's
own address, or to one given for a token without an address. A token
without a room can be sent too.

The `room_invites.enabled` and `room_invites.invite_lifetime` settings
are gone: minting is the token API's, which takes an expiry, and every
admin API client may already read invite links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R27QDzJTWSBxkrfbxBxcKF
The room invite email becomes the invite email, as it is sent for any
registration token. When the token opens no room, it invites the
recipient to the server, names no room and asks for no approval.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R27QDzJTWSBxkrfbxBxcKF
devonh and others added 2 commits October 19, 2026 19:52
Adding a registration token renders a username template with a sample
`random`, in strict mode, and checks the result against the registration
policy. A template that doesn't parse or render is a 400 carrying the
render error. This replaces the `{random}` sample check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145EoXnCPYo2bnhuGTohX55
devonh added a commit to element-hq/element-modules that referenced this pull request Oct 28, 2026
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.

2 participants