From 4efe5881dec977948dd0099b8ff9964a7ac0343c Mon Sep 17 00:00:00 2001 From: Devon Hudson Date: Sat, 3 Oct 2026 14:34:30 +0000 Subject: [PATCH] Add a `guest_invites` config section Holds the settings for inviting guests to rooms by email, starting with the client URL invitees are sent to once they are signed in. The value goes on `SiteConfig`, which the admin handlers and the views already take. `client_room_url` is a string rather than a URL, because parsing it as one would percent-encode the braces of a `{room_id}` placeholder in its path, which would then never be replaced. Whenever it is set, it must contain `{room_id}` and still parse as an absolute `http` or `https` URL with a host once the placeholder is filled in, so that `/open-room` never redirects relative to MAS. Co-Authored-By: Claude Opus 5.5 --- crates/cli/src/commands/server.rs | 1 + crates/cli/src/commands/templates.rs | 6 +- crates/cli/src/commands/worker.rs | 2 + crates/cli/src/util.rs | 7 +- crates/config/src/sections/guest_invites.rs | 129 ++++++++++++++++++++ crates/config/src/sections/mod.rs | 13 ++ crates/data-model/src/site_config.rs | 5 + crates/handlers/src/test_utils.rs | 1 + docs/config.schema.json | 21 ++++ docs/reference/configuration.md | 14 +++ 10 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 crates/config/src/sections/guest_invites.rs diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 5ff5f3cf8..294e11096 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -156,6 +156,7 @@ impl Options { &config.account, &config.captcha, &config.oauth, + &config.guest_invites, )?; // Load and compile the templates diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index 7e724e87c..5115ad45d 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -14,7 +14,8 @@ use clap::Parser; use figment::Figment; use mas_config::{ AccountConfig, BrandingConfig, CaptchaConfig, ConfigurationSection, ConfigurationSectionExt, - ExperimentalConfig, MatrixConfig, OAuthConfig, PasswordsConfig, TemplatesConfig, + ExperimentalConfig, GuestInvitesConfig, MatrixConfig, OAuthConfig, PasswordsConfig, + TemplatesConfig, }; use mas_data_model::{Clock, SystemClock}; use rand::SeedableRng; @@ -68,6 +69,8 @@ impl Options { .map_err(anyhow::Error::from_boxed)?; let oauth_config = OAuthConfig::extract_or_default(figment).map_err(anyhow::Error::from_boxed)?; + let guest_invites_config = GuestInvitesConfig::extract_or_default(figment) + .map_err(anyhow::Error::from_boxed)?; let now = if stabilise { DateTime::from_timestamp_secs(1_446_823_992).unwrap() @@ -92,6 +95,7 @@ impl Options { &account_config, &captcha_config, &oauth_config, + &guest_invites_config, )?; let templates = templates_from_config( &template_config, diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 5b2924552..2c3a45b4c 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -50,6 +51,7 @@ impl Options { &config.account, &config.captcha, &config.oauth, + &config.guest_invites, )?; // Load and compile the templates diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 5172ae1ed..63c92e28c 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -10,8 +10,8 @@ use std::{sync::Arc, time::Duration}; use anyhow::Context; use mas_config::{ AccountConfig, BrandingConfig, CaptchaConfig, DatabaseConfig, EmailConfig, EmailSmtpMode, - EmailTransportKind, ExperimentalConfig, HomeserverKind, MatrixConfig, OAuthConfig, - PasswordsConfig, PolicyConfig, TemplatesConfig, + EmailTransportKind, ExperimentalConfig, GuestInvitesConfig, HomeserverKind, MatrixConfig, + OAuthConfig, PasswordsConfig, PolicyConfig, TemplatesConfig, }; use mas_context::LogContext; use mas_data_model::{SessionExpirationConfig, SessionLimitConfig, SiteConfig}; @@ -205,6 +205,7 @@ pub fn captcha_config_from_config( })) } +#[expect(clippy::too_many_arguments)] pub fn site_config_from_config( branding_config: &BrandingConfig, matrix_config: &MatrixConfig, @@ -213,6 +214,7 @@ pub fn site_config_from_config( account_config: &AccountConfig, captcha_config: &CaptchaConfig, oauth_config: &OAuthConfig, + guest_invites_config: &GuestInvitesConfig, ) -> Result { let captcha = captcha_config_from_config(captcha_config)?; let session_expiration = experimental_config @@ -269,6 +271,7 @@ 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_client_room_url: guest_invites_config.client_room_url.clone(), }) } diff --git a/crates/config/src/sections/guest_invites.rs b/crates/config/src/sections/guest_invites.rs new file mode 100644 index 000000000..b47e21fb5 --- /dev/null +++ b/crates/config/src/sections/guest_invites.rs @@ -0,0 +1,129 @@ +// 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 schemars::JsonSchema; +use serde::{Deserialize, Serialize, de::Error}; +use url::Url; + +use crate::ConfigurationSection; + +/// Configuration section for inviting guests to rooms by email +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, Serialize)] +pub struct GuestInvitesConfig { + /// 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}`. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_room_url: Option, +} + +impl GuestInvitesConfig { + /// Returns true if the configuration is the default one + pub(crate) fn is_default(&self) -> bool { + self.client_room_url.is_none() + } +} + +impl ConfigurationSection for GuestInvitesConfig { + const PATH: Option<&'static str> = Some("guest_invites"); + + fn validate( + &self, + figment: &figment::Figment, + ) -> Result<(), Box> { + let metadata = figment.find_metadata(Self::PATH.unwrap()); + + let error = |message: &str| -> Box { + let mut error = figment::error::Error::custom(message); + error.metadata = metadata.cloned(); + error.profile = Some(figment::Profile::Default); + error.path = vec![Self::PATH.unwrap().to_owned(), "client_room_url".to_owned()]; + error.into() + }; + + match &self.client_room_url { + Some(url) if !url.contains("{room_id}") => { + return Err(error("must contain `{room_id}`")); + } + Some(url) + if !Url::parse(&url.replace("{room_id}", "room")).is_ok_and(|url| { + matches!(url.scheme(), "http" | "https") && url.has_host() + }) => + { + return Err(error("must be an absolute HTTP or HTTPS URL")); + } + _ => {} + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + // The closures passed to `Jail::expect_with` return `figment::Error`, which is + // large, and we can't change figment's API. + #![expect(clippy::result_large_err)] + + use figment::{ + Figment, Jail, + providers::{Format, Yaml}, + }; + + use super::*; + + fn load(jail: &mut Jail, yaml: &str) -> Result { + jail.create_file("config.yaml", yaml) + .map_err(|e| e.to_string())?; + let figment = Figment::new().merge(Yaml::file("config.yaml")); + GuestInvitesConfig::extract(&figment).map_err(|e| e.to_string()) + } + + #[test] + fn defaults() { + Jail::expect_with(|jail| { + let config = load(jail, "guest_invites: {}").unwrap(); + assert_eq!(config.client_room_url, None); + assert!(config.is_default()); + Ok(()) + }); + } + + #[test] + fn client_room_url_requires_room_id() { + Jail::expect_with(|jail| { + let error = load( + jail, + "guest_invites: { client_room_url: 'https://app.example.com/' }", + ) + .unwrap_err(); + assert!(error.contains("{room_id}"), "{error}"); + Ok(()) + }); + } + + #[test] + fn client_room_url_must_be_absolute() { + Jail::expect_with(|jail| { + for url in [ + "app.example.com/#/room/{room_id}", + "localhost:8080/#/room/{room_id}", + "ftp://app.example.com/#/room/{room_id}", + "https:/#/room/{room_id}", + ] { + let error = load( + jail, + &format!("guest_invites: {{ client_room_url: '{url}' }}"), + ) + .unwrap_err(); + assert!( + error.contains("absolute HTTP or HTTPS URL"), + "{url}: {error}" + ); + } + Ok(()) + }); + } +} diff --git a/crates/config/src/sections/mod.rs b/crates/config/src/sections/mod.rs index 2f233108e..0a66b41ce 100644 --- a/crates/config/src/sections/mod.rs +++ b/crates/config/src/sections/mod.rs @@ -18,6 +18,7 @@ mod clients; mod database; mod email; mod experimental; +mod guest_invites; mod http; mod matrix; mod oauth; @@ -37,6 +38,7 @@ pub use self::{ database::{DatabaseConfig, PgSslMode}, email::{EmailConfig, EmailSmtpMode, EmailTransportKind}, experimental::{ExperimentalConfig, SessionLimitConfig as ExperimentalSessionLimitConfig}, + guest_invites::GuestInvitesConfig, http::{ BindConfig as HttpBindConfig, HttpConfig, ListenerConfig as HttpListenerConfig, Resource as HttpResource, TlsConfig as HttpTlsConfig, UnixOrTcp, @@ -133,6 +135,10 @@ pub struct RootConfig { #[serde(default, skip_serializing_if = "OAuthConfig::is_default")] pub oauth: OAuthConfig, + /// Configuration section for inviting guests to rooms by email + #[serde(default, skip_serializing_if = "GuestInvitesConfig::is_default")] + pub guest_invites: GuestInvitesConfig, + /// Experimental configuration options #[serde(default, skip_serializing_if = "ExperimentalConfig::is_default")] pub experimental: ExperimentalConfig, @@ -159,6 +165,7 @@ impl ConfigurationSection for RootConfig { self.captcha.validate(figment)?; self.account.validate(figment)?; self.oauth.validate(figment)?; + self.guest_invites.validate(figment)?; self.experimental.validate(figment)?; Ok(()) @@ -192,6 +199,7 @@ impl RootConfig { captcha: CaptchaConfig::default(), account: AccountConfig::default(), oauth: OAuthConfig::default(), + guest_invites: GuestInvitesConfig::default(), experimental: ExperimentalConfig::default(), }) } @@ -216,6 +224,7 @@ impl RootConfig { captcha: CaptchaConfig::default(), account: AccountConfig::default(), oauth: OAuthConfig::default(), + guest_invites: GuestInvitesConfig::default(), experimental: ExperimentalConfig::default(), } } @@ -262,6 +271,9 @@ pub struct AppConfig { #[serde(default)] pub oauth: OAuthConfig, + #[serde(default)] + pub guest_invites: GuestInvitesConfig, + #[serde(default)] pub experimental: ExperimentalConfig, } @@ -284,6 +296,7 @@ impl ConfigurationSection for AppConfig { self.captcha.validate(figment)?; self.account.validate(figment)?; self.oauth.validate(figment)?; + self.guest_invites.validate(figment)?; self.experimental.validate(figment)?; Ok(()) diff --git a/crates/data-model/src/site_config.rs b/crates/data-model/src/site_config.rs index d57bc43c7..5e43b2599 100644 --- a/crates/data-model/src/site_config.rs +++ b/crates/data-model/src/site_config.rs @@ -1,3 +1,4 @@ +// Copyright 2025, 2026 Element Creations Ltd. // Copyright 2024, 2025 New Vector Ltd. // Copyright 2023, 2024 The Matrix.org Foundation C.I.C. // @@ -127,4 +128,8 @@ pub struct SiteConfig { /// `verification_uri_complete` and whether `/link` accepts a `code` /// query parameter to auto-fill the user code. pub device_code_user_code_auto_fill_enabled: bool, + + /// The client URL to send invitees to, with `{room_id}` standing for the + /// room ID. + pub guest_invites_client_room_url: Option, } diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index f3e125b50..f7bcf6a0c 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -157,6 +157,7 @@ pub fn test_site_config() -> SiteConfig { session_limit: None, device_code_grant_enabled: true, device_code_user_code_auto_fill_enabled: true, + guest_invites_client_room_url: Some("https://app.example.com/#/room/{room_id}".to_owned()), } } diff --git a/docs/config.schema.json b/docs/config.schema.json index cbe8f67c8..157fc954c 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -213,6 +213,14 @@ } ] }, + "guest_invites": { + "description": "Configuration section for inviting guests to rooms by email", + "allOf": [ + { + "$ref": "#/definitions/GuestInvitesConfig" + } + ] + }, "experimental": { "description": "Experimental configuration options", "allOf": [ @@ -2884,6 +2892,19 @@ } } }, + "GuestInvitesConfig": { + "description": "Configuration section for inviting guests to rooms by email", + "type": "object", + "properties": { + "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}`.", + "type": [ + "string", + "null" + ] + } + } + }, "ExperimentalConfig": { "description": "Configuration sections for experimental options\n\nDo not change these options unless you know what you are doing.", "type": "object", diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index e9f07ae34..733f56e55 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1032,6 +1032,20 @@ oauth: device_code_user_code_auto_fill_enabled: false ``` +## `guest_invites` + +Configuration section for inviting guests to rooms by email. + +```yaml +guest_invites: + # 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}`. + #client_room_url: "https://app.element.io/#/room/{room_id}" +``` + ## `experimental` Settings that may change or be removed in future versions.