mirror of
https://github.com/element-hq/matrix-authentication-service.git
synced 2026-10-22 21:54:07 +00:00
Add a POST /api/admin/v1/invite-guests admin endpoint
`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
This commit is contained in:
1 parent
230c9ee84c
commit
1a5002e894
25 files changed
+1680
-43
No files matched your search
@@ -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(),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// 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(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// How long a guest invite link stays valid.
|
||||
pub guest_invite_lifetime: Duration,
|
||||
}
|
||||
@@ -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<EmailGuestInviteContext>,
|
||||
) -> Result<Message, Error> {
|
||||
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<EmailGuestInviteContext>,
|
||||
) -> 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
|
||||
|
||||
@@ -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<S>,
|
||||
BoxRng: FromRequestParts<S>,
|
||||
CallContext: FromRequestParts<S>,
|
||||
Policy: FromRequestParts<S>,
|
||||
Templates: FromRef<S>,
|
||||
UrlBuilder: FromRef<S>,
|
||||
Arc<PolicyFactory>: FromRef<S>,
|
||||
|
||||
@@ -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<ErrorResponse>")]
|
||||
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<String>,
|
||||
},
|
||||
|
||||
#[error("Username {0:?} is already taken")]
|
||||
UsernameTaken(String),
|
||||
|
||||
#[error(transparent)]
|
||||
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||
}
|
||||
|
||||
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<String>,
|
||||
|
||||
/// 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<String>,
|
||||
|
||||
/// The display name of the user sending the invites, shown in the invite
|
||||
/// email
|
||||
#[schemars(example = &"Bob")]
|
||||
inviter_name: Option<String>,
|
||||
|
||||
/// The guests to invite. An address given more than once, ignoring case,
|
||||
/// is invited once.
|
||||
invites: Vec<Invite>,
|
||||
}
|
||||
|
||||
/// # 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<Response>, _>(|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<BoxRng>,
|
||||
NoApi(mut policy): NoApi<Policy>,
|
||||
State(site_config): State<SiteConfig>,
|
||||
Json(params): Json<Request>,
|
||||
) -> Result<(StatusCode, Json<Response>), 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<String> = 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<UserRegistrationToken> {
|
||||
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<serde_json::Value> {
|
||||
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<String> {
|
||||
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::<serde_json::Value>(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::Value>(),
|
||||
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::Value>(),
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
@@ -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<S>,
|
||||
BoxRng: FromRequestParts<S>,
|
||||
CallContext: FromRequestParts<S>,
|
||||
Policy: FromRequestParts<S>,
|
||||
{
|
||||
ApiRouter::<S>::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)
|
||||
|
||||
@@ -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<dyn mas_matrix::HomeserverConnection>);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [
|
||||
(
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<String>,
|
||||
inviter: Option<String>,
|
||||
inviter_name: Option<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
inviter: Option<String>,
|
||||
inviter_name: Option<String>,
|
||||
) -> 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";
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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::<mas_storage::queue::ReactivateUserJob>()
|
||||
.register_handler::<mas_storage::queue::SendAccountRecoveryEmailsJob>()
|
||||
.register_handler::<mas_storage::queue::SendEmailAuthenticationCodeJob>()
|
||||
.register_handler::<mas_storage::queue::SendGuestInviteEmailJob>()
|
||||
.register_handler::<mas_storage::queue::SyncDevicesJob>()
|
||||
.register_handler::<mas_storage::queue::VerifyEmailJob>()
|
||||
.register_handler::<mas_storage::queue::ExpireInactiveSessionsJob>()
|
||||
|
||||
@@ -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<String>,
|
||||
inviter: Option<String>,
|
||||
inviter_name: Option<String>,
|
||||
invite_link: Url,
|
||||
}
|
||||
|
||||
impl EmailGuestInviteContext {
|
||||
/// Constructs a context for the guest invite email
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
room_name: Option<String>,
|
||||
inviter: Option<String>,
|
||||
inviter_name: Option<String>,
|
||||
invite_link: Url,
|
||||
) -> Self {
|
||||
Self {
|
||||
room_name,
|
||||
inviter,
|
||||
inviter_name,
|
||||
invite_link,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TemplateContext for EmailGuestInviteContext {
|
||||
fn sample<R: RngExt>(
|
||||
_now: chrono::DateTime<Utc>,
|
||||
_rng: &mut R,
|
||||
_locales: &[DataLocale],
|
||||
) -> BTreeMap<SampleIdentifier, Self>
|
||||
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")]
|
||||
|
||||
+80
-25
@@ -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<EmailGuestInviteContext>) { "emails/guest_invite.txt" }
|
||||
|
||||
/// Render the guest invite email (HTML variant)
|
||||
pub fn render_email_guest_invite_html(WithLanguage<EmailGuestInviteContext>) { "emails/guest_invite.html" }
|
||||
|
||||
/// Render the guest invite email subject
|
||||
pub fn render_email_guest_invite_subject(WithLanguage<EmailGuestInviteContext>) { "emails/guest_invite.subject" }
|
||||
|
||||
/// Render the email recovery email (plain text variant)
|
||||
pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "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 <team>".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 <team> \
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
+11
-1
@@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 -%}
|
||||
|
||||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" lang="{{ lang }}">
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<style type="text/css">
|
||||
a#button:hover { background-color: #3C4045!important; }
|
||||
a#button:active { background-color: #4C5158!important; }
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="
|
||||
color: black;
|
||||
background-color: white;
|
||||
font-family: Inter, system-ui, ui-sans-serif, sans-serif;
|
||||
">
|
||||
{{ headline }}
|
||||
{{ _("mas.emails.guest_invite.approval") }}<br />
|
||||
<br />
|
||||
{{ _("mas.emails.guest_invite.click_button") }}<br />
|
||||
<br />
|
||||
<a id="button" href="{{ invite_link }}" target="_blank" style="
|
||||
display: inline-block;
|
||||
transition: background-color 0.1s ease;
|
||||
font-size: 18px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #FFF;
|
||||
background-color: #1B1D22;
|
||||
padding: 16px 32px;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 32px;
|
||||
border-radius: 2rem;
|
||||
text-decoration: none;
|
||||
">{{ _("mas.emails.guest_invite.accept") }}</a><br />
|
||||
<p style="font-size: 14px; font-size: 0.875rem;">
|
||||
{{ _("mas.emails.guest_invite.fallback") }} {{ _("mas.emails.guest_invite.copy_link") }}
|
||||
</p>
|
||||
<p style="font-size: 14px; font-size: 0.875rem;">
|
||||
<a href="{{ invite_link }}" target="_blank">{{ invite_link }}</a>
|
||||
</p>
|
||||
{{ _("mas.emails.guest_invite.you_can_ignore") }}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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) }}
|
||||
@@ -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") }}
|
||||
@@ -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": {
|
||||
|
||||
Reference in new issue
Block a user