diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 4807693e6..f66e47504 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -271,7 +271,11 @@ pub fn site_config_from_config( device_code_grant_enabled: oauth_config.device_code_grant_enabled, device_code_user_code_auto_fill_enabled: oauth_config .device_code_user_code_auto_fill_enabled, + guest_invites_enabled: room_invites_config.enabled, room_invites_client_room_url: room_invites_config.client_room_url.clone(), + guest_invite_lifetime: chrono::Duration::seconds( + room_invites_config.invite_lifetime.get().into(), + ), }) } diff --git a/crates/config/src/sections/room_invites.rs b/crates/config/src/sections/room_invites.rs index bea6d2156..2c623d103 100644 --- a/crates/config/src/sections/room_invites.rs +++ b/crates/config/src/sections/room_invites.rs @@ -3,26 +3,73 @@ // SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial // Please see LICENSE files in the repository root for full details. +use std::num::NonZeroU32; + use schemars::JsonSchema; use serde::{Deserialize, Serialize, de::Error}; use url::Url; use crate::ConfigurationSection; +const fn default_false() -> bool { + false +} + +#[expect(clippy::trivially_copy_pass_by_ref)] +const fn is_default_false(value: &bool) -> bool { + *value == default_false() +} + +/// Seven days: a starting point; shorten it where unused invite links are a +/// concern +fn default_invite_lifetime() -> NonZeroU32 { + NonZeroU32::new(7 * 24 * 60 * 60).unwrap() +} + +#[expect(clippy::trivially_copy_pass_by_ref)] +fn is_default_invite_lifetime(value: &NonZeroU32) -> bool { + *value == default_invite_lifetime() +} + /// Configuration section for inviting email addresses to rooms -#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct RoomInvitesConfig { + /// Whether the `POST /api/admin/v1/invite-guests` endpoint is enabled. + /// Defaults to `false`. + #[serde(default = "default_false", skip_serializing_if = "is_default_false")] + pub enabled: bool, + /// The client URL to send invitees to once they are signed in, with /// `{room_id}` standing for the room ID, e.g. - /// `https://app.element.io/#/room/{room_id}`. + /// `https://app.element.io/#/room/{room_id}`. Required when `enabled` is + /// set. #[serde(skip_serializing_if = "Option::is_none")] pub client_room_url: Option, + + /// How long an invite link stays valid, in seconds. Defaults to 7 days. + #[serde( + default = "default_invite_lifetime", + skip_serializing_if = "is_default_invite_lifetime" + )] + pub invite_lifetime: NonZeroU32, +} + +impl Default for RoomInvitesConfig { + fn default() -> Self { + Self { + enabled: default_false(), + client_room_url: None, + invite_lifetime: default_invite_lifetime(), + } + } } impl RoomInvitesConfig { /// Returns true if the configuration is the default one pub(crate) fn is_default(&self) -> bool { - self.client_room_url.is_none() + is_default_false(&self.enabled) + && self.client_room_url.is_none() + && is_default_invite_lifetime(&self.invite_lifetime) } } @@ -44,6 +91,7 @@ impl ConfigurationSection for RoomInvitesConfig { }; match &self.client_room_url { + None if self.enabled => return Err(error("missing field `client_room_url`")), Some(url) if !url.contains("{room_id}") => { return Err(error("must contain `{room_id}`")); } @@ -85,21 +133,64 @@ mod tests { fn defaults() { Jail::expect_with(|jail| { let config = load(jail, "room_invites: {}").unwrap(); + assert!(!config.enabled); assert_eq!(config.client_room_url, None); + assert_eq!(config.invite_lifetime.get(), 604_800); assert!(config.is_default()); Ok(()) }); } + #[test] + fn enabled_requires_client_room_url() { + Jail::expect_with(|jail| { + let error = load(jail, "room_invites: { enabled: true }").unwrap_err(); + assert!(error.contains("client_room_url"), "{error}"); + + let config = load( + jail, + r" + room_invites: + enabled: true + client_room_url: https://app.example.com/#/room/{room_id} + invite_lifetime: 3600 + ", + ) + .unwrap(); + assert!(config.enabled); + assert_eq!(config.invite_lifetime.get(), 3600); + Ok(()) + }); + } + + #[test] + fn invite_lifetime_must_be_positive() { + Jail::expect_with(|jail| { + for lifetime in ["0", "-1", "4294967296"] { + let error = load( + jail, + &format!("room_invites: {{ invite_lifetime: {lifetime} }}"), + ) + .unwrap_err(); + assert!(error.contains("invite_lifetime"), "{error}"); + } + Ok(()) + }); + } + #[test] fn client_room_url_requires_room_id() { Jail::expect_with(|jail| { - let error = load( - jail, - "room_invites: { client_room_url: 'https://app.example.com/' }", - ) - .unwrap_err(); - assert!(error.contains("{room_id}"), "{error}"); + for enabled in [true, false] { + let error = load( + jail, + &format!( + "room_invites: {{ enabled: {enabled}, client_room_url: 'https://app.example.com/' }}" + ), + ) + .unwrap_err(); + assert!(error.contains("{room_id}"), "{error}"); + } Ok(()) }); } diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index 06fff5c67..33fbca593 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -129,7 +129,13 @@ pub struct SiteConfig { /// query parameter to auto-fill the user code. pub device_code_user_code_auto_fill_enabled: bool, + /// Whether the guest invites admin endpoint is enabled. + pub guest_invites_enabled: bool, + /// The client URL to send invitees to, with `{room_id}` standing for the /// room ID. pub room_invites_client_room_url: Option, + + /// How long a guest invite link stays valid. + pub guest_invite_lifetime: Duration, } diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 0b5f96d97..067dafcbe 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -10,7 +11,10 @@ use lettre::{ AsyncTransport, Message, message::{Mailbox, MessageBuilder, MultiPart}, }; -use mas_templates::{EmailRecoveryContext, EmailVerificationContext, Templates, WithLanguage}; +use mas_templates::{ + EmailGuestInviteContext, EmailRecoveryContext, EmailVerificationContext, Templates, + WithLanguage, +}; use thiserror::Error; use crate::MailTransport; @@ -102,6 +106,28 @@ impl Mailer { Ok(message) } + fn prepare_guest_invite_email( + &self, + to: Mailbox, + context: &WithLanguage, + ) -> Result { + let plain = self.templates.render_email_guest_invite_txt(context)?; + + let html = self.templates.render_email_guest_invite_html(context)?; + + let multipart = MultiPart::alternative_plain_html(plain, html); + + let subject = self.templates.render_email_guest_invite_subject(context)?; + + let message = self + .base_message() + .subject(subject.trim()) + .to(to) + .multipart(multipart)?; + + Ok(message) + } + /// Send the verification email to a user /// /// # Errors @@ -150,6 +176,29 @@ impl Mailer { Ok(()) } + /// Send a guest invite email + /// + /// # Errors + /// + /// Will return `Err` if the email failed rendering or failed sending + #[tracing::instrument( + name = "email.guest_invite.send", + skip_all, + fields( + email.to = %to, + email.language = %context.language(), + ), + )] + pub async fn send_guest_invite_email( + &self, + to: Mailbox, + context: &WithLanguage, + ) -> Result<(), Error> { + let message = self.prepare_guest_invite_email(to, context)?; + self.transport.send(message).await?; + Ok(()) + } + /// Test the connetion to the mail server /// /// # Errors diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index 58a9e0aa4..5b6dc0def 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -24,7 +24,7 @@ use mas_axum_utils::InternalError; use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_http::CorsLayerExt; use mas_matrix::HomeserverConnection; -use mas_policy::PolicyFactory; +use mas_policy::{Policy, PolicyFactory}; use mas_router::{ ApiDoc, ApiDocCallback, OAuth2AuthorizationEndpoint, OAuth2TokenEndpoint, Route, SimpleRoute, UrlBuilder, @@ -55,6 +55,11 @@ fn finish(t: TransformOpenApi) -> TransformOpenApi { description: Some("Manage compatibility sessions from legacy clients".to_owned()), ..Tag::default() }) + .tag(Tag { + name: "guest-invite".to_owned(), + description: Some("Invite guests to rooms by email".to_owned()), + ..Tag::default() + }) .tag(Tag { name: "policy-data".to_owned(), description: Some("Manage the dynamic policy data".to_owned()), @@ -167,6 +172,7 @@ where PasswordManager: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, + Policy: FromRequestParts, Templates: FromRef, UrlBuilder: FromRef, Arc: FromRef, diff --git a/crates/handlers/src/admin/v1/guest_invites.rs b/crates/handlers/src/admin/v1/guest_invites.rs new file mode 100644 index 000000000..01981b971 --- /dev/null +++ b/crates/handlers/src/admin/v1/guest_invites.rs @@ -0,0 +1,784 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use std::{collections::HashSet, str::FromStr}; + +use aide::{NoApi, OperationIo, transform::TransformOperation}; +use axum::{Json, extract::State, response::IntoResponse}; +use chrono::Duration; +use hyper::StatusCode; +use lettre::Address; +use mas_axum_utils::record_error; +use mas_data_model::{BoxRng, SiteConfig}; +use mas_policy::Policy; +use mas_storage::queue::{QueueJobRepositoryExt as _, SendGuestInviteEmailJob}; +use rand::distr::{Alphanumeric, SampleString}; +use ruma_common::UserId; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ + admin::{call_context::CallContext, response::ErrorResponse}, + impl_from_error_for_route, + room_id::{is_control_or_bidi, is_valid_room_id}, +}; + +/// How many characters of the room name and of the inviter's name are kept. +/// Both can be up to 64 KiB, and this keeps the job payload and the email small +/// and readable. The value itself is a choice. +const MAX_NAME_LENGTH: usize = 100; + +/// The delay between the scheduled sends of one request, so that a large batch +/// doesn't burst against the SMTP server +const SEND_INTERVAL: Duration = Duration::milliseconds(100); + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error("Guest invites are disabled")] + Disabled, + + #[error("Invalid room ID {0:?}")] + InvalidRoomId(String), + + #[error("No invites given")] + NoInvites, + + #[error("Email {email:?} is not valid")] + EmailNotValid { + email: String, + + #[source] + source: lettre::address::AddressError, + }, + + #[error("Username {0:?} is given twice")] + DuplicateUsername(String), + + #[error( + "Invite for {email:?} with username {username:?} is not allowed: {}", + .violations.join("; ") + )] + NotAllowed { + email: String, + username: String, + violations: Vec, + }, + + #[error("Username {0:?} is already taken")] + UsernameTaken(String), + + #[error(transparent)] + Internal(Box), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); +impl_from_error_for_route!(mas_policy::EvaluationError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let sentry_event_id = record_error!(self, Self::Internal(_)); + let status = match self { + Self::Disabled => StatusCode::NOT_FOUND, + Self::InvalidRoomId(_) + | Self::NoInvites + | Self::EmailNotValid { .. } + | Self::DuplicateUsername(_) + | Self::NotAllowed { .. } => StatusCode::BAD_REQUEST, + Self::UsernameTaken(_) => StatusCode::CONFLICT, + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, sentry_event_id, Json(error)).into_response() + } +} + +/// # A guest to invite to the room +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "GuestInvite")] +pub struct Invite { + /// The address to send the invite to. The guest can only register with + /// this address. + #[schemars(email, example = &"alice@example.com")] + email: String, + + /// The username the guest registers with + #[schemars(example = &"guest-abcdef")] + username: String, +} + +/// # JSON payload for the `POST /api/admin/v1/invite-guests` endpoint +#[derive(Deserialize, JsonSchema)] +#[serde(rename = "InviteGuestsRequest")] +pub struct Request { + /// The room the guests are invited to + #[schemars(example = &"!room:example.com")] + room_id: String, + + /// The name of the room, shown in the invite email + #[schemars(example = &"Project X")] + room_name: Option, + + /// The Matrix ID of the user sending the invites, shown in the invite + /// email. It is left out if it isn't a valid Matrix ID. + #[schemars(example = &"@bob:example.com")] + inviter: Option, + + /// The display name of the user sending the invites, shown in the invite + /// email + #[schemars(example = &"Bob")] + inviter_name: Option, + + /// The guests to invite. An address given more than once, ignoring case, + /// is invited once. + invites: Vec, +} + +/// # Response to the `POST /api/admin/v1/invite-guests` endpoint +#[derive(Serialize, JsonSchema)] +#[serde(rename = "InviteGuestsResponse")] +pub struct Response { + /// How many guests were scheduled an invite email, after dropping repeated + /// addresses + scheduled: usize, +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("inviteGuests") + .summary("Invite a list of email addresses to a room as guests") + .description( + "Mints a single-use, passwordless registration token for each guest, pinned to their \ + address and username, and emails them a link to register with it and open the room. \ + The links are only ever sent to the guests, so they are not in the response.", + ) + .tag("guest-invite") + .response_with::<202, Json, _>(|t| { + t.description("The invite emails were scheduled") + .example(Response { scheduled: 2 }) + }) + .response_with::<400, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotAllowed { + email: "alice@example.com".to_owned(), + username: "guest-abcdef".to_owned(), + violations: vec!["email: email domain is banned".to_owned()], + }); + t.description( + "The request has an invalid room ID, email or username, no invites, a repeated \ + username, or an invite the registration policy refuses", + ) + .example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::Disabled); + t.description("Guest invites are disabled") + .example(response) + }) + .response_with::<409, RouteError, _>(|t| { + let response = + ErrorResponse::from_error(&RouteError::UsernameTaken("alice".to_owned())); + t.description("A username is already taken") + .example(response) + }) +} + +/// Strip control and bidirectional characters, and keep at most +/// [`MAX_NAME_LENGTH`] characters +fn sanitize(text: &str) -> String { + text.chars() + .filter(|c| !is_control_or_bidi(*c)) + .take(MAX_NAME_LENGTH) + .collect() +} + +#[tracing::instrument( + name = "handler.admin.v1.guest_invites.post", + fields(room.id = tracing::field::Empty, recipients.total = params.invites.len()), + skip_all, +)] +pub async fn handler( + CallContext { + mut repo, clock, .. + }: CallContext, + NoApi(mut rng): NoApi, + NoApi(mut policy): NoApi, + State(site_config): State, + Json(params): Json, +) -> Result<(StatusCode, Json), RouteError> { + if !site_config.guest_invites_enabled { + return Err(RouteError::Disabled); + } + + if !is_valid_room_id(¶ms.room_id) { + return Err(RouteError::InvalidRoomId(params.room_id)); + } + tracing::Span::current().record("room.id", params.room_id.as_str()); + + if params.invites.is_empty() { + return Err(RouteError::NoInvites); + } + + // Validate everything before minting anything, so that a rejected request + // sends no email at all + let mut emails = HashSet::new(); + let mut usernames = HashSet::new(); + let mut valid_invites = Vec::with_capacity(params.invites.len()); + for invite in params.invites { + if let Err(source) = Address::from_str(&invite.email) { + return Err(RouteError::EmailNotValid { + email: invite.email, + source, + }); + } + + if !emails.insert(invite.email.to_lowercase()) { + continue; + } + + if !usernames.insert(invite.username.clone()) { + return Err(RouteError::DuplicateUsername(invite.username)); + } + + // The same input as `POST /register` in `views/register/password.rs`, + // which also evaluates a passwordless registration as `Password` + let result = policy + .evaluate_register(mas_policy::RegisterInput { + registration_method: mas_policy::RegistrationMethod::Password, + username: &invite.username, + email: Some(&invite.email), + // The request comes from the inviter's homeserver, not the guest + requester: mas_policy::Requester::default(), + }) + .await?; + if !result.valid() { + let mut violations: Vec = result + .violations + .into_iter() + .map(|violation| match violation.field { + Some(field) => format!("{field}: {}", violation.msg), + None => violation.msg, + }) + .collect(); + violations.sort(); + return Err(RouteError::NotAllowed { + email: invite.email, + username: invite.username, + violations, + }); + } + + // A token pins the username, so the recipient of a token pinned to a + // name someone else already has can never finish registering + if repo.user().exists(&invite.username).await? { + return Err(RouteError::UsernameTaken(invite.username)); + } + + valid_invites.push(invite); + } + + let room_name = params.room_name.as_deref().map(sanitize); + // The Matrix ID is kept whole or left out, never cut, as a cut ID could + // show a server the inviter isn't from. The historical grammar is the + // widest that servers accept, and it has no control or bidi characters. + let inviter = params.inviter.filter(|inviter| { + <&UserId>::try_from(inviter.as_str()).is_ok_and(|id| id.validate_historical().is_ok()) + }); + let inviter_name = params.inviter_name.as_deref().map(sanitize); + let now = clock.now(); + let expires_at = now + site_config.guest_invite_lifetime; + let scheduled = valid_invites.len(); + + for (i, invite) in (0..).zip(valid_invites) { + let token = Alphanumeric.sample_string(&mut rng, 12); + let token = repo + .user_registration_token() + .add( + &mut rng, + &clock, + token, + Some(1), + Some(expires_at), + Some(invite.username), + Some(invite.email), + true, + None, + ) + .await?; + + repo.queue_job() + .schedule_job_later( + &mut rng, + &clock, + SendGuestInviteEmailJob::new( + token.id, + params.room_id.clone(), + room_name.clone(), + inviter.clone(), + inviter_name.clone(), + ), + now + SEND_INTERVAL * i, + ) + .await?; + } + + repo.save().await?; + + tracing::info!("Scheduled {scheduled} guest invite emails"); + + Ok((StatusCode::ACCEPTED, Json(Response { scheduled }))) +} + +#[cfg(test)] +mod tests { + use chrono::Duration; + use hyper::{Request, StatusCode}; + use mas_data_model::{Clock as _, UserRegistrationToken}; + use mas_storage::{ + Pagination, + queue::{QueueJobRepositoryExt as _, SendGuestInviteEmailJob}, + user::UserRegistrationTokenFilter, + }; + use sqlx::PgPool; + + use crate::{ + SiteConfig, + test_utils::{RequestBuilderExt, ResponseExt, TestState, setup, test_site_config}, + }; + + async fn tokens(state: &TestState) -> Vec { + let mut repo = state.repository().await.unwrap(); + let page = repo + .user_registration_token() + .list( + UserRegistrationTokenFilter::new(state.clock.now()), + Pagination::first(100), + ) + .await + .unwrap(); + let mut tokens: Vec<_> = page.edges.into_iter().map(|edge| edge.node).collect(); + tokens.sort_by(|a, b| a.email.cmp(&b.email)); + tokens + } + + async fn job_payloads(pool: &PgPool) -> Vec { + sqlx::query_scalar( + "SELECT payload FROM queue_jobs WHERE queue_name = 'send-guest-invite-email'", + ) + .fetch_all(pool) + .await + .unwrap() + } + + async fn job_statuses(pool: &PgPool) -> Vec { + sqlx::query_scalar( + "SELECT status::TEXT FROM queue_jobs + WHERE queue_name = 'send-guest-invite-email' + ORDER BY payload->>'room_id'", + ) + .fetch_all(pool) + .await + .unwrap() + } + + /// Post `body` and return the response's status and first error title + async fn invite(state: &mut TestState, body: serde_json::Value) -> (StatusCode, String) { + let token = state.token_with_scope("urn:mas:admin").await; + let request = Request::post("/api/admin/v1/invite-guests") + .bearer(&token) + .json(body); + let response = state.request(request).await; + let title = serde_json::from_str::(response.body()) + .ok() + .and_then(|body| body["errors"][0]["title"].as_str().map(ToOwned::to_owned)) + .unwrap_or_default(); + (response.status(), title) + } + + /// Every guest gets a single-use token pinned to their address and + /// username, expiring after the configured lifetime, and an email job of + /// their own + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite(pool: PgPool) { + setup(); + let mut state = TestState::from_pool_with_site_config( + pool.clone(), + SiteConfig { + guest_invite_lifetime: Duration::hours(1), + ..test_site_config() + }, + ) + .await + .unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/invite-guests") + .bearer(&token) + .json(serde_json::json!({ + "room_id": "!room:example.com", + "room_name": "Project X", + "inviter": "@bob:example.com", + "inviter_name": "Bob", + "invites": [ + { "email": "alice@example.com", "username": "guest-alice" }, + { "email": "carol@example.com", "username": "guest-carol" }, + ], + })); + let response = state.request(request).await; + response.assert_status(StatusCode::ACCEPTED); + assert_eq!( + response.json::(), + serde_json::json!({ "scheduled": 2 }) + ); + + let tokens = tokens(&state).await; + let expires_at = state.clock.now() + Duration::hours(1); + assert_eq!(tokens.len(), 2); + for (token, name) in tokens.iter().zip(["alice", "carol"]) { + assert_eq!(token.username.as_deref(), Some(&*format!("guest-{name}"))); + assert_eq!( + token.email.as_deref(), + Some(&*format!("{name}@example.com")) + ); + assert!(token.passwordless); + assert_eq!(token.usage_limit, Some(1)); + assert_eq!(token.expires_at, Some(expires_at)); + } + + let payloads = job_payloads(&pool).await; + assert_eq!(payloads.len(), 2); + for payload in &payloads { + assert_eq!(payload["room_id"], "!room:example.com"); + assert_eq!(payload["room_name"], "Project X"); + assert_eq!(payload["inviter"], "@bob:example.com"); + assert_eq!(payload["inviter_name"], "Bob"); + } + + // Each job finds its token and renders an email out of it. The sends are + // spread out, so the second one only comes due later. + state.run_jobs_in_queue().await; + state.clock.advance(Duration::seconds(1)); + state.run_jobs_in_queue().await; + assert_eq!(job_statuses(&pool).await, ["completed", "completed"]); + } + + /// A version 12 room ID has no server name, and the room name and inviter + /// are optional + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_v12_room(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + + let (status, _) = invite( + &mut state, + serde_json::json!({ + "room_id": "!31hneApxJ_1o-63DmFrpeqnkFfWppnzWso1JvH3ogLM", + "invites": [{ "email": "alice@example.com", "username": "guest-alice" }], + }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); + + let payloads = job_payloads(&pool).await; + assert_eq!( + payloads[0]["room_id"], + "!31hneApxJ_1o-63DmFrpeqnkFfWppnzWso1JvH3ogLM" + ); + assert_eq!(payloads[0]["room_name"], serde_json::Value::Null); + } + + /// An address given twice, in any case, is invited once, with the username + /// it was first given with + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_repeated_email(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/invite-guests") + .bearer(&token) + .json(serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "Alice@Example.com", "username": "guest-a" }, + { "email": "alice@example.com", "username": "guest-b" }, + { "email": "ALICE@EXAMPLE.COM", "username": "guest-a" }, + ], + })); + let response = state.request(request).await; + response.assert_status(StatusCode::ACCEPTED); + assert_eq!( + response.json::(), + serde_json::json!({ "scheduled": 1 }) + ); + + let tokens = tokens(&state).await; + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].username.as_deref(), Some("guest-a")); + assert_eq!(tokens[0].email.as_deref(), Some("Alice@Example.com")); + } + + /// A token pinned to a username nobody can register with is refused, rather + /// than emailed out as a dead link + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_username_taken(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + + let mut repo = state.repository().await.unwrap(); + repo.user() + .add(&mut state.rng(), &state.clock, "guest-alice".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let (status, title) = invite( + &mut state, + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "bob@example.com", "username": "guest-bob" }, + { "email": "alice@example.com", "username": "guest-alice" }, + ], + }), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(title, r#"Username "guest-alice" is already taken"#); + assert!(tokens(&state).await.is_empty()); + } + + /// An invite the registration policy refuses is a 400 naming the invite and + /// every violation, and mints nothing + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_refused_by_policy(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let request = Request::post("/api/admin/v1/policy-data") + .bearer(&token) + .json(serde_json::json!({ + "data": { + "banned_domains": ["banned.example"], + "registration": { "banned_usernames": { "literals": ["guest-banned"] } }, + }, + })); + state + .request(request) + .await + .assert_status(StatusCode::CREATED); + + for (invite, error) in [ + ( + serde_json::json!({ "email": "bob@banned.example", "username": "guest-bob" }), + r#"Invite for "bob@banned.example" with username "guest-bob" is not allowed: email: email domain is banned"#, + ), + ( + serde_json::json!({ "email": "bob@example.com", "username": "guest-banned" }), + r#"Invite for "bob@example.com" with username "guest-banned" is not allowed: username: username is banned"#, + ), + ( + serde_json::json!({ "email": "bob@banned.example", "username": "guest-banned" }), + r#"Invite for "bob@banned.example" with username "guest-banned" is not allowed: email: email domain is banned; username: username is banned"#, + ), + ] { + let (status, title) = invite_one(&mut state, invite).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(title, error); + } + + assert!(tokens(&state).await.is_empty()); + assert!(job_payloads(&pool).await.is_empty()); + } + + async fn invite_one(state: &mut TestState, invite: serde_json::Value) -> (StatusCode, String) { + self::invite( + state, + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "alice@example.com", "username": "guest-alice" }, + invite, + ], + }), + ) + .await + } + + /// A bad request mints nothing at all, so no part of the batch goes out + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_invalid(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + + for (body, error) in [ + ( + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "alice@example.com", "username": "guest-alice" }, + { "email": "not-an-email", "username": "guest-bob" }, + ], + }), + r#"Email "not-an-email" is not valid"#, + ), + ( + serde_json::json!({ + "room_id": "#room:example.com", + "invites": [{ "email": "alice@example.com", "username": "guest-alice" }], + }), + r##"Invalid room ID "#room:example.com""##, + ), + ( + serde_json::json!({ "room_id": "!room:example.com", "invites": [] }), + "No invites given", + ), + ( + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [ + { "email": "alice@example.com", "username": "guest-a" }, + { "email": "bob@example.com", "username": "guest-a" }, + ], + }), + r#"Username "guest-a" is given twice"#, + ), + ] { + let (status, title) = invite(&mut state, body).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{error}"); + assert_eq!(title, error); + } + + // A body which doesn't parse, including an invite without a username + for body in [ + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [{ "email": "alice@example.com" }], + }), + serde_json::json!({ "invites": [] }), + ] { + let (status, _) = invite(&mut state, body).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + } + + assert!(tokens(&state).await.is_empty()); + assert!(job_payloads(&pool).await.is_empty()); + } + + /// With guest invites disabled, the endpoint answers as if it didn't exist + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_disabled(pool: PgPool) { + setup(); + let mut state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + guest_invites_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); + + let (status, title) = invite( + &mut state, + serde_json::json!({ + "room_id": "!room:example.com", + "invites": [{ "email": "alice@example.com", "username": "guest-alice" }], + }), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(title, "Guest invites are disabled"); + } + + /// The room name and the inviter's name are stripped of control characters + /// and truncated before they reach the job payload. The inviter's Matrix ID + /// is kept whole, or left out if it isn't valid. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_names(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool.clone()).await.unwrap(); + + let controls = "\u{0}\n\u{7f}\u{85}\u{202A}\u{202E}\u{2066}\u{2069}"; + let long_id = format!("@{}:example.com", "b".repeat(200)); + let inviters = [ + (long_id.clone(), serde_json::Value::from(long_id)), + ( + format!("@bob{controls}:example.com"), + serde_json::Value::Null, + ), + ]; + for (i, (inviter, expected)) in inviters.into_iter().enumerate() { + let room_id = format!("!room{i}:example.com"); + let (status, _) = invite( + &mut state, + serde_json::json!({ + "room_id": room_id, + "room_name": format!("{controls}Project{controls} X{}", "x".repeat(64 * 1024)), + "inviter": inviter, + "inviter_name": format!("B\u{202E}ob{}", "é".repeat(200)), + "invites": [{ "email": "alice@example.com", "username": "guest-alice" }], + }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED); + + let payloads = job_payloads(&pool).await; + let payload = payloads + .iter() + .find(|payload| payload["room_id"] == room_id) + .unwrap(); + assert_eq!(payload["room_name"], format!("Project X{}", "x".repeat(91))); + assert_eq!(payload["inviter"], expected, "{inviter:?}"); + assert_eq!(payload["inviter_name"], format!("Bob{}", "é".repeat(97))); + } + } + + /// A token revoked by the time its job runs gets no email. The tokens pin + /// an address that doesn't parse, so a send fails the job. + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_invite_revoked_before_sending(pool: PgPool) { + setup(); + let state = TestState::from_pool(pool.clone()).await.unwrap(); + let mut rng = state.rng(); + + let mut repo = state.repository().await.unwrap(); + for (room_id, revoke) in [("!a:example.com", true), ("!b:example.com", false)] { + let token = repo + .user_registration_token() + .add( + &mut rng, + &state.clock, + room_id.to_owned(), + Some(1), + None, + Some("guest-alice".to_owned()), + Some("not-an-email".to_owned()), + true, + None, + ) + .await + .unwrap(); + if revoke { + repo.user_registration_token() + .revoke(&state.clock, token.clone()) + .await + .unwrap(); + } + repo.queue_job() + .schedule_job( + &mut rng, + &state.clock, + SendGuestInviteEmailJob::new(token.id, room_id.to_owned(), None, None, None), + ) + .await + .unwrap(); + } + repo.save().await.unwrap(); + + state.run_jobs_in_queue().await; + assert_eq!(job_statuses(&pool).await, ["completed", "failed"]); + } +} diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index b3e4a1540..fae1ee4e6 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -14,13 +14,14 @@ use aide::axum::{ use axum::extract::{FromRef, FromRequestParts}; use mas_data_model::{AppVersion, BoxRng, SiteConfig}; use mas_matrix::HomeserverConnection; -use mas_policy::PolicyFactory; +use mas_policy::{Policy, PolicyFactory}; use mas_router::UrlBuilder; use super::call_context::CallContext; use crate::passwords::PasswordManager; mod compat_sessions; +mod guest_invites; mod oauth2_clients; mod oauth2_sessions; mod personal_sessions; @@ -45,6 +46,7 @@ where UrlBuilder: FromRef, BoxRng: FromRequestParts, CallContext: FromRequestParts, + Policy: FromRequestParts, { ApiRouter::::new() .api_route( @@ -140,6 +142,10 @@ where "/policy-data/{id}", get_with(self::policy_data::get, self::policy_data::get_doc), ) + .api_route( + "/invite-guests", + post_with(self::guest_invites::handler, self::guest_invites::doc), + ) .api_route( "/users", get_with(self::users::list, self::users::list_doc) diff --git a/crates/handlers/src/bin/api-schema.rs b/crates/handlers/src/bin/api-schema.rs index 20f56cd96..9c5bde6e7 100644 --- a/crates/handlers/src/bin/api-schema.rs +++ b/crates/handlers/src/bin/api-schema.rs @@ -54,6 +54,7 @@ impl_from_request_parts!(mas_storage::BoxRepository); impl_from_request_parts!(mas_data_model::BoxClock); impl_from_request_parts!(mas_data_model::BoxRng); impl_from_request_parts!(mas_handlers::BoundActivityTracker); +impl_from_request_parts!(mas_policy::Policy); impl_from_ref!(mas_router::UrlBuilder); impl_from_ref!(mas_templates::Templates); impl_from_ref!(Arc); diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 56214f06e..7000c54cd 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -72,6 +72,7 @@ mod cleanup_tests; mod client_ip; mod preferred_language; mod rate_limit; +mod room_id; mod session; #[cfg(test)] mod test_utils; diff --git a/crates/handlers/src/room_id.rs b/crates/handlers/src/room_id.rs new file mode 100644 index 000000000..586a53591 --- /dev/null +++ b/crates/handlers/src/room_id.rs @@ -0,0 +1,18 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +use ruma_common::RoomId; + +/// Whether `c` is a control character, including the bidirectional controls +/// which `char::is_control` doesn't cover +pub(crate) fn is_control_or_bidi(c: char) -> bool { + c.is_control() || matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}') +} + +/// Whether `room_id` is a room ID, without control or bidirectional +/// characters +pub(crate) fn is_valid_room_id(room_id: &str) -> bool { + <&RoomId>::try_from(room_id).is_ok() && !room_id.chars().any(is_control_or_bidi) +} diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index 83a0d3174..69b281b35 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -157,7 +157,9 @@ pub fn test_site_config() -> SiteConfig { session_limit: None, device_code_grant_enabled: true, device_code_user_code_auto_fill_enabled: true, + guest_invites_enabled: true, room_invites_client_room_url: Some("https://app.example.com/#/room/{room_id}".to_owned()), + guest_invite_lifetime: Duration::try_days(7).unwrap(), } } diff --git a/crates/handlers/src/views/open_room.rs b/crates/handlers/src/views/open_room.rs index 78a068b4b..dbdc45df1 100644 --- a/crates/handlers/src/views/open_room.rs +++ b/crates/handlers/src/views/open_room.rs @@ -90,11 +90,20 @@ mod tests { } /// The room ID is percent-encoded into the client URL, with a space - /// encoded as `%20` and not `+`, which the client wouldn't decode. + /// encoded as `%20` and not `+`, which the client wouldn't decode. The + /// client URL is used whether or not guest invites are enabled. #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] async fn test_open_room(pool: PgPool) { setup(); - let state = TestState::from_pool(pool).await.unwrap(); + let state = TestState::from_pool_with_site_config( + pool, + SiteConfig { + guest_invites_enabled: false, + ..test_site_config() + }, + ) + .await + .unwrap(); for (room_id, location) in [ ( diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index f216fb343..23fb95d90 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2022-2024 The Matrix.org Foundation C.I.C. // @@ -237,6 +238,13 @@ impl UrlBuilder { pub fn account_recovery_link(&self, ticket: String) -> Url { self.absolute_url_for(&crate::endpoints::AccountRecoveryFinish::new(ticket)) } + + /// Guest invite link, which registers with the token and then opens the + /// room + #[must_use] + pub fn guest_invite_link(&self, _room_id: String, token: String) -> Url { + self.absolute_url_for(&crate::endpoints::Register::default().with_token(token)) + } } #[cfg(test)] diff --git a/crates/storage/src/queue/tasks.rs b/crates/storage/src/queue/tasks.rs index 51845a43b..8b65849c4 100644 --- a/crates/storage/src/queue/tasks.rs +++ b/crates/storage/src/queue/tasks.rs @@ -651,3 +651,80 @@ pub struct CleanupInactiveUserSessionIpsJob; impl InsertableJob for CleanupInactiveUserSessionIpsJob { const QUEUE_NAME: &'static str = "cleanup-inactive-user-session-ips"; } + +/// Send a guest invite email for a registration token. +/// +/// The recipient's address and the invite link both come from the token, so +/// that the link in the email can't be sent to anyone but the address the +/// token is pinned to. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SendGuestInviteEmailJob { + registration_token_id: Ulid, + room_id: String, + room_name: Option, + inviter: Option, + inviter_name: Option, +} + +impl SendGuestInviteEmailJob { + /// Create a new job to send a guest invite email + /// + /// # Parameters + /// + /// * `registration_token_id` - The registration token minted for the + /// recipient + /// * `room_id` - The room to invite the recipient to + /// * `room_name` - The name of the room + /// * `inviter` - The Matrix ID of the user who sent the invite + /// * `inviter_name` - The display name of the user who sent the invite + #[must_use] + pub fn new( + registration_token_id: Ulid, + room_id: String, + room_name: Option, + inviter: Option, + inviter_name: Option, + ) -> Self { + Self { + registration_token_id, + room_id, + room_name, + inviter, + inviter_name, + } + } + + /// The registration token minted for the recipient + #[must_use] + pub fn registration_token_id(&self) -> Ulid { + self.registration_token_id + } + + /// The room the recipient is invited to + #[must_use] + pub fn room_id(&self) -> &str { + &self.room_id + } + + /// The name of the room + #[must_use] + pub fn room_name(&self) -> Option<&str> { + self.room_name.as_deref() + } + + /// The Matrix ID of the user who sent the invite + #[must_use] + pub fn inviter(&self) -> Option<&str> { + self.inviter.as_deref() + } + + /// The display name of the user who sent the invite + #[must_use] + pub fn inviter_name(&self) -> Option<&str> { + self.inviter_name.as_deref() + } +} + +impl InsertableJob for SendGuestInviteEmailJob { + const QUEUE_NAME: &'static str = "send-guest-invite-email"; +} diff --git a/crates/tasks/src/guest_invite.rs b/crates/tasks/src/guest_invite.rs new file mode 100644 index 000000000..a11d229c5 --- /dev/null +++ b/crates/tasks/src/guest_invite.rs @@ -0,0 +1,75 @@ +// Copyright 2026 Element Creations Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +// Please see LICENSE files in the repository root for full details. + +//! Sending guest invite emails + +use async_trait::async_trait; +use mas_email::{Address, Mailbox}; +use mas_i18n::locale; +use mas_storage::queue::SendGuestInviteEmailJob; +use mas_templates::{EmailGuestInviteContext, TemplateContext as _}; +use tracing::info; + +use crate::{ + State, + new_queue::{JobContext, JobError, RunnableJob}, +}; + +#[async_trait] +impl RunnableJob for SendGuestInviteEmailJob { + #[tracing::instrument( + name = "job.send_guest_invite_email", + fields( + room.id = self.room_id(), + user_registration_token.id = %self.registration_token_id(), + ), + skip_all, + )] + async fn run(&self, state: &State, _context: JobContext) -> Result<(), JobError> { + let mailer = state.mailer(); + let url_builder = state.url_builder(); + let mut repo = state.repository().await.map_err(JobError::retry)?; + + let token = repo + .user_registration_token() + .lookup(self.registration_token_id()) + .await + .map_err(JobError::retry)? + .ok_or_else(|| JobError::fail(anyhow::anyhow!("Registration token not found")))?; + repo.cancel().await.map_err(JobError::retry)?; + + if !token.is_valid(state.clock().now()) { + info!("Registration token is no longer valid, not sending the invite"); + return Ok(()); + } + + let email = token.email.ok_or_else(|| { + JobError::fail(anyhow::anyhow!( + "Registration token has no email address pinned to it" + )) + })?; + + // A malformed address will never parse, however many times we retry + let address: Address = email.parse().map_err(JobError::fail)?; + let mailbox = Mailbox::new(None, address); + + // Recipients have no account, so no language preference to look up + let context = EmailGuestInviteContext::new( + self.room_name().map(ToOwned::to_owned), + self.inviter().map(ToOwned::to_owned), + self.inviter_name().map(ToOwned::to_owned), + url_builder.guest_invite_link(self.room_id().to_owned(), token.token), + ) + .with_language(locale!("en").into()); + + info!("Sending guest invite email to {}", mailbox); + mailer + .send_guest_invite_email(mailbox, &context) + .await + .map_err(JobError::retry)?; + + Ok(()) + } +} diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index 7fe3c343e..ebb1c1002 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -23,6 +23,7 @@ pub use crate::new_queue::QueueWorker; mod cleanup; mod email; +mod guest_invite; mod matrix; mod new_queue; mod recovery; @@ -151,6 +152,7 @@ pub async fn init( .register_handler::() .register_handler::() .register_handler::() + .register_handler::() .register_handler::() .register_handler::() .register_handler::() diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 2260fc840..fd4f2b0cb 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -1263,6 +1263,82 @@ impl TemplateContext for EmailVerificationContext { } } +/// Context used by the `emails/guest_invite.{txt,html,subject}` templates +#[derive(Serialize)] +pub struct EmailGuestInviteContext { + room_name: Option, + inviter: Option, + inviter_name: Option, + invite_link: Url, +} + +impl EmailGuestInviteContext { + /// Constructs a context for the guest invite email + #[must_use] + pub fn new( + room_name: Option, + inviter: Option, + inviter_name: Option, + invite_link: Url, + ) -> Self { + Self { + room_name, + inviter, + inviter_name, + invite_link, + } + } +} + +impl TemplateContext for EmailGuestInviteContext { + fn sample( + _now: chrono::DateTime, + _rng: &mut R, + _locales: &[DataLocale], + ) -> BTreeMap + where + Self: Sized, + { + let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); + let invite_link = + url_builder.guest_invite_link("!abc:example.com".to_owned(), "abcdefghijkl".to_owned()); + + sample_list(vec![ + Self::new( + Some("Project X".to_owned()), + Some("@alice:example.com".to_owned()), + Some("Alice O'Brien & Co".to_owned()), + invite_link.clone(), + ), + Self::new( + None, + Some("@alice:example.com".to_owned()), + Some("Alice O'Brien & Co".to_owned()), + invite_link.clone(), + ), + Self::new( + Some("Project X".to_owned()), + None, + Some("Alice O'Brien & Co".to_owned()), + invite_link.clone(), + ), + Self::new( + None, + Some("@alice:example.com".to_owned()), + None, + invite_link.clone(), + ), + Self::new( + Some("Project X".to_owned()), + None, + None, + invite_link.clone(), + ), + Self::new(None, None, None, invite_link), + ]) + } +} + /// Fields of the email verification form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 0bb1b0c1b..fc6de4d20 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -40,10 +40,10 @@ pub use self::{ context::{ AccountInactiveContext, ApiDocContext, AppContext, CompatLoginPolicyViolationContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext, - DeviceLinkFormField, DeviceNameContext, EmailRecoveryContext, EmailVerificationContext, - EmptyContext, ErrorContext, FormPostContext, IndexContext, InviteContext, LoginContext, - LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext, - PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext, + DeviceLinkFormField, DeviceNameContext, EmailGuestInviteContext, EmailRecoveryContext, + EmailVerificationContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, + InviteContext, LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, + PostAuthContext, PostAuthContextInner, RecoveryExpiredContext, RecoveryFinishContext, RecoveryFinishFormField, RecoveryProgressContext, RecoveryStartContext, RecoveryStartFormField, RegisterContext, RegisterFormField, RegisterStepsDisplayNameContext, RegisterStepsDisplayNameFormField, @@ -425,6 +425,15 @@ register_templates! { /// Render the HTML error page pub fn render_error(ErrorContext) { "pages/error.html" } + /// Render the guest invite email (plain text variant) + pub fn render_email_guest_invite_txt(WithLanguage) { "emails/guest_invite.txt" } + + /// Render the guest invite email (HTML variant) + pub fn render_email_guest_invite_html(WithLanguage) { "emails/guest_invite.html" } + + /// Render the guest invite email subject + pub fn render_email_guest_invite_subject(WithLanguage) { "emails/guest_invite.subject" } + /// Render the email recovery email (plain text variant) pub fn render_email_recovery_txt(WithLanguage) { "emails/recovery.txt" } @@ -499,15 +508,14 @@ mod tests { use super::*; - #[tokio::test] - async fn check_builtin_templates() { - #[expect(clippy::disallowed_methods)] - let now = chrono::Utc::now(); - let rng = rand_chacha::ChaCha8Rng::from_seed([42; 32]); + fn test_url_builder() -> UrlBuilder { + UrlBuilder::new("https://example.com/".parse().unwrap(), None, None) + } + /// Load the built-in templates in strict mode, against either the real + /// vite manifest or the 'dummy' one used for reproducible renders + async fn load_templates(use_real_vite_manifest: bool) -> Templates { let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/"); - let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); - let branding = SiteBranding::new("example.com"); let features = SiteFeatures { password_login: true, password_registration: true, @@ -521,21 +529,27 @@ mod tests { let translations_path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../translations"); + Templates::load( + path, + test_url_builder(), + use_real_vite_manifest.then_some(vite_manifest_path), + translations_path, + SiteBranding::new("example.com"), + features, + true, + ) + .await + .unwrap() + } + + #[tokio::test] + async fn check_builtin_templates() { + #[expect(clippy::disallowed_methods)] + let now = chrono::Utc::now(); + let rng = rand_chacha::ChaCha8Rng::from_seed([42; 32]); + for use_real_vite_manifest in [true, false] { - let templates = Templates::load( - path.clone(), - url_builder.clone(), - // Check both renders against the real vite manifest and the 'dummy' vite manifest - // used for reproducible renders. - use_real_vite_manifest.then_some(vite_manifest_path.clone()), - translations_path.clone(), - branding.clone(), - features, - // Use strict mode in tests - true, - ) - .await - .unwrap(); + let templates = load_templates(use_real_vite_manifest).await; // Check the renders are deterministic, when given the same rng let render1 = templates.check_render(now, &rng).unwrap(); @@ -544,4 +558,45 @@ mod tests { assert_eq!(render1, render2); } } + + /// The headline escapes the room and the inviter once in HTML, and the + /// subject names neither + #[tokio::test] + async fn guest_invite_email() { + let templates = load_templates(false).await; + + let context = EmailGuestInviteContext::new( + Some("Q&A ".to_owned()), + Some("@alice:example.com".to_owned()), + Some("Alice O'Brien & Co".to_owned()), + test_url_builder().guest_invite_link("!abc:example.com".to_owned(), "token".to_owned()), + ) + .with_language(mas_i18n::locale!("en").into()); + + let html = templates.render_email_guest_invite_html(&context).unwrap(); + assert!( + html.contains( + "Alice O'Brien & Co (@alice:example.com) invited you to ask to join \ + Q&A <team> on example.com." + ), + "{html}" + ); + + let txt = templates.render_email_guest_invite_txt(&context).unwrap(); + assert!( + txt.contains( + "Alice O'Brien & Co (@alice:example.com) invited you to ask to join Q&A \ + on example.com." + ), + "{txt}" + ); + + let subject = templates + .render_email_guest_invite_subject(&context) + .unwrap(); + assert_eq!( + subject.trim(), + "You have been invited to a room on example.com" + ); + } } diff --git a/docs/api/spec.json b/docs/api/spec.json index 134cdba51..d8b962b89 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -2005,6 +2005,92 @@ } } }, + "/api/admin/v1/invite-guests": { + "post": { + "tags": [ + "guest-invite" + ], + "summary": "Invite a list of email addresses to a room as guests", + "description": "Mints a single-use, passwordless registration token for each guest, pinned to their address and username, and emails them a link to register with it and open the room. The links are only ever sent to the guests, so they are not in the response.", + "operationId": "inviteGuests", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteGuestsRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "The invite emails were scheduled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InviteGuestsResponse" + }, + "example": { + "scheduled": 2 + } + } + } + }, + "400": { + "description": "The request has an invalid room ID, email or username, no invites, a repeated username, or an invite the registration policy refuses", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Invite for \"alice@example.com\" with username \"guest-abcdef\" is not allowed: email: email domain is banned" + } + ] + } + } + } + }, + "404": { + "description": "Guest invites are disabled", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Guest invites are disabled" + } + ] + } + } + } + }, + "409": { + "description": "A username is already taken", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "Username \"alice\" is already taken" + } + ] + } + } + } + } + } + } + }, "/api/admin/v1/users": { "get": { "tags": [ @@ -6494,6 +6580,88 @@ "data" ] }, + "InviteGuestsRequest": { + "title": "JSON payload for the `POST /api/admin/v1/invite-guests` endpoint", + "type": "object", + "properties": { + "room_id": { + "description": "The room the guests are invited to", + "type": "string", + "example": "!room:example.com" + }, + "room_name": { + "description": "The name of the room, shown in the invite email", + "type": [ + "string", + "null" + ], + "example": "Project X" + }, + "inviter": { + "description": "The Matrix ID of the user sending the invites, shown in the invite\nemail. It is left out if it isn't a valid Matrix ID.", + "type": [ + "string", + "null" + ], + "example": "@bob:example.com" + }, + "inviter_name": { + "description": "The display name of the user sending the invites, shown in the invite\nemail", + "type": [ + "string", + "null" + ], + "example": "Bob" + }, + "invites": { + "description": "The guests to invite. An address given more than once, ignoring case,\nis invited once.", + "type": "array", + "items": { + "$ref": "#/components/schemas/GuestInvite" + } + } + }, + "required": [ + "room_id", + "invites" + ] + }, + "GuestInvite": { + "title": "A guest to invite to the room", + "type": "object", + "properties": { + "email": { + "description": "The address to send the invite to. The guest can only register with\nthis address.", + "type": "string", + "format": "email", + "example": "alice@example.com" + }, + "username": { + "description": "The username the guest registers with", + "type": "string", + "example": "guest-abcdef" + } + }, + "required": [ + "email", + "username" + ] + }, + "InviteGuestsResponse": { + "title": "Response to the `POST /api/admin/v1/invite-guests` endpoint", + "type": "object", + "properties": { + "scheduled": { + "description": "How many guests were scheduled an invite email, after dropping repeated\naddresses", + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "required": [ + "scheduled" + ] + }, "UserFilter": { "type": "object", "properties": { @@ -7921,6 +8089,10 @@ "name": "compat-session", "description": "Manage compatibility sessions from legacy clients" }, + { + "name": "guest-invite", + "description": "Invite guests to rooms by email" + }, { "name": "policy-data", "description": "Manage the dynamic policy data" diff --git a/docs/config.schema.json b/docs/config.schema.json index 878c01c15..f13e0c800 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -2908,12 +2908,22 @@ "description": "Configuration section for inviting email addresses to rooms", "type": "object", "properties": { + "enabled": { + "description": "Whether the `POST /api/admin/v1/invite-guests` endpoint is enabled.\nDefaults to `false`.", + "type": "boolean" + }, "client_room_url": { - "description": "The client URL to send invitees to once they are signed in, with\n`{room_id}` standing for the room ID, e.g.\n`https://app.element.io/#/room/{room_id}`.", + "description": "The client URL to send invitees to once they are signed in, with\n`{room_id}` standing for the room ID, e.g.\n`https://app.element.io/#/room/{room_id}`. Required when `enabled` is\nset.", "type": [ "string", "null" ] + }, + "invite_lifetime": { + "description": "How long an invite link stays valid, in seconds. Defaults to 7 days.", + "type": "integer", + "format": "uint32", + "minimum": 1 } } }, diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index e10251ef5..184e86909 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1042,16 +1042,28 @@ oauth: ## `room_invites` -Configuration section for inviting email addresses to rooms. +Configuration section for inviting email addresses to rooms, through the `POST /api/admin/v1/invite-guests` admin API endpoint. ```yaml room_invites: + # Whether the `POST /api/admin/v1/invite-guests` endpoint is enabled. + # + # Defaults to `false`. + enabled: false + # The client URL to send invitees to once they are signed in, with # `{room_id}` standing for the percent-encoded room ID. # - # MAS refuses to start if it isn't an absolute HTTP or HTTPS URL or doesn't - # contain `{room_id}`. + # Required when `enabled` is `true`. When set, it is used for invite links + # already sent, even with `enabled` set to `false`. MAS refuses to start if + # it isn't an absolute HTTP or HTTPS URL or doesn't contain `{room_id}`. #client_room_url: "https://app.element.io/#/room/{room_id}" + + # How long an invite link stays valid, in seconds. Must be positive. + # + # Defaults to 604800, 7 days: a starting point; shorten it where unused + # invite links are a concern. + invite_lifetime: 604800 ``` ## `experimental` diff --git a/templates/emails/guest_invite.html b/templates/emails/guest_invite.html new file mode 100644 index 000000000..bf5a43411 --- /dev/null +++ b/templates/emails/guest_invite.html @@ -0,0 +1,69 @@ +{# +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} +{%- if inviter_name and inviter -%} + {%- if room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_name_and_mxid", name=inviter_name, mxid=inviter, room=room_name, server_name=branding.server_name) -%} + {%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_name_and_mxid_unnamed_room", name=inviter_name, mxid=inviter, server_name=branding.server_name) -%} + {%- endif -%} +{%- elif inviter_name or inviter -%} + {%- if room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline_from", inviter=inviter_name or inviter, room=room_name, server_name=branding.server_name) -%} + {%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_unnamed_room", inviter=inviter_name or inviter, server_name=branding.server_name) -%} + {%- endif -%} +{%- elif room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline", room=room_name, server_name=branding.server_name) -%} +{%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_unnamed_room", server_name=branding.server_name) -%} +{%- endif -%} + + + + + + + + + + {{ headline }} + {{ _("mas.emails.guest_invite.approval") }}
+
+ {{ _("mas.emails.guest_invite.click_button") }}
+
+ {{ _("mas.emails.guest_invite.accept") }}
+

+ {{ _("mas.emails.guest_invite.fallback") }} {{ _("mas.emails.guest_invite.copy_link") }} +

+

+ {{ invite_link }} +

+ {{ _("mas.emails.guest_invite.you_can_ignore") }} + + diff --git a/templates/emails/guest_invite.subject b/templates/emails/guest_invite.subject new file mode 100644 index 000000000..fe59faee7 --- /dev/null +++ b/templates/emails/guest_invite.subject @@ -0,0 +1,10 @@ +{# +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} + +{{ _("mas.emails.guest_invite.subject", server_name=branding.server_name) }} diff --git a/templates/emails/guest_invite.txt b/templates/emails/guest_invite.txt new file mode 100644 index 000000000..64a9626c4 --- /dev/null +++ b/templates/emails/guest_invite.txt @@ -0,0 +1,33 @@ +{# +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +-#} + +{%- set _ = translator(lang) -%} +{%- if inviter_name and inviter -%} + {%- if room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_name_and_mxid", name=inviter_name, mxid=inviter, room=room_name, server_name=branding.server_name) -%} + {%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_name_and_mxid_unnamed_room", name=inviter_name, mxid=inviter, server_name=branding.server_name) -%} + {%- endif -%} +{%- elif inviter_name or inviter -%} + {%- if room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline_from", inviter=inviter_name or inviter, room=room_name, server_name=branding.server_name) -%} + {%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_from_unnamed_room", inviter=inviter_name or inviter, server_name=branding.server_name) -%} + {%- endif -%} +{%- elif room_name -%} + {%- set headline = _("mas.emails.guest_invite.headline", room=room_name, server_name=branding.server_name) -%} +{%- else -%} + {%- set headline = _("mas.emails.guest_invite.headline_unnamed_room", server_name=branding.server_name) -%} +{%- endif -%} + +{{ headline }} {{ _("mas.emails.guest_invite.approval") }} + +{{ _("mas.emails.guest_invite.copy_link") }} + + {{ invite_link }} + +{{ _("mas.emails.guest_invite.you_can_ignore") }} diff --git a/translations/en.json b/translations/en.json index bb4709cfa..e0ff5b11f 100644 --- a/translations/en.json +++ b/translations/en.json @@ -314,6 +314,67 @@ "context": "emails/verification.html:17:3-64, emails/verification.txt:17:3-64", "description": "Greeting at the top of emails sent to the user" }, + "guest_invite": { + "accept": "Accept the invitation", + "@accept": { + "context": "emails/guest_invite.html:60:9-44" + }, + "approval": "Someone in the room will approve your request.", + "@approval": { + "context": "emails/guest_invite.html:43:7-44, emails/guest_invite.txt:27:18-55" + }, + "click_button": "Click on the button below to accept the invitation:", + "@click_button": { + "context": "emails/guest_invite.html:45:7-48" + }, + "copy_link": "Copy the following link and paste it into a browser to accept the invitation:", + "@copy_link": { + "context": "emails/guest_invite.html:62:53-91, emails/guest_invite.txt:29:3-41" + }, + "fallback": "The button doesn't work for you?", + "@fallback": { + "context": "emails/guest_invite.html:62:9-46" + }, + "headline": "You have been invited to ask to join %(room)s on %(server_name)s.", + "@headline": { + "context": "emails/guest_invite.html:22:21-108, emails/guest_invite.txt:22:21-108", + "description": "Headline of the guest invite email, when it doesn't say who sent it" + }, + "headline_from": "%(inviter)s invited you to ask to join %(room)s on %(server_name)s.", + "@headline_from": { + "context": "emails/guest_invite.html:17:23-148, emails/guest_invite.txt:17:23-148", + "description": "Headline of the guest invite email, naming the sender by display name or Matrix ID" + }, + "headline_from_name_and_mxid": "%(name)s (%(mxid)s) invited you to ask to join %(room)s on %(server_name)s.", + "@headline_from_name_and_mxid": { + "context": "emails/guest_invite.html:11:23-162, emails/guest_invite.txt:11:23-162", + "description": "Headline of the guest invite email, naming the sender by display name and Matrix ID" + }, + "headline_from_name_and_mxid_unnamed_room": "%(name)s (%(mxid)s) invited you to ask to join a room on %(server_name)s.", + "@headline_from_name_and_mxid_unnamed_room": { + "context": "emails/guest_invite.html:13:23-159, emails/guest_invite.txt:13:23-159", + "description": "Headline of the guest invite email, naming the sender by display name and Matrix ID, when the room has no name" + }, + "headline_from_unnamed_room": "%(inviter)s invited you to ask to join a room on %(server_name)s.", + "@headline_from_unnamed_room": { + "context": "emails/guest_invite.html:19:23-145, emails/guest_invite.txt:19:23-145", + "description": "Headline of the guest invite email, naming the sender by display name or Matrix ID, when the room has no name" + }, + "headline_unnamed_room": "You have been invited to ask to join a room on %(server_name)s.", + "@headline_unnamed_room": { + "context": "emails/guest_invite.html:24:21-105, emails/guest_invite.txt:24:21-105", + "description": "Headline of the guest invite email, when it doesn't say who sent it or name the room" + }, + "subject": "You have been invited to a room on %(server_name)s", + "@subject": { + "context": "emails/guest_invite.subject:10:3-73", + "description": "The subject line of the guest invite email" + }, + "you_can_ignore": "If you weren't expecting this invitation, you can ignore this email.", + "@you_can_ignore": { + "context": "emails/guest_invite.html:67:7-50, emails/guest_invite.txt:33:3-46" + } + }, "recovery": { "click_button": "Click on the button below to create a new password:", "@click_button": {