feat: Add admin commands for managing users' email addresses

This commit is contained in:
Ginger
2026-03-22 11:46:26 -04:00
committed by Ellis Git
parent 7e79a544cf
commit 955da3a74f
16 changed files with 281 additions and 48 deletions
Generated
+2
View File
@@ -982,6 +982,7 @@ dependencies = [
"conduwuit_service",
"const-str",
"futures",
"lettre",
"log",
"ruma",
"serde-saphyr",
@@ -1011,6 +1012,7 @@ dependencies = [
"hyper",
"ipaddress",
"itertools 0.14.0",
"lettre",
"log",
"rand 0.10.0",
"reqwest",
+2
View File
@@ -2050,6 +2050,8 @@
#
# For most modern mail servers, format the URI like this:
# `smtps://username:password@hostname:port`
# Note that you will need to URL-encode the username and password. If your username _is_
# your email address, you will need to replace the `@` with `%40`.
#
# For a guide on the accepted URI syntax, consult Lettre's documentation:
# https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
+1
View File
@@ -80,6 +80,7 @@ conduwuit-macros.workspace = true
conduwuit-service.workspace = true
const-str.workspace = true
futures.workspace = true
lettre.workspace = true
log.workspace = true
ruma.workspace = true
serde_json.workspace = true
+29
View File
@@ -19,6 +19,7 @@ use conduwuit::{
warn,
};
use futures::{FutureExt, StreamExt, TryStreamExt};
use lettre::message::Mailbox;
use ruma::{
CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedEventId, OwnedRoomId,
OwnedRoomOrAliasId, OwnedServerName, RoomId, RoomVersionId,
@@ -876,3 +877,31 @@ pub(super) async fn trim_memory(&self) -> Result {
writeln!(self, "done").await
}
#[admin_command]
pub(super) async fn send_test_email(&self) -> Result {
self.bail_restricted()?;
let mailer = self.services.mailer.expect_mailer()?;
let Some(sender) = self.sender else {
return Err!("No sender user provided in context");
};
let Some(email) = self
.services
.threepid
.get_email_for_localpart(sender.localpart())
.await
else {
return Err!("{} has no associated email address", sender);
};
mailer
.send(Mailbox::new(None, email.clone()), service::mailer::messages::Test)
.await?;
self.write_str(&format!("Test email successfully sent to {email}"))
.await?;
Ok(())
}
+3
View File
@@ -225,6 +225,9 @@ pub enum DebugCommand {
level: Option<i32>,
},
/// Send a test email to the invoking admin's email address
SendTestEmail,
/// Developer test stubs
#[command(subcommand)]
#[allow(non_snake_case)]
+104
View File
@@ -11,6 +11,7 @@ use conduwuit::{
warn,
};
use futures::{FutureExt, StreamExt};
use lettre::Address;
use ruma::{
OwnedEventId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId, UserId,
events::{
@@ -1094,3 +1095,106 @@ pub(super) async fn enable_login(&self, user_id: String) -> Result {
self.write_str(&format!("{user_id} can now log in.")).await
}
#[admin_command]
pub(super) async fn get_email(&self, user_id: String) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
match self
.services
.threepid
.get_email_for_localpart(user_id.localpart())
.await
{
| Some(email) =>
self.write_str(&format!("{user_id} has the associated email address {email}."))
.await,
| None =>
self.write_str(&format!("{user_id} has no associated email address."))
.await,
}
}
#[admin_command]
pub(super) async fn get_user_by_email(&self, email: String) -> Result {
self.bail_restricted()?;
let Ok(email) = Address::try_from(email) else {
return Err!("Invalid email address");
};
match self.services.threepid.get_localpart_for_email(&email).await {
| Some(localpart) => {
let user_id = OwnedUserId::parse(format!(
"@{localpart}:{}",
self.services.globals.server_name()
))
.unwrap();
self.write_str(&format!("{email} belongs to {user_id}."))
.await
},
| None =>
self.write_str(&format!("No user has {email} as their email address."))
.await,
}
}
#[admin_command]
pub(super) async fn change_email(&self, user_id: String, email: Option<String>) -> Result {
self.bail_restricted()?;
let user_id = parse_local_user_id(self.services, &user_id)?;
let Ok(new_email) = email.map(Address::try_from).transpose() else {
return Err!("Invalid email address");
};
if self.services.mailer.mailer().is_none() {
warn!("SMTP has not been configured on this server, emails cannot be sent.");
}
let current_email = self
.services
.threepid
.get_email_for_localpart(user_id.localpart())
.await;
match (current_email, new_email) {
| (None, None) =>
self.write_str(&format!(
"{user_id} already had no associated email. No changes have been made."
))
.await,
| (current_email, Some(new_email)) => {
self.services
.threepid
.associate_localpart_email(user_id.localpart(), &new_email)
.await?;
if let Some(current_email) = current_email {
self.write_str(&format!(
"The associated email of {user_id} has been changed from {current_email} to \
{new_email}."
))
.await
} else {
self.write_str(&format!(
"{user_id} has been associated with the email {new_email}."
))
.await
}
},
| (Some(current_email), None) => {
self.services
.threepid
.disassociate_localpart_email(user_id.localpart())
.await;
self.write_str(&format!(
"The associated email of {user_id} has been removed (it was {current_email})."
))
.await
},
}
}
+18
View File
@@ -35,6 +35,24 @@ pub enum UserCommand {
username: String,
},
/// Get a user's associated email address.
GetEmail {
user_id: String,
},
/// Get the user with the given email address.
GetUserByEmail {
email: String,
},
/// Update or remove a user's email address.
///
/// If `email` is not supplied, the user's existing address wil be removed.
ChangeEmail {
user_id: String,
email: Option<String>,
},
/// Deactivate a user
///
/// User will be removed from all rooms by default.
+11 -5
View File
@@ -168,19 +168,25 @@ pub(crate) async fn register_route(
let password = if is_guest { None } else { body.password.as_deref() };
// Create user
services.users.create(&user_id, password, None).await?;
// If the user registered with an email, associate it with their account
// If the user registered with an email, associate it with their account.
// Do this _before_ creating the user to make sure that, if their email is
// already in use, we don't make them an account.
//
// Note that this should only rarely cause a bailout because email uniqueness is
// also checked by /requestToken.
#[allow(clippy::collapsible_if)]
if let Some(identity) = identity {
if let Some(email) = identity.email {
services
.threepid
.associate_localpart_email(user_id.localpart(), email);
.associate_localpart_email(user_id.localpart(), &email)
.await?;
}
}
// Create user
services.users.create(&user_id, password, None).await?;
// Set an initial display name
let mut displayname = user_id.localpart().to_owned();
+3
View File
@@ -2461,6 +2461,9 @@ pub struct SmtpConfig {
///
/// For most modern mail servers, format the URI like this:
/// `smtps://username:password@hostname:port`
/// Note that you will need to URL-encode the username and password. If your
/// username _is_ your email address, you will need to replace the `@` with
/// `%40`.
///
/// For a guide on the accepted URI syntax, consult Lettre's documentation:
/// https://docs.rs/lettre/latest/lettre/transport/smtp/struct.AsyncSmtpTransport.html#method.from_url
+20
View File
@@ -49,10 +49,18 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "bannedroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "clientsecret_validationsessionid",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "disabledroomids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "email_localpart",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "eventid_outlierpdu",
cache_disp: CacheDisp::SharedWith("pduid_pdu"),
@@ -100,6 +108,10 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "lazyloadedids",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "localpart_email",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "mediaid_file",
..descriptor::RANDOM_SMALL
@@ -458,4 +470,12 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "validationsessionid_session",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "validationsessionid_token",
..descriptor::RANDOM_SMALL
},
];
+7 -7
View File
@@ -8,8 +8,8 @@ pub trait MessageTemplate: Template {
#[derive(Template)]
#[template(path = "mail/change_email.txt.j2")]
pub struct ChangeEmail<'a> {
user_id: &'a UserId,
verification_link: &'a str,
pub user_id: &'a UserId,
pub verification_link: String,
}
impl MessageTemplate for ChangeEmail<'_> {
@@ -19,8 +19,8 @@ impl MessageTemplate for ChangeEmail<'_> {
#[derive(Template)]
#[template(path = "mail/new_account.txt.j2")]
pub struct NewAccount<'a> {
server_name: &'a str,
verification_link: &'a str,
pub server_name: &'a str,
pub verification_link: String,
}
impl MessageTemplate for NewAccount<'_> {
@@ -30,9 +30,9 @@ impl MessageTemplate for NewAccount<'_> {
#[derive(Template)]
#[template(path = "mail/password_reset.txt.j2")]
pub struct PasswordReset<'a> {
display_name: &'a str,
user_id: &'a UserId,
verification_link: &'a str,
pub display_name: Option<&'a str>,
pub user_id: &'a UserId,
pub verification_link: String,
}
impl MessageTemplate for PasswordReset<'_> {
+7 -1
View File
@@ -3,7 +3,7 @@ use std::sync::Arc;
use conduwuit::{Err, Result, err, info};
use lettre::{
AsyncSmtpTransport, AsyncTransport, Tokio1Executor,
message::{Mailbox, MessageBuilder},
message::{Mailbox, MessageBuilder, header::ContentType},
};
use crate::{Args, mailer::messages::MessageTemplate};
@@ -65,6 +65,11 @@ impl Service {
.as_ref()
.map(|(sender, transport)| Mailer { sender, transport })
}
pub fn expect_mailer(&self) -> Result<Mailer<'_>> {
self.mailer()
.ok_or_else(|| err!("SMTP is not configured on this server"))
}
}
pub struct Mailer<'a> {
@@ -89,6 +94,7 @@ impl Mailer<'_> {
.to(recipient)
.subject(subject)
.date_now()
.header(ContentType::TEXT_PLAIN)
.body(body)
.expect("should have been able to construct message");
@@ -1,7 +1,11 @@
{% extends "_base.txt.j2" %}
{% block content -%}
{%- if let Some(display_name) = display_name -%}
Hello {{ display_name }} ({{ user_id }}),
{%- else -%}
Hello {{ user_id }},
{%- endif %}
Somebody, probably you, tried to reset your Matrix account's password.
If you requested for your password to be reset, click this link to proceed:
+9 -8
View File
@@ -6,6 +6,7 @@ use std::{
use conduwuit::utils;
use database::{Database, Deserialized, Map};
use lettre::Address;
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
use serde::{Deserialize, Serialize};
pub(super) struct Data {
@@ -20,11 +21,11 @@ pub(super) struct Data {
#[derive(Debug, Serialize, Deserialize)]
pub(crate) struct ValidationSession {
/// The session's ID
pub session_id: String,
pub session_id: OwnedSessionId,
/// The email address which is being validated
pub email: Address,
/// The client's supplied client secret
pub client_secret: String,
pub client_secret: OwnedClientSecret,
/// Whether the email address has been validated successfully yet
pub(super) has_been_validated: bool,
}
@@ -62,9 +63,9 @@ impl PartialEq<str> for ValidationToken {
impl Data {
pub(super) fn new(db: &Arc<Database>) -> Self {
Self {
clientsecret_sessionid: db["clientsecret_sessionid"].clone(),
sessionid_session: db["sessionid_session"].clone(),
sessionid_token: db["sessionid_token"].clone(),
clientsecret_sessionid: db["clientsecret_validationsessionid"].clone(),
sessionid_session: db["validationsessionid_session"].clone(),
sessionid_token: db["validationsessionid_token"].clone(),
localpart_email: db["localpart_email"].clone(),
email_localpart: db["email_localpart"].clone(),
}
@@ -74,8 +75,8 @@ impl Data {
pub(super) fn create_session(
&self,
email: Address,
session_id: String,
client_secret: String,
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
token: ValidationToken,
) {
let session = ValidationSession {
@@ -103,7 +104,7 @@ impl Data {
/// Get a validation session by client secret.
pub(super) async fn get_session_by_secret(
&self,
client_secret: &str,
client_secret: &ClientSecret,
) -> Option<ValidationSession> {
let session_id: String = self
.clientsecret_sessionid
+60 -26
View File
@@ -10,15 +10,16 @@ use crate::{
};
mod data;
use conduwuit::{Err, Result, utils};
use conduwuit::{Err, Result, result::FlatOk, utils};
use data::{Data, ValidationToken};
use database::Deserialized;
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
pub struct Service {
db: Data,
services: Services,
send_attempts: Mutex<HashMap<(String, Address), usize>>,
send_attempts: Mutex<HashMap<(OwnedClientSecret, Address), usize>>,
}
struct Services {
@@ -45,20 +46,23 @@ impl Service {
const RANDOM_SID_LENGTH: usize = 16;
const VALIDATION_URL_PATH: &str = "/_continuwuity/3pid/email/validate";
#[must_use]
pub fn generate_session_id() -> OwnedSessionId {
OwnedSessionId::parse(utils::random_string(Self::RANDOM_SID_LENGTH)).unwrap()
}
/// Send a validation message to an email address.
///
/// Returns the validation session ID on success.
#[allow(clippy::impl_trait_in_params)]
pub async fn send_validation_email<Template: MessageTemplate>(
&self,
recipient: Address,
recipient: Mailbox,
prepare_body: impl FnOnce(String) -> Template,
client_secret: &str,
client_secret: &ClientSecret,
send_attempt: usize,
) -> Result<String> {
let Some(mailer) = self.services.mailer.mailer() else {
return Err!("SMTP is not configured");
};
) -> Result<OwnedSessionId> {
let mailer = self.services.mailer.expect_mailer()?;
let (session_id, ValidationToken { token, .. }) =
match self.db.get_session_by_secret(client_secret).await {
@@ -102,11 +106,11 @@ impl Service {
},
// If no session exists, create a new one.
| None => {
let session_id = utils::random_string(Self::RANDOM_SID_LENGTH);
let session_id = Self::generate_session_id();
let token = ValidationToken::new_random();
self.db.create_session(
recipient.clone(),
recipient.email.clone(),
session_id.clone(),
client_secret.to_owned(),
token.clone(),
@@ -125,11 +129,9 @@ impl Service {
validation_url
.query_pairs_mut()
.append_pair("session_id", &session_id)
.append_pair("client_secret", client_secret)
.append_pair("session_id", session_id.as_ref())
.append_pair("token", &token);
let recipient = Mailbox::new(None, recipient);
let message = prepare_body(validation_url.to_string());
mailer.send(recipient, message).await?;
@@ -137,10 +139,10 @@ impl Service {
Ok(session_id)
}
/// Attempt to mark a validation session as valid using a validation token.
pub async fn try_validate_session(
&self,
session_id: &str,
client_secret: &str,
supplied_token: &str,
) -> Result<(), Cow<'static, str>> {
let Some(session) = self.db.get_session(session_id).await else {
@@ -151,15 +153,12 @@ impl Service {
return Ok(());
}
if session.client_secret != client_secret {
return Err("Invalid client secret for session".into());
}
let token = self
.db
.get_session_validation_token(&session)
.await
.expect("valid session should have a token");
if token != *supplied_token || !token.is_valid() {
return Err("Validation token is invalid or expired, please request a new one".into());
}
@@ -169,10 +168,12 @@ impl Service {
Ok(())
}
/// Consume a validated validation session, removing it from the database
/// and returning the newly validated email address.
pub async fn consume_valid_session(
&self,
session_id: &str,
client_secret: &str,
client_secret: &ClientSecret,
) -> Result<Address, Cow<'static, str>> {
let Some(session) = self.db.get_session(session_id).await else {
return Err("Validation session does not exist".into());
@@ -183,14 +184,38 @@ impl Service {
self.db.remove_session(session).await;
Ok(email)
} else {
Err("Validation failed. Did you use the link that was sent to you?".into())
Err("This email address has not been validated. Did you use the link that was sent \
to you?"
.into())
}
}
/// Associate a localpart with an email address.
pub fn associate_localpart_email(&self, localpart: &str, email: Address) {
self.db.localpart_email.raw_put(localpart, &email);
self.db.email_localpart.put_raw(email, localpart);
pub async fn associate_localpart_email(
&self,
localpart: &str,
email: &Address,
) -> Result<()> {
match self.get_localpart_for_email(email).await {
| Some(existing_localpart) if existing_localpart != localpart => {
// Another account is already using the supplied email
Err!(Request(ThreepidInUse("This email address is already in use.")))
},
| Some(_) => {
// The supplied localpart is already associated with the supplied email,
// no changes are necessary
Ok(())
},
| None => {
// The supplied email is not already in use
let email: &str = email.as_ref();
self.db.localpart_email.insert(localpart, email);
self.db.email_localpart.insert(email, localpart);
Ok(())
},
}
}
/// Given a localpart, remove its corresponding email address.
@@ -203,7 +228,9 @@ impl Service {
.await
.expect("localpart has no email associated");
self.db.localpart_email.remove(localpart);
self.db.email_localpart.del(&email);
self.db
.email_localpart
.remove(<Address as AsRef<str>>::as_ref(&email));
}
/// Get the email associated with a localpart, if one exists.
@@ -212,12 +239,19 @@ impl Service {
.localpart_email
.get(localpart)
.await
.deserialized()
.deserialized::<String>()
.ok()
.map(TryInto::try_into)
.flat_ok()
}
/// Get the localpart associated with an email, if one exists.
pub async fn get_localpart_for_email(&self, email: &Address) -> Option<String> {
self.db.email_localpart.qry(email).await.deserialized().ok()
self.db
.email_localpart
.get(<Address as AsRef<str>>::as_ref(email))
.await
.deserialized()
.ok()
}
}
+1 -1
View File
@@ -301,7 +301,7 @@ impl Service {
match self
.services
.threepid
.consume_valid_session(sid.as_str(), client_secret.as_str())
.consume_valid_session(sid.as_str(), client_secret)
.await
{
| Ok(email) => {