diff --git a/src/email.rs b/src/email.rs index e539a70..84ff7ac 100644 --- a/src/email.rs +++ b/src/email.rs @@ -213,3 +213,27 @@ pub async fn send_verification_email( send_via_resend(to, "Verify your Voltius email", html, text).await } + +pub async fn send_password_changed_email( + to: &str, +) -> Result<(), Box> { + let account_url = format!("{}/account", app_url().trim_end_matches('/')); + let when = chrono::Utc::now().format("%Y-%m-%d %H:%M UTC").to_string(); + let html = branded_email_html( + "The master password of your Voltius account was just changed.", + "Security notice", + "Your master password was changed", + &format!( + r#"

The master password of your Voltius account was changed on {when}.

+

If you did this, there is nothing else to do. If you did not, someone with access to one of your signed-in devices changed it: write to contact@voltius.app right away.

"# + ), + "Open your account", + &account_url, + "Every other device signed in to this account has been signed out and needs the new password.", + ); + let text = format!( + "The master password of your Voltius account was changed on {when}.\n\nIf you did this, there is nothing else to do. If you did not, write to contact@voltius.app right away.\n\nEvery other device signed in to this account has been signed out and needs the new password." + ); + + send_via_resend(to, "Your Voltius master password was changed", html, text).await +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 1c25ed0..cd33550 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -15,7 +15,7 @@ use crate::auth::{ password::{hash_auth_key, verify_auth_key}, AuthUser, }; -use crate::email::send_verification_email; +use crate::email::{send_password_changed_email, send_verification_email}; use crate::routes::email_undeliverable_response; use crate::routes::invitations::{accept_pending_invitation, PendingInvitation, PENDING_INVITATION_COLUMNS}; use crate::self_host; @@ -937,13 +937,13 @@ pub async fn update_password( StatusCode::INTERNAL_SERVER_ERROR })?; - sqlx::query( - "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, token_version = token_version + 1, updated_at = now() WHERE id = $3", + let (email, undeliverable) = sqlx::query_as::<_, (String, bool)>( + "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, token_version = token_version + 1, updated_at = now() WHERE id = $3 RETURNING email, email_undeliverable_at IS NOT NULL", ) .bind(&new_auth_hash) .bind(&body.new_wrapped_user_secrets) .bind(auth.0) - .execute(&pool) + .fetch_one(&pool) .await .map_err(|e| { error!(error = %e, user_id = %auth.0, "Failed to update password"); @@ -952,6 +952,14 @@ pub async fn update_password( let tier = fetch_tier(&pool, auth.0).await?; notifier.notify(auth.0, "token_invalidated".to_string()); + if !undeliverable { + let user_id = auth.0; + tokio::spawn(async move { + if let Err(e) = send_password_changed_email(&email).await { + error!(error = %e, user_id = %user_id, "Failed to send password change notice"); + } + }); + } info!(user_id = %auth.0, "User password updated");