diff --git a/Cargo.lock b/Cargo.lock index 6b21d74c..7c70a89b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3534,6 +3534,7 @@ dependencies = [ "mas-email", "mas-i18n", "mas-matrix", + "mas-router", "mas-storage", "mas-storage-pg", "mas-templates", diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index 108278ee..b2bcd286 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -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()); } diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index afaeecbf..3f78b053 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -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(); diff --git a/crates/email/src/mailer.rs b/crates/email/src/mailer.rs index 8e561678..322c7fd8 100644 --- a/crates/email/src/mailer.rs +++ b/crates/email/src/mailer.rs @@ -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, + ) -> Result { + 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, + ) -> Result<(), Error> { + let message = self.prepare_recovery_email(to, context)?; + self.transport.send(message).await?; + Ok(()) + } + /// Test the connetion to the mail server /// /// # Errors diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index aa114898..450a92c5 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -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, diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index a0a3daaf..016b7a28 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -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)] diff --git a/crates/storage/src/job.rs b/crates/storage/src/job.rs index e7c8257f..599cdca0 100644 --- a/crates/storage/src/job.rs +++ b/crates/storage/src/job.rs @@ -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 { diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 3d51499d..c8ebf72f 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -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 diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs index 30a84f7c..f84e321e 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/tasks/src/lib.rs @@ -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>, + url_builder: UrlBuilder, } impl State { @@ -47,12 +49,14 @@ impl State { clock: SystemClock, mailer: Mailer, homeserver: impl HomeserverConnection + '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 { 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, mailer: &Mailer, homeserver: impl HomeserverConnection + 'static, + url_builder: UrlBuilder, ) -> Result, 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()); diff --git a/crates/tasks/src/recovery.rs b/crates/tasks/src/recovery.rs index 2e3a8358..df002a82 100644 --- a/crates/tasks/src/recovery.rs +++ b/crates/tasks/src/recovery.rs @@ -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); } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 0aa33c10..ec421b52 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -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, rng: &mut impl Rng) -> Vec + 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 { diff --git a/crates/templates/src/context/captcha.rs b/crates/templates/src/context/captcha.rs index 3e9e0924..48e71f8e 100644 --- a/crates/templates/src/context/captcha.rs +++ b/crates/templates/src/context/captcha.rs @@ -56,7 +56,7 @@ pub struct WithCaptcha { impl WithCaptcha { #[must_use] - pub fn new(captcha: Option, inner: T) -> Self { + pub(crate) fn new(captcha: Option, inner: T) -> Self { Self { captcha: captcha.map(|captcha| Value::from_object(CaptchaConfig(captcha))), inner, diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 38b96013..69185c8c 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -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) { "emails/recovery.txt" } + + /// Render the email recovery email (HTML text variant) + pub fn render_email_recovery_html(WithLanguage) { "emails/recovery.html" } + + /// Render the email recovery subject + pub fn render_email_recovery_subject(WithLanguage) { "emails/recovery.subject" } + /// Render the email verification email (plain text variant) pub fn render_email_verification_txt(WithLanguage) { "emails/verification.txt" } diff --git a/templates/emails/recovery.html b/templates/emails/recovery.html new file mode 100644 index 00000000..57f368ae --- /dev/null +++ b/templates/emails/recovery.html @@ -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) -%} + + + + + + + + + + {{ _("mas.emails.recovery.headline", server_name=branding.server_name) }}
+
+ {{ _("mas.emails.recovery.click_button") }}
+
+ {{ _("mas.emails.recovery.create_new_password") }}
+
+ {{ _("mas.emails.recovery.you_can_ignore") }} + + \ No newline at end of file diff --git a/templates/emails/recovery.subject b/templates/emails/recovery.subject new file mode 100644 index 00000000..f75c0a3a --- /dev/null +++ b/templates/emails/recovery.subject @@ -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) }} diff --git a/templates/emails/recovery.txt b/templates/emails/recovery.txt new file mode 100644 index 00000000..61f42074 --- /dev/null +++ b/templates/emails/recovery.txt @@ -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") }} \ No newline at end of file diff --git a/translations/en.json b/translations/en.json index 56d03b98..130e3f61 100644 --- a/translations/en.json +++ b/translations/en.json @@ -221,6 +221,32 @@ "context": "emails/verification.html:19:3-51, emails/verification.txt:19:3-51", "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": { "body_html": "Your verification code to confirm this email address is: %(code)s", "@body_html": {