1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +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

1
Cargo.lock generated
View File

@@ -3534,6 +3534,7 @@ dependencies = [
"mas-email", "mas-email",
"mas-i18n", "mas-i18n",
"mas-matrix", "mas-matrix",
"mas-router",
"mas-storage", "mas-storage",
"mas-storage-pg", "mas-storage-pg",
"mas-templates", "mas-templates",

View File

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

View File

@@ -77,7 +77,7 @@ impl Options {
let worker_name = Alphanumeric.sample_string(&mut rng, 10); let worker_name = Alphanumeric.sample_string(&mut rng, 10);
info!(worker_name, "Starting task scheduler"); 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(); span.exit();

View File

@@ -18,7 +18,7 @@ use lettre::{
message::{Mailbox, MessageBuilder, MultiPart}, message::{Mailbox, MessageBuilder, MultiPart},
AsyncTransport, Message, AsyncTransport, Message,
}; };
use mas_templates::{EmailVerificationContext, Templates, WithLanguage}; use mas_templates::{EmailRecoveryContext, EmailVerificationContext, Templates, WithLanguage};
use thiserror::Error; use thiserror::Error;
use crate::MailTransport; use crate::MailTransport;
@@ -85,6 +85,28 @@ impl Mailer {
Ok(message) 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 /// Send the verification email to a user
/// ///
/// # Errors /// # Errors
@@ -112,6 +134,32 @@ impl Mailer {
Ok(()) 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 /// Test the connetion to the mail server
/// ///
/// # Errors /// # 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` /// `GET /assets`
pub struct StaticAsset { pub struct StaticAsset {
path: String, path: String,

View File

@@ -237,6 +237,12 @@ impl UrlBuilder {
pub fn account_management_uri(&self) -> Url { pub fn account_management_uri(&self) -> Url {
self.absolute_url_for(&crate::endpoints::Account::default()) 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)] #[cfg(test)]

View File

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

View File

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

View File

@@ -14,13 +14,16 @@
use anyhow::Context; use anyhow::Context;
use apalis_core::{context::JobContext, executor::TokioExecutor, monitor::Monitor}; use apalis_core::{context::JobContext, executor::TokioExecutor, monitor::Monitor};
use mas_email::{Address, Mailbox};
use mas_i18n::DataLocale;
use mas_storage::{ use mas_storage::{
job::{JobWithSpanContext, SendAccountRecoveryEmailsJob}, job::{JobWithSpanContext, SendAccountRecoveryEmailsJob},
user::{UserEmailFilter, UserRecoveryRepository}, user::{UserEmailFilter, UserRecoveryRepository},
Pagination, RepositoryAccess, Pagination, RepositoryAccess,
}; };
use mas_templates::{EmailRecoveryContext, TemplateContext};
use rand::distributions::{Alphanumeric, DistString}; use rand::distributions::{Alphanumeric, DistString};
use tracing::info; use tracing::{error, info};
use crate::{storage::PostgresStorageFactory, JobContextExt, State}; use crate::{storage::PostgresStorageFactory, JobContextExt, State};
@@ -40,6 +43,8 @@ async fn send_account_recovery_email_job(
) -> Result<(), anyhow::Error> { ) -> Result<(), anyhow::Error> {
let state = ctx.state(); let state = ctx.state();
let clock = state.clock(); let clock = state.clock();
let mailer = state.mailer();
let url_builder = state.url_builder();
let mut rng = state.rng(); let mut rng = state.rng();
let mut repo = state.repository().await?; let mut repo = state.repository().await?;
@@ -58,6 +63,11 @@ async fn send_account_recovery_email_job(
let mut cursor = Pagination::first(50); let mut cursor = Pagination::first(50);
let lang: DataLocale = session
.locale
.parse()
.context("Invalid locale in database on recovery session")?;
loop { loop {
let page = repo let page = repo
.user_email() .user_email()
@@ -72,13 +82,39 @@ async fn send_account_recovery_email_job(
for email in page.edges { for email in page.edges {
let ticket = Alphanumeric.sample_string(&mut rng, 32); let ticket = Alphanumeric.sample_string(&mut rng, 32);
let _ticket = repo let ticket = repo
.user_recovery() .user_recovery()
.add_ticket(&mut rng, &clock, &session, &email, ticket) .add_ticket(&mut rng, &clock, &session, &email, ticket)
.await?; .await?;
info!("Sending recovery email to {}", email.email); let user_email = repo
// TODO .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); cursor = cursor.after(email.id);
} }

View File

@@ -29,7 +29,7 @@ use http::{Method, Uri, Version};
use mas_data_model::{ use mas_data_model::{
AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState, AuthorizationGrant, BrowserSession, Client, CompatSsoLogin, CompatSsoLoginState,
DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail, DeviceCodeGrant, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserAgent, UserEmail,
UserEmailVerification, UserEmailVerification, UserRecoverySession,
}; };
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_router::{Account, GraphQL, PostAuthAction, UrlBuilder}; 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 /// Context used by the `emails/verification.{txt,html,subject}` templates
#[derive(Serialize)] #[derive(Serialize)]
pub struct EmailVerificationContext { pub struct EmailVerificationContext {

View File

@@ -56,7 +56,7 @@ pub struct WithCaptcha<T> {
impl<T> WithCaptcha<T> { impl<T> WithCaptcha<T> {
#[must_use] #[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 { Self {
captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))), captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))),
inner, inner,

View File

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

View File

@@ -0,0 +1,55 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-#}
{%- set _ = translator(lang) -%}
<!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;
">
{{ _("mas.emails.recovery.headline", server_name=branding.server_name) }}<br />
<br />
{{ _("mas.emails.recovery.click_button") }}<br />
<br />
<a id="button" href="{{ recovery_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.recovery.create_new_password") }}</a><br />
<br />
{{ _("mas.emails.recovery.you_can_ignore") }}
</body>
</html>

View File

@@ -0,0 +1,22 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-#}
{%- set _ = translator(lang) -%}
{%- set mxid -%}
@{{ user.username }}:{{ branding.server_name }}
{%- endset -%}
{{ _("mas.emails.recovery.subject", mxid=mxid) }}

View File

@@ -0,0 +1,24 @@
{#
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-#}
{%- set _ = translator(lang) -%}
{{ _("mas.emails.recovery.headline", server_name=branding.server_name) }}
{{ _("mas.emails.recovery.copy_link") }}
{{ recovery_link }}
{{ _("mas.emails.recovery.you_can_ignore") }}

View File

@@ -221,6 +221,32 @@
"context": "emails/verification.html:19:3-51, emails/verification.txt:19:3-51", "context": "emails/verification.html:19:3-51, emails/verification.txt:19:3-51",
"description": "Greeting at the top of emails sent to the user" "description": "Greeting at the top of emails sent to the user"
}, },
"recovery": {
"click_button": "Click on the button below to create a new password:",
"@click_button": {
"context": "emails/recovery.html:36:7-44"
},
"copy_link": "Copy the following link and paste it into a browser to create a new password:",
"@copy_link": {
"context": "emails/recovery.txt:20:3-37"
},
"create_new_password": "Create new password",
"@create_new_password": {
"context": "emails/recovery.html:51:9-53"
},
"headline": "You requested a password reset for your %(server_name)s account.",
"@headline": {
"context": "emails/recovery.html:34:7-74, emails/recovery.txt:18:3-70"
},
"subject": "Reset your account password (%(mxid)s)",
"@subject": {
"context": "emails/recovery.subject:22:3-46"
},
"you_can_ignore": "If you didn't ask for a new password, you can ignore this email. Your current password will continue to work.",
"@you_can_ignore": {
"context": "emails/recovery.html:53:7-46, emails/recovery.txt:24:3-42"
}
},
"verify": { "verify": {
"body_html": "Your verification code to confirm this email address is: <strong>%(code)s</strong>", "body_html": "Your verification code to confirm this email address is: <strong>%(code)s</strong>",
"@body_html": { "@body_html": {