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
+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) => {