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 <noreply@anthropic.com>
This commit is contained in:
Devon HudsonandClaude Opus 5.5 authored and Quentin Gliech committed 2026-10-13 11:32:19 +02:00
1 parent 9d161bc24a
commit 4efe5881de
10 files changed
+196 -3

No files matched your search

+1
View File
@@ -156,6 +156,7 @@ impl Options {
&config.account,
&config.captcha,
&config.oauth,
&config.guest_invites,
)?;
// Load and compile the templates
+5 -1
View File
@@ -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,
+2
View File
@@ -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
+5 -2
View File
@@ -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<SiteConfig, anyhow::Error> {
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(),
})
}
+129
View File
@@ -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<String>,
}
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<dyn std::error::Error + Send + Sync + 'static>> {
let metadata = figment.find_metadata(Self::PATH.unwrap());
let error = |message: &str| -> Box<dyn std::error::Error + Send + Sync + 'static> {
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<GuestInvitesConfig, String> {
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(())
});
}
}
+13
View File
@@ -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(())
+5
View File
@@ -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<String>,
}
+1
View File
@@ -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()),
}
}
+21
View File
@@ -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",
+14
View File
@@ -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.