1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-07 17:03:01 +03:00

Actually send emails for recovery

This commit is contained in:
Quentin Gliech
2024-06-24 17:20:22 +02:00
parent 4a60f5d32f
commit c156a3891e
17 changed files with 337 additions and 14 deletions

View File

@@ -170,9 +170,14 @@ impl Options {
let worker_name = Alphanumeric.sample_string(&mut rng, 10);
info!(worker_name, "Starting task worker");
let monitor =
mas_tasks::init(&worker_name, &pool, &mailer, homeserver_connection.clone())
.await?;
let monitor = mas_tasks::init(
&worker_name,
&pool,
&mailer,
homeserver_connection.clone(),
url_builder.clone(),
)
.await?;
// TODO: grab the handle
tokio::spawn(monitor.run());
}

View File

@@ -77,7 +77,7 @@ impl Options {
let worker_name = Alphanumeric.sample_string(&mut rng, 10);
info!(worker_name, "Starting task scheduler");
let monitor = mas_tasks::init(&worker_name, &pool, &mailer, conn).await?;
let monitor = mas_tasks::init(&worker_name, &pool, &mailer, conn, url_builder).await?;
span.exit();

View File

@@ -18,7 +18,7 @@ use lettre::{
message::{Mailbox, MessageBuilder, MultiPart},
AsyncTransport, Message,
};
use mas_templates::{EmailVerificationContext, Templates, WithLanguage};
use mas_templates::{EmailRecoveryContext, EmailVerificationContext, Templates, WithLanguage};
use thiserror::Error;
use crate::MailTransport;
@@ -85,6 +85,28 @@ impl Mailer {
Ok(message)
}
fn prepare_recovery_email(
&self,
to: Mailbox,
context: &WithLanguage<EmailRecoveryContext>,
) -> Result<Message, Error> {
let plain = self.templates.render_email_recovery_txt(context)?;
let html = self.templates.render_email_recovery_html(context)?;
let multipart = MultiPart::alternative_plain_html(plain, html);
let subject = self.templates.render_email_recovery_subject(context)?;
let message = self
.base_message()
.subject(subject.trim())
.to(to)
.multipart(multipart)?;
Ok(message)
}
/// Send the verification email to a user
///
/// # Errors
@@ -112,6 +134,32 @@ impl Mailer {
Ok(())
}
/// Send the recovery email to a user
///
/// # Errors
///
/// Will return `Err` if the email failed rendering or failed sending
#[tracing::instrument(
name = "email.recovery.send",
skip_all,
fields(
email.to = %to,
email.language = %context.language(),
user.id = %context.user().id,
user_recovery_session.id = %context.session().id,
),
err,
)]
pub async fn send_recovery_email(
&self,
to: Mailbox,
context: &WithLanguage<EmailRecoveryContext>,
) -> Result<(), Error> {
let message = self.prepare_recovery_email(to, context)?;
self.transport.send(message).await?;
Ok(())
}
/// Test the connetion to the mail server
///
/// # Errors

View File

@@ -808,6 +808,31 @@ impl Route for AccountRecoveryProgress {
}
}
/// `GET|POST /recover/complete?ticket=:ticket`
#[derive(Default, Serialize, Deserialize, Debug, Clone)]
pub struct AccountRecoveryFinish {
ticket: String,
}
impl AccountRecoveryFinish {
#[must_use]
pub fn new(ticket: String) -> Self {
Self { ticket }
}
}
impl Route for AccountRecoveryFinish {
type Query = AccountRecoveryFinish;
fn route() -> &'static str {
"/recover/complete"
}
fn query(&self) -> Option<&Self::Query> {
Some(self)
}
}
/// `GET /assets`
pub struct StaticAsset {
path: String,

View File

@@ -237,6 +237,12 @@ impl UrlBuilder {
pub fn account_management_uri(&self) -> Url {
self.absolute_url_for(&crate::endpoints::Account::default())
}
/// Account recovery link
#[must_use]
pub fn account_recovery_link(&self, ticket: String) -> Url {
self.absolute_url_for(&crate::endpoints::AccountRecoveryFinish::new(ticket))
}
}
#[cfg(test)]

View File

@@ -446,6 +446,7 @@ mod jobs {
///
/// * `user_recovery_session` - The user recovery session to send the
/// email for
/// * `language` - The locale to send the email in
#[must_use]
pub fn new(user_recovery_session: &UserRecoverySession) -> Self {
Self {

View File

@@ -38,6 +38,7 @@ mas-data-model.workspace = true
mas-email.workspace = true
mas-i18n.workspace = true
mas-matrix.workspace = true
mas-router.workspace = true
mas-storage.workspace = true
mas-storage-pg.workspace = true
mas-templates.workspace = true

View File

@@ -17,6 +17,7 @@ use std::sync::Arc;
use apalis_core::{executor::TokioExecutor, layers::extensions::Extension, monitor::Monitor};
use mas_email::Mailer;
use mas_matrix::HomeserverConnection;
use mas_router::UrlBuilder;
use mas_storage::{BoxClock, BoxRepository, Repository, SystemClock};
use mas_storage_pg::{DatabaseError, PgRepository};
use rand::SeedableRng;
@@ -39,6 +40,7 @@ struct State {
mailer: Mailer,
clock: SystemClock,
homeserver: Arc<dyn HomeserverConnection<Error = anyhow::Error>>,
url_builder: UrlBuilder,
}
impl State {
@@ -47,12 +49,14 @@ impl State {
clock: SystemClock,
mailer: Mailer,
homeserver: impl HomeserverConnection<Error = anyhow::Error> + 'static,
url_builder: UrlBuilder,
) -> Self {
Self {
pool,
mailer,
clock,
homeserver: Arc::new(homeserver),
url_builder,
}
}
@@ -90,6 +94,10 @@ impl State {
pub fn matrix_connection(&self) -> &dyn HomeserverConnection<Error = anyhow::Error> {
self.homeserver.as_ref()
}
pub fn url_builder(&self) -> &UrlBuilder {
&self.url_builder
}
}
trait JobContextExt {
@@ -140,12 +148,14 @@ pub async fn init(
pool: &Pool<Postgres>,
mailer: &Mailer,
homeserver: impl HomeserverConnection<Error = anyhow::Error> + 'static,
url_builder: UrlBuilder,
) -> Result<Monitor<TokioExecutor>, sqlx::Error> {
let state = State::new(
pool.clone(),
SystemClock::default(),
mailer.clone(),
homeserver,
url_builder,
);
let factory = PostgresStorageFactory::new(pool.clone());
let monitor = Monitor::new().executor(TokioExecutor::new());

View File

@@ -14,13 +14,16 @@
use anyhow::Context;
use apalis_core::{context::JobContext, executor::TokioExecutor, monitor::Monitor};
use mas_email::{Address, Mailbox};
use mas_i18n::DataLocale;
use mas_storage::{
job::{JobWithSpanContext, SendAccountRecoveryEmailsJob},
user::{UserEmailFilter, UserRecoveryRepository},
Pagination, RepositoryAccess,
};
use mas_templates::{EmailRecoveryContext, TemplateContext};
use rand::distributions::{Alphanumeric, DistString};
use tracing::info;
use tracing::{error, info};
use crate::{storage::PostgresStorageFactory, JobContextExt, State};
@@ -40,6 +43,8 @@ async fn send_account_recovery_email_job(
) -> Result<(), anyhow::Error> {
let state = ctx.state();
let clock = state.clock();
let mailer = state.mailer();
let url_builder = state.url_builder();
let mut rng = state.rng();
let mut repo = state.repository().await?;
@@ -58,6 +63,11 @@ async fn send_account_recovery_email_job(
let mut cursor = Pagination::first(50);
let lang: DataLocale = session
.locale
.parse()
.context("Invalid locale in database on recovery session")?;
loop {
let page = repo
.user_email()
@@ -72,13 +82,39 @@ async fn send_account_recovery_email_job(
for email in page.edges {
let ticket = Alphanumeric.sample_string(&mut rng, 32);
let _ticket = repo
let ticket = repo
.user_recovery()
.add_ticket(&mut rng, &clock, &session, &email, ticket)
.await?;
info!("Sending recovery email to {}", email.email);
// TODO
let user_email = repo
.user_email()
.lookup(email.id)
.await?
.context("User email not found")?;
let user = repo
.user()
.lookup(user_email.user_id)
.await?
.context("User not found")?;
let url = url_builder.account_recovery_link(ticket.ticket);
let address: Address = user_email.email.parse()?;
let mailbox = Mailbox::new(Some(user.username.clone()), address);
info!("Sending recovery email to {}", mailbox);
let context =
EmailRecoveryContext::new(user, session.clone(), url).with_language(lang.clone());
// XXX: we only log if the email fails to send, to avoid stopping the loop
if let Err(e) = mailer.send_recovery_email(mailbox, &context).await {
error!(
error = &e as &dyn std::error::Error,
"Failed to send recovery email"
);
}
cursor = cursor.after(email.id);
}

View File

@@ -29,7 +29,7 @@ use http::{Method, Uri, Version};
use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail,
UserEmailVerification,
UserEmailVerification, UserRecoverySession,
};
use mas_i18n::DataLocale;
use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder};
@@ -751,6 +751,61 @@ where {
}
}
/// Context used by the `emails/recovery.{txt,html,subject}` templates
#[derive(Serialize)]
pub struct EmailRecoveryContext {
user: User,
session: UserRecoverySession,
recovery_link: Url,
}
impl EmailRecoveryContext {
/// Constructs a context for the recovery email
#[must_use]
pub fn new(user: User, session: UserRecoverySession, recovery_link: Url) -> Self {
Self {
user,
session,
recovery_link,
}
}
/// Returns the user associated with the recovery email
#[must_use]
pub fn user(&self) -> &User {
&self.user
}
/// Returns the recovery session associated with the recovery email
#[must_use]
pub fn session(&self) -> &UserRecoverySession {
&self.session
}
}
impl TemplateContext for EmailRecoveryContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
User::samples(now, rng).into_iter().map(|user| {
let session = UserRecoverySession {
id: Ulid::from_datetime_with_source(now.into(), rng),
email: "hello@example.com".to_owned(),
user_agent: UserAgent::parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_8_4) AppleWebKit/536.30.1 (KHTML, like Gecko) Version/6.0.5 Safari/536.30.1".to_owned()),
ip_address: Some(IpAddr::from([192_u8, 0, 2, 1])),
locale: "en".to_owned(),
created_at: now,
consumed_at: None,
};
let link = "https://example.com/recovery/complete?ticket=abcdefghijklmnopqrstuvwxyz0123456789".parse().unwrap();
Self::new(user, session, link)
}).collect()
}
}
/// Context used by the `emails/verification.{txt,html,subject}` templates
#[derive(Serialize)]
pub struct EmailVerificationContext {

View File

@@ -56,7 +56,7 @@ pub struct WithCaptcha<T> {
impl<T> WithCaptcha<T> {
#[must_use]
pub fn new(captcha: Option<mas_data_model::CaptchaConfig>, inner: T) -> Self {
pub(crate) fn new(captcha: Option<mas_data_model::CaptchaConfig>, inner: T) -> Self {
Self {
captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))),
inner,

View File

@@ -22,7 +22,6 @@ use std::{collections::HashSet, sync::Arc};
use anyhow::Context as _;
use arc_swap::ArcSwap;
use camino::{Utf8Path, Utf8PathBuf};
use context::WithCaptcha;
use mas_i18n::Translator;
use mas_router::UrlBuilder;
use mas_spa::ViteManifest;
@@ -44,13 +43,13 @@ mod macros;
pub use self::{
context::{
AppContext, CompatSsoContext, ConsentContext, DeviceConsentContext, DeviceLinkContext,
DeviceLinkFormField, EmailAddContext, EmailVerificationContext,
DeviceLinkFormField, EmailAddContext, EmailRecoveryContext, EmailVerificationContext,
EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
LoginContext, LoginFormField, NotFoundContext, PolicyViolationContext, PostAuthContext,
PostAuthContextInner, ReauthContext, ReauthFormField, RecoveryStartContext,
RecoveryStartFormField, RegisterContext, RegisterFormField, SiteBranding, SiteConfigExt,
SiteFeatures, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister,
UpstreamRegisterFormField, UpstreamSuggestLink, WithCsrf, WithLanguage,
UpstreamRegisterFormField, UpstreamSuggestLink, WithCaptcha, WithCsrf, WithLanguage,
WithOptionalSession, WithSession,
},
forms::{FieldError, FormError, FormField, FormState, ToFormState},
@@ -360,6 +359,15 @@ register_templates! {
/// Render the HTML error page
pub fn render_error(ErrorContext) { "pages/error.html" }
/// Render the email recovery email (plain text variant)
pub fn render_email_recovery_txt(WithLanguage<EmailRecoveryContext>) { "emails/recovery.txt" }
/// Render the email recovery email (HTML text variant)
pub fn render_email_recovery_html(WithLanguage<EmailRecoveryContext>) { "emails/recovery.html" }
/// Render the email recovery subject
pub fn render_email_recovery_subject(WithLanguage<EmailRecoveryContext>) { "emails/recovery.subject" }
/// Render the email verification email (plain text variant)
pub fn render_email_verification_txt(WithLanguage<EmailVerificationContext>) { "emails/verification.txt" }