diff --git a/src/controllers/github/secret_scanning.rs b/src/controllers/github/secret_scanning.rs index a2002b96c6c..43250656890 100644 --- a/src/controllers/github/secret_scanning.rs +++ b/src/controllers/github/secret_scanning.rs @@ -1,5 +1,5 @@ use crate::app::AppState; -use crate::email::Email; +use crate::email::EmailMessage; use crate::models::{ApiToken, User}; use crate::schema::{api_tokens, crate_owners, crates, emails}; use crate::util::errors::{AppResult, BoxedAppError, bad_request}; @@ -16,6 +16,7 @@ use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; use futures_util::TryStreamExt; use http::HeaderMap; +use minijinja::context; use p256::PublicKey; use p256::ecdsa::VerifyingKey; use p256::ecdsa::signature::Verifier; @@ -25,6 +26,7 @@ use std::str::FromStr; use std::sync::LazyLock; use std::time::Duration; use tokio::sync::Mutex; +use tracing::warn; // Minimum number of seconds to wait before refreshing cache of GitHub's public keys const PUBLIC_KEY_CACHE_LIFETIME: Duration = Duration::from_secs(60 * 60 * 24); // 24 hours @@ -226,13 +228,16 @@ async fn send_notification_email( return Err(anyhow!("No address found")); }; - let email = TokenExposedEmail { - domain: &state.config.domain_name, - reporter: "GitHub", - source: &alert.source, - token_name: &token.name, - url: &alert.url, - }; + let email = EmailMessage::from_template( + "token_exposed", + context! { + domain => state.config.domain_name, + reporter => "GitHub", + source => alert.source, + token_name => token.name, + url => if alert.url.is_empty() { "" } else { &alert.url } + }, + )?; state.emails.send(&recipient, email).await?; @@ -285,12 +290,24 @@ async fn send_trustpub_notification_emails( // Send notifications in sorted order by email for consistent testing for (email, crate_names) in notifications { - let email_template = TrustedPublishingTokenExposedEmail { - domain: &state.config.domain_name, - reporter: "GitHub", - source: &alert.source, - crate_names: &crate_names.iter().cloned().collect::>(), - url: &alert.url, + let message = EmailMessage::from_template( + "trustpub_token_exposed", + context! { + domain => state.config.domain_name, + reporter => "GitHub", + source => alert.source, + crate_names, + url => alert.url + }, + ); + + let Ok(email_template) = message.inspect_err(|error| { + warn!( + %email, ?crate_names, ?error, + "Failed to create trusted publishing token exposure email template" + ); + }) else { + continue; }; if let Err(error) = state.emails.send(&email, email_template).await { @@ -304,104 +321,6 @@ async fn send_trustpub_notification_emails( Ok(()) } -struct TokenExposedEmail<'a> { - domain: &'a str, - reporter: &'a str, - source: &'a str, - token_name: &'a str, - url: &'a str, -} - -impl Email for TokenExposedEmail<'_> { - fn subject(&self) -> String { - format!( - "crates.io: Your API token \"{}\" has been revoked", - self.token_name - ) - } - - fn body(&self) -> String { - let mut body = format!( - "{reporter} has notified us that your crates.io API token {token_name} \ -has been exposed publicly. We have revoked this token as a precaution. - -Please review your account at https://{domain} to confirm that no \ -unexpected changes have been made to your settings or crates. - -Source type: {source}", - domain = self.domain, - reporter = self.reporter, - source = self.source, - token_name = self.token_name, - ); - if self.url.is_empty() { - body.push_str("\n\nWe were not informed of the URL where the token was found."); - } else { - body.push_str(&format!("\n\nURL where the token was found: {}", self.url)); - } - - body - } -} - -struct TrustedPublishingTokenExposedEmail<'a> { - domain: &'a str, - reporter: &'a str, - source: &'a str, - crate_names: &'a [String], - url: &'a str, -} - -impl Email for TrustedPublishingTokenExposedEmail<'_> { - fn subject(&self) -> String { - "crates.io: Your Trusted Publishing token has been revoked".to_string() - } - - fn body(&self) -> String { - let authorization = if self.crate_names.len() == 1 { - format!( - "This token was only authorized to publish the \"{}\" crate.", - self.crate_names[0] - ) - } else { - format!( - "This token was authorized to publish the following crates: \"{}\".", - self.crate_names.join("\", \"") - ) - }; - - let mut body = format!( - "{reporter} has notified us that one of your crates.io Trusted Publishing tokens \ -has been exposed publicly. We have revoked this token as a precaution. - -{authorization} - -Please review your account at https://{domain} and your GitHub repository \ -settings to confirm that no unexpected changes have been made to your crates \ -or trusted publishing configurations. - -Source type: {source}", - domain = self.domain, - reporter = self.reporter, - source = self.source, - ); - - if self.url.is_empty() { - body.push_str("\n\nWe were not informed of the URL where the token was found."); - } else { - body.push_str(&format!("\n\nURL where the token was found: {}", self.url)); - } - - body.push_str( - "\n\nTrusted Publishing tokens are temporary and used for automated \ -publishing from GitHub Actions. If this exposure was unexpected, please review \ -your repository's workflow files and secrets.", - ); - - body - } -} - #[derive(Deserialize, Serialize)] pub struct GitHubSecretAlertFeedback { pub token_raw: String, diff --git a/src/controllers/krate/delete.rs b/src/controllers/krate/delete.rs index bc23449b15d..6afc3b2edf8 100644 --- a/src/controllers/krate/delete.rs +++ b/src/controllers/krate/delete.rs @@ -2,7 +2,7 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::helpers::authorization::Rights; use crate::controllers::krate::CratePath; -use crate::email::Email; +use crate::email::EmailMessage; use crate::models::NewDeletedCrate; use crate::schema::{crate_downloads, crates, dependencies}; use crate::util::errors::{AppResult, BoxedAppError, custom}; @@ -18,6 +18,8 @@ use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use http::StatusCode; use http::request::Parts; +use minijinja::context; +use tracing::error; const DOWNLOADS_PER_MONTH_LIMIT: u64 = 500; const AVAILABLE_AFTER: TimeDelta = TimeDelta::hours(24); @@ -147,10 +149,13 @@ pub async fn delete_crate( let email_future = async { if let Some(recipient) = user.email(&mut conn).await? { - let email = CrateDeletionEmail { - user: &user.gh_login, - krate: &crate_name, - }; + let email = EmailMessage::from_template( + "crate_deletion", + context! { + user => user.gh_login, + krate => crate_name + }, + )?; app.emails.send(&recipient, email).await? } @@ -193,33 +198,6 @@ async fn has_rev_dep(conn: &mut AsyncPgConnection, crate_id: i32) -> QueryResult Ok(rev_dep.is_some()) } -/// Email template for notifying a crate owner about a crate being deleted. -/// -/// The owner usually should be aware of the deletion since they initiated it, -/// but this email can be helpful in detecting malicious account activity. -#[derive(Debug, Clone)] -struct CrateDeletionEmail<'a> { - user: &'a str, - krate: &'a str, -} - -impl Email for CrateDeletionEmail<'_> { - fn subject(&self) -> String { - format!("crates.io: Deleted \"{}\" crate", self.krate) - } - - fn body(&self) -> String { - format!( - "Hi {}, - -Your \"{}\" crate has been deleted, per your request. - -If you did not initiate this deletion, your account may have been compromised. Please contact us at help@crates.io.", - self.user, self.krate - ) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/controllers/krate/owners.rs b/src/controllers/krate/owners.rs index 1d1a0691538..439dadab69e 100644 --- a/src/controllers/krate/owners.rs +++ b/src/controllers/krate/owners.rs @@ -11,7 +11,7 @@ use crate::models::{ use crate::util::errors::{AppResult, BoxedAppError, bad_request, crate_not_found, custom}; use crate::views::EncodableOwner; use crate::{App, app::AppState}; -use crate::{auth::AuthCheck, email::Email}; +use crate::{auth::AuthCheck, email::EmailMessage}; use axum::Json; use chrono::Utc; use crates_io_github::{GitHubClient, GitHubError}; @@ -20,9 +20,11 @@ use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use http::StatusCode; use http::request::Parts; +use minijinja::context; use oauth2::AccessToken; -use secrecy::{ExposeSecret, SecretString}; +use secrecy::ExposeSecret; use thiserror::Error; +use tracing::warn; #[derive(Debug, Serialize, utoipa::ToSchema)] pub struct UsersResponse { @@ -240,13 +242,20 @@ async fn modify_owners( if let Some(recipient) = invitee.verified_email(conn).await.ok().flatten() { - emails.push(OwnerInviteEmail { - recipient_email_address: recipient, - inviter: user.gh_login.clone(), - domain: app.emails.domain.clone(), - crate_name: krate.name.clone(), - token, - }); + let email = EmailMessage::from_template( + "owner_invite", + context! { + inviter => user.gh_login, + domain => app.emails.domain, + crate_name => krate.name, + token => token.expose_secret() + }, + ); + + match email { + Ok(email_msg) => emails.push((recipient, email_msg)), + Err(error) => warn!("Failed to render owner invite email template: {error}"), + } } } @@ -291,11 +300,9 @@ async fn modify_owners( // Send the accumulated invite emails now the database state has // committed. - for email in emails { - let addr = email.recipient_email_address().to_string(); - - if let Err(e) = app.emails.send(&addr, email).await { - warn!("Failed to send co-owner invite email: {e}"); + for (recipient, email) in emails { + if let Err(error) = app.emails.send(&recipient, email).await { + warn!("Failed to send owner invite email to {recipient}: {error}"); } } @@ -503,41 +510,3 @@ impl From for BoxedAppError { } } } - -pub struct OwnerInviteEmail { - /// The destination email address for this email. - recipient_email_address: String, - - /// Email body variables. - inviter: String, - domain: String, - crate_name: String, - token: SecretString, -} - -impl OwnerInviteEmail { - pub fn recipient_email_address(&self) -> &str { - &self.recipient_email_address - } -} - -impl Email for OwnerInviteEmail { - fn subject(&self) -> String { - format!( - "crates.io: Ownership invitation for \"{}\"", - self.crate_name - ) - } - - fn body(&self) -> String { - format!( - "{user_name} has invited you to become an owner of the crate {crate_name}!\n -Visit https://{domain}/accept-invite/{token} to accept this invitation, -or go to https://{domain}/me/pending-invites to manage all of your crate ownership invitations.", - user_name = self.inviter, - domain = self.domain, - crate_name = self.crate_name, - token = self.token.expose_secret(), - ) - } -} diff --git a/src/controllers/session.rs b/src/controllers/session.rs index ce5300e6ae5..c2e30f75232 100644 --- a/src/controllers/session.rs +++ b/src/controllers/session.rs @@ -4,10 +4,12 @@ use diesel::prelude::*; use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, AsyncPgConnection, RunQueryDsl}; use http::request::Parts; +use minijinja::context; use oauth2::{AuthorizationCode, CsrfToken, Scope, TokenResponse}; +use secrecy::ExposeSecret; use crate::app::AppState; -use crate::controllers::user::update::UserConfirmEmail; +use crate::email::EmailMessage; use crate::email::Emails; use crate::middleware::log_request::RequestLogExt; use crate::models::{NewEmail, NewUser, User}; @@ -170,13 +172,24 @@ async fn create_or_update_user( .build(); if let Some(token) = new_email.insert_if_missing(conn).await? { - // Swallows any error. Some users might insert an invalid email address here. - let email = UserConfirmEmail { - user_name: &user.gh_login, - domain: &emails.domain, - token, + let email = EmailMessage::from_template( + "user_confirm", + context! { + user_name => user.gh_login, + domain => emails.domain, + token => token.expose_secret() + }, + ); + + match email { + Ok(email) => { + // Swallows any error. Some users might insert an invalid email address here. + let _ = emails.send(user_email, email).await; + } + Err(error) => { + warn!("Failed to render user confirmation email template: {error}"); + } }; - let _ = emails.send(user_email, email).await; } } diff --git a/src/controllers/token.rs b/src/controllers/token.rs index bfcd889d3b5..42cb3a9d87c 100644 --- a/src/controllers/token.rs +++ b/src/controllers/token.rs @@ -1,6 +1,8 @@ +use crate::email::EmailMessage; use crate::models::ApiToken; use crate::schema::api_tokens; use crate::views::EncodableApiTokenWithToken; +use anyhow::Context; use crate::app::AppState; use crate::auth::AuthCheck; @@ -20,7 +22,9 @@ use diesel::sql_types::Timestamptz; use diesel_async::RunQueryDsl; use http::StatusCode; use http::request::Parts; +use minijinja::context; use secrecy::ExposeSecret; +use serde::Serialize; #[derive(Deserialize)] pub struct GetParams { @@ -171,17 +175,16 @@ pub async fn create_api_token( .build(); if let Some(recipient) = recipient { - let email = NewTokenEmail { - token_name: &new.api_token.name, - user_name: &user.gh_login, - domain: &app.emails.domain, + let context = context! { + token_name => &new.api_token.name, + user_name => &user.gh_login, + domain => app.emails.domain, }; // At this point the token has been created so failing to send the // email should not cause an error response to be returned to the // caller. - let email_ret = app.emails.send(&recipient, email).await; - if let Err(e) = email_ret { + if let Err(e) = send_creation_email(&app.emails, &recipient, context).await { error!("Failed to send token creation email: {e}") } } @@ -286,28 +289,13 @@ pub async fn revoke_current_api_token(app: AppState, req: Parts) -> AppResult { - token_name: &'a str, - user_name: &'a str, - domain: &'a str, -} - -impl crate::email::Email for NewTokenEmail<'_> { - fn subject(&self) -> String { - format!("crates.io: New API token \"{}\" created", self.token_name) - } - - fn body(&self) -> String { - format!( - "\ -Hello {user_name}! - -A new API token with the name \"{token_name}\" was recently added to your {domain} account. - -If this wasn't you, you should revoke the token immediately: https://{domain}/settings/tokens", - token_name = self.token_name, - user_name = self.user_name, - domain = self.domain, - ) - } +async fn send_creation_email( + emails: &crate::Emails, + recipient: &str, + context: impl Serialize, +) -> anyhow::Result<()> { + let email = EmailMessage::from_template("new_token", context); + let email = email.context("Failed to render email template")?; + let result = emails.send(recipient, email).await; + result.context("Failed to send email") } diff --git a/src/controllers/trustpub/github_configs/create/mod.rs b/src/controllers/trustpub/github_configs/create/mod.rs index ea028321d36..7e6e3e99ebc 100644 --- a/src/controllers/trustpub/github_configs/create/mod.rs +++ b/src/controllers/trustpub/github_configs/create/mod.rs @@ -1,9 +1,10 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::krate::load_crate; -use crate::controllers::trustpub::github_configs::emails::ConfigCreatedEmail; use crate::controllers::trustpub::github_configs::json; +use crate::email::EmailMessage; use crate::util::errors::{AppResult, bad_request, forbidden}; +use anyhow::Context; use axum::Json; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::NewGitHubConfig; @@ -15,6 +16,7 @@ use crates_io_trustpub::github::validation::{ use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::request::Parts; +use minijinja::context; use oauth2::AccessToken; use secrecy::ExposeSecret; @@ -105,18 +107,18 @@ pub async fn create_trustpub_github_config( .collect::>(); for (recipient, email_address) in &recipients { - let email = ConfigCreatedEmail { - recipient, - user: &auth_user.gh_login, - krate: &krate.name, - repository_owner: &saved_config.repository_owner, - repository_name: &saved_config.repository_name, - workflow_filename: &saved_config.workflow_filename, - environment: saved_config.environment.as_deref().unwrap_or("(not set)"), + let context = context! { + recipient => recipient, + user => auth_user.gh_login, + krate => krate.name, + repository_owner => saved_config.repository_owner, + repository_name => saved_config.repository_name, + workflow_filename => saved_config.workflow_filename, + environment => saved_config.environment }; - if let Err(err) = state.emails.send(email_address, email).await { - warn!("Failed to send trusted publishing notification to {email_address}: {err}") + if let Err(err) = send_notification_email(&state, email_address, context).await { + warn!("Failed to send trusted publishing notification to {email_address}: {err}"); } } @@ -133,3 +135,18 @@ pub async fn create_trustpub_github_config( Ok(Json(json::CreateResponse { github_config })) } + +async fn send_notification_email( + state: &AppState, + email_address: &str, + context: minijinja::Value, +) -> anyhow::Result<()> { + let email = EmailMessage::from_template("config_created", context) + .context("Failed to render email template")?; + + state + .emails + .send(email_address, email) + .await + .context("Failed to send email") +} diff --git a/src/controllers/trustpub/github_configs/delete/mod.rs b/src/controllers/trustpub/github_configs/delete/mod.rs index 51b0e533529..31d97b78605 100644 --- a/src/controllers/trustpub/github_configs/delete/mod.rs +++ b/src/controllers/trustpub/github_configs/delete/mod.rs @@ -1,7 +1,8 @@ use crate::app::AppState; use crate::auth::AuthCheck; -use crate::controllers::trustpub::github_configs::emails::ConfigDeletedEmail; +use crate::email::EmailMessage; use crate::util::errors::{AppResult, bad_request, not_found}; +use anyhow::Context; use axum::extract::Path; use crates_io_database::models::OwnerKind; use crates_io_database::models::trustpub::GitHubConfig; @@ -10,6 +11,8 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::StatusCode; use http::request::Parts; +use minijinja::context; +use tracing::warn; #[cfg(test)] mod tests; @@ -76,20 +79,35 @@ pub async fn delete_trustpub_github_config( .collect::>(); for (recipient, email_address) in &recipients { - let email = ConfigDeletedEmail { - recipient, - user: &auth_user.gh_login, - krate: &crate_name, - repository_owner: &config.repository_owner, - repository_name: &config.repository_name, - workflow_filename: &config.workflow_filename, - environment: config.environment.as_deref().unwrap_or("(not set)"), + let context = context! { + recipient => recipient, + user => auth_user.gh_login, + krate => crate_name, + repository_owner => config.repository_owner, + repository_name => config.repository_name, + workflow_filename => config.workflow_filename, + environment => config.environment }; - if let Err(err) = state.emails.send(email_address, email).await { - warn!("Failed to send trusted publishing notification to {email_address}: {err}") + if let Err(err) = send_notification_email(&state, email_address, context).await { + warn!("Failed to send trusted publishing notification to {email_address}: {err}"); } } Ok(StatusCode::NO_CONTENT) } + +async fn send_notification_email( + state: &AppState, + email_address: &str, + context: minijinja::Value, +) -> anyhow::Result<()> { + let email = EmailMessage::from_template("config_deleted", context) + .context("Failed to render email template")?; + + state + .emails + .send(email_address, email) + .await + .context("Failed to send email") +} diff --git a/src/controllers/trustpub/github_configs/emails.rs b/src/controllers/trustpub/github_configs/emails.rs deleted file mode 100644 index b3a89a28d24..00000000000 --- a/src/controllers/trustpub/github_configs/emails.rs +++ /dev/null @@ -1,97 +0,0 @@ -use crate::email::Email; - -/// Email template for notifying crate owners about a new crate version -/// being published. -#[derive(Debug, Clone)] -pub struct ConfigCreatedEmail<'a> { - pub recipient: &'a str, - pub user: &'a str, - pub krate: &'a str, - pub repository_owner: &'a str, - pub repository_name: &'a str, - pub workflow_filename: &'a str, - pub environment: &'a str, -} - -impl Email for ConfigCreatedEmail<'_> { - fn subject(&self) -> String { - let Self { krate, .. } = self; - format!("crates.io: Trusted Publishing configuration added to {krate}") - } - - fn body(&self) -> String { - let Self { - recipient, - user, - krate, - repository_owner, - repository_name, - workflow_filename, - environment, - } = self; - - format!( - "Hello {recipient}! - -crates.io user {user} added a new \"Trusted Publishing\" configuration for GitHub Actions to a crate that you manage ({krate}). Trusted publishers act as trusted users and can publish new versions of the crate automatically. - -Trusted Publishing configuration: - -- Repository owner: {repository_owner} -- Repository name: {repository_name} -- Workflow filename: {workflow_filename} -- Environment: {environment} - -If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the \"Settings\" tab on the crate's page. - -If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team." - ) - } -} - -/// Email template for notifying crate owners about a Trusted Publishing -/// configuration being deleted. -#[derive(Debug, Clone)] -pub struct ConfigDeletedEmail<'a> { - pub recipient: &'a str, - pub user: &'a str, - pub krate: &'a str, - pub repository_owner: &'a str, - pub repository_name: &'a str, - pub workflow_filename: &'a str, - pub environment: &'a str, -} - -impl Email for ConfigDeletedEmail<'_> { - fn subject(&self) -> String { - let Self { krate, .. } = self; - format!("crates.io: Trusted Publishing configuration removed from {krate}") - } - - fn body(&self) -> String { - let Self { - recipient, - user, - krate, - repository_owner, - repository_name, - workflow_filename, - environment, - } = self; - - format!( - "Hello {recipient}! - -crates.io user {user} removed a \"Trusted Publishing\" configuration for GitHub Actions from a crate that you manage ({krate}). - -Trusted Publishing configuration: - -- Repository owner: {repository_owner} -- Repository name: {repository_name} -- Workflow filename: {workflow_filename} -- Environment: {environment} - -If you did not make this change and you think it was made maliciously, you can email help@crates.io to communicate with the crates.io support team." - ) - } -} diff --git a/src/controllers/trustpub/github_configs/mod.rs b/src/controllers/trustpub/github_configs/mod.rs index 8f5f21e0fc7..5b6bc46270a 100644 --- a/src/controllers/trustpub/github_configs/mod.rs +++ b/src/controllers/trustpub/github_configs/mod.rs @@ -1,5 +1,4 @@ pub mod create; pub mod delete; -pub mod emails; pub mod json; pub mod list; diff --git a/src/controllers/user/email_verification.rs b/src/controllers/user/email_verification.rs index 293d557ed5f..bab08ef3726 100644 --- a/src/controllers/user/email_verification.rs +++ b/src/controllers/user/email_verification.rs @@ -1,7 +1,7 @@ -use super::update::UserConfirmEmail; use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::helpers::OkResponse; +use crate::email::EmailMessage; use crate::models::Email; use crate::util::errors::AppResult; use crate::util::errors::{BoxedAppError, bad_request}; @@ -12,6 +12,8 @@ use diesel::prelude::*; use diesel_async::scoped_futures::ScopedFutureExt; use diesel_async::{AsyncConnection, RunQueryDsl}; use http::request::Parts; +use minijinja::context; +use secrecy::ExposeSecret; /// Marks the email belonging to the given token as verified. #[utoipa::path( @@ -77,15 +79,19 @@ pub async fn resend_email_verification( .optional()? .ok_or_else(|| bad_request("Email could not be found"))?; - let email1 = UserConfirmEmail { - user_name: &auth.user().gh_login, - domain: &state.emails.domain, - token: email.token, - }; + let email_message = EmailMessage::from_template( + "user_confirm", + context! { + user_name => auth.user().gh_login, + domain => state.emails.domain, + token => email.token.expose_secret() + }, + ) + .map_err(|_| bad_request("Failed to render email template"))?; state .emails - .send(&email.email, email1) + .send(&email.email, email_message) .await .map_err(BoxedAppError::from) } diff --git a/src/controllers/user/update.rs b/src/controllers/user/update.rs index eb956c6b556..6a681b6bdae 100644 --- a/src/controllers/user/update.rs +++ b/src/controllers/user/update.rs @@ -1,6 +1,7 @@ use crate::app::AppState; use crate::auth::AuthCheck; use crate::controllers::helpers::OkResponse; +use crate::email::EmailMessage; use crate::models::NewEmail; use crate::schema::users; use crate::util::errors::{AppResult, bad_request, server_error}; @@ -10,7 +11,8 @@ use diesel::prelude::*; use diesel_async::RunQueryDsl; use http::request::Parts; use lettre::Address; -use secrecy::{ExposeSecret, SecretString}; +use minijinja::context; +use secrecy::ExposeSecret; #[derive(Deserialize)] pub struct UserUpdate { @@ -68,15 +70,23 @@ pub async fn update_user( let email_address = user.verified_email(&mut conn).await?; if let Some(email_address) = email_address { - let email = PublishNotificationsUnsubscribeEmail { - user_name: &user.gh_login, - domain: &state.emails.domain, - }; - - if let Err(error) = state.emails.send(&email_address, email).await { - warn!( - "Failed to send publish notifications unsubscribe email to {email_address}: {error}" - ); + let email = EmailMessage::from_template( + "unsubscribe_notifications", + context! { + user_name => user.gh_login, + domain => state.emails.domain + }, + ); + + match email { + Ok(email) => { + if let Err(error) = state.emails.send(&email_address, email).await { + warn!( + "Failed to send publish notifications unsubscribe email to {email_address}: {error}" + ); + } + } + Err(error) => warn!("Failed to render unsubscribe email template: {error}"), } } } @@ -106,63 +116,24 @@ pub async fn update_user( // an invalid email set in their GitHub profile, and we should let them sign in even though // we're trying to silently use their invalid address during signup and can't send them an // email. They'll then have to provide a valid email address. - let email = UserConfirmEmail { - user_name: &user.gh_login, - domain: &state.emails.domain, - token, + let email = EmailMessage::from_template( + "user_confirm", + context! { + user_name => user.gh_login, + domain => state.emails.domain, + token => token.expose_secret() + }, + ); + + match email { + Ok(email) => { + let _ = state.emails.send(user_email, email).await; + } + Err(error) => { + warn!("Failed to render user confirmation email template: {error}"); + } }; - - let _ = state.emails.send(user_email, email).await; } Ok(OkResponse::new()) } - -pub struct UserConfirmEmail<'a> { - pub user_name: &'a str, - pub domain: &'a str, - pub token: SecretString, -} - -impl crate::email::Email for UserConfirmEmail<'_> { - fn subject(&self) -> String { - "crates.io: Please confirm your email address".into() - } - - fn body(&self) -> String { - // Create a URL with token string as path to send to user - // If user clicks on path, look email/user up in database, - // make sure tokens match - - format!( - "Hello {user_name}! Welcome to crates.io. Please click the -link below to verify your email address. Thank you!\n -https://{domain}/confirm/{token}", - user_name = self.user_name, - domain = self.domain, - token = self.token.expose_secret(), - ) - } -} - -pub struct PublishNotificationsUnsubscribeEmail<'a> { - pub user_name: &'a str, - pub domain: &'a str, -} - -impl crate::email::Email for PublishNotificationsUnsubscribeEmail<'_> { - fn subject(&self) -> String { - "crates.io: Unsubscribed from publish notifications".into() - } - - fn body(&self) -> String { - let Self { user_name, domain } = self; - format!( - "Hello {user_name}! - -You have been unsubscribed from publish notifications. - -If you would like to resubscribe, please visit https://{domain}/settings/profile", - ) - } -} diff --git a/src/email.rs b/src/email.rs index c16ce550cff..da1c41b718b 100644 --- a/src/email.rs +++ b/src/email.rs @@ -8,11 +8,75 @@ use lettre::transport::smtp::AsyncSmtpTransport; use lettre::transport::smtp::authentication::{Credentials, Mechanism}; use lettre::transport::stub::AsyncStubTransport; use lettre::{Address, AsyncTransport, Message, Tokio1Executor}; +use minijinja::Environment; use rand::distr::{Alphanumeric, SampleString}; +use serde::Serialize; +use std::sync::LazyLock; -pub trait Email { - fn subject(&self) -> String; - fn body(&self) -> String; +static EMAIL_ENV: LazyLock> = LazyLock::new(|| { + let mut env = Environment::new(); + + // Load templates from each email directory + let entries = std::fs::read_dir("src/email/templates"); + let entries = entries.expect("Failed to read email templates directory"); + + for entry in entries { + let entry = entry.expect("Failed to read directory entry"); + + let file_type = entry.file_type().expect("Failed to get file type"); + if !file_type.is_dir() { + continue; + } + + let dir_name = entry.file_name(); + let email_name = dir_name.to_str(); + let email_name = email_name.expect("Invalid UTF-8 in email template directory name"); + + // Load subject.txt.j2 file + let subject_path = entry.path().join("subject.txt.j2"); + let subject_contents = std::fs::read_to_string(&subject_path).unwrap_or_else(|error| { + panic!("Failed to read subject template for {email_name}: {error}") + }); + let filename = format!("{}/subject.txt.j2", email_name); + env.add_template_owned(filename, subject_contents) + .expect("Failed to add subject template"); + + // Load body.txt.j2 file + let body_path = entry.path().join("body.txt.j2"); + let body_contents = std::fs::read_to_string(&body_path).unwrap_or_else(|error| { + panic!("Failed to read body template for {email_name}: {error}") + }); + let filename = format!("{}/body.txt.j2", email_name); + env.add_template_owned(filename, body_contents) + .expect("Failed to add body template"); + } + + env +}); + +fn render_template( + template_name: &str, + context: impl Serialize, +) -> Result { + EMAIL_ENV.get_template(template_name)?.render(context) +} + +#[derive(Debug, Clone)] +pub struct EmailMessage { + pub subject: String, + pub body_text: String, +} + +impl EmailMessage { + pub fn from_template( + template_name: &str, + context: impl Serialize, + ) -> Result { + let subject = render_template(&format!("{}/subject.txt.j2", template_name), &context)?; + let body_text = render_template(&format!("{}/body.txt.j2", template_name), context)?; + + Ok(EmailMessage { subject, body_text }) + } } #[derive(Debug, Clone)] @@ -115,8 +179,8 @@ impl Emails { Ok(message) } - pub async fn send(&self, recipient: &str, email: E) -> Result<(), EmailError> { - let email = self.build_message(recipient, email.subject(), email.body())?; + pub async fn send(&self, recipient: &str, email: EmailMessage) -> Result<(), EmailError> { + let email = self.build_message(recipient, email.subject, email.body_text)?; self.backend .send(email) @@ -170,24 +234,16 @@ pub struct StoredEmail { mod tests { use super::*; - struct TestEmail; - - impl Email for TestEmail { - fn subject(&self) -> String { - "test".into() - } - - fn body(&self) -> String { - "test".into() - } - } - #[tokio::test] async fn sending_to_invalid_email_fails() { let emails = Emails::new_in_memory(); let address = "String.Format(\"{0}.{1}@live.com\", FirstName, LastName)"; - assert_err!(emails.send(address, TestEmail).await); + let email = EmailMessage { + subject: "test".into(), + body_text: "test".into(), + }; + assert_err!(emails.send(address, email).await); } #[tokio::test] @@ -195,6 +251,10 @@ mod tests { let emails = Emails::new_in_memory(); let address = "someone@example.com"; - assert_ok!(emails.send(address, TestEmail).await); + let email = EmailMessage { + subject: "test".into(), + body_text: "test".into(), + }; + assert_ok!(emails.send(address, email).await); } } diff --git a/src/email/templates/admin_account/body.txt.j2 b/src/email/templates/admin_account/body.txt.j2 new file mode 100644 index 00000000000..e54a230c8c5 --- /dev/null +++ b/src/email/templates/admin_account/body.txt.j2 @@ -0,0 +1,13 @@ +{% if added_admins -%} +Granted admin access: + +{% for admin in added_admins -%} +- {{ admin }} +{% endfor %} +{% endif -%} +{% if removed_admins -%} +Revoked admin access: +{% for admin in removed_admins -%} +- {{ admin }} +{% endfor -%} +{% endif %} \ No newline at end of file diff --git a/src/email/templates/admin_account/subject.txt.j2 b/src/email/templates/admin_account/subject.txt.j2 new file mode 100644 index 00000000000..d14d0ec6584 --- /dev/null +++ b/src/email/templates/admin_account/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Admin account changes \ No newline at end of file diff --git a/src/email/templates/config_created/body.txt.j2 b/src/email/templates/config_created/body.txt.j2 new file mode 100644 index 00000000000..20be3dbd452 --- /dev/null +++ b/src/email/templates/config_created/body.txt.j2 @@ -0,0 +1,14 @@ +Hello {{ recipient }}! + +crates.io user {{ user }} added a new "Trusted Publishing" configuration for GitHub Actions to a crate that you manage ({{ krate }}). Trusted publishers act as trusted users and can publish new versions of the crate automatically. + +Trusted Publishing configuration: + +- Repository owner: {{ repository_owner }} +- Repository name: {{ repository_name }} +- Workflow filename: {{ workflow_filename }} +- Environment: {{ environment or "(not set)" }} + +If you did not make this change and you think it was made maliciously, you can remove the configuration from the crate via the "Settings" tab on the crate's page. + +If you are unable to revert the change and need to do so, you can email help@crates.io to communicate with the crates.io support team. diff --git a/src/email/templates/config_created/subject.txt.j2 b/src/email/templates/config_created/subject.txt.j2 new file mode 100644 index 00000000000..981251d5e66 --- /dev/null +++ b/src/email/templates/config_created/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Trusted Publishing configuration added to {{ krate }} diff --git a/src/email/templates/config_deleted/body.txt.j2 b/src/email/templates/config_deleted/body.txt.j2 new file mode 100644 index 00000000000..61beaee55df --- /dev/null +++ b/src/email/templates/config_deleted/body.txt.j2 @@ -0,0 +1,12 @@ +Hello {{ recipient }}! + +crates.io user {{ user }} removed a "Trusted Publishing" configuration for GitHub Actions from a crate that you manage ({{ krate }}). + +Trusted Publishing configuration: + +- Repository owner: {{ repository_owner }} +- Repository name: {{ repository_name }} +- Workflow filename: {{ workflow_filename }} +- Environment: {{ environment or "(not set)" }} + +If you did not make this change and you think it was made maliciously, you can email help@crates.io to communicate with the crates.io support team. diff --git a/src/email/templates/config_deleted/subject.txt.j2 b/src/email/templates/config_deleted/subject.txt.j2 new file mode 100644 index 00000000000..d7c001b956e --- /dev/null +++ b/src/email/templates/config_deleted/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Trusted Publishing configuration removed from {{ krate }} diff --git a/src/email/templates/crate_deletion/body.txt.j2 b/src/email/templates/crate_deletion/body.txt.j2 new file mode 100644 index 00000000000..674a874890d --- /dev/null +++ b/src/email/templates/crate_deletion/body.txt.j2 @@ -0,0 +1,5 @@ +Hi {{ user }}, + +Your "{{ krate }}" crate has been deleted, per your request. + +If you did not initiate this deletion, your account may have been compromised. Please contact us at help@crates.io. \ No newline at end of file diff --git a/src/email/templates/crate_deletion/subject.txt.j2 b/src/email/templates/crate_deletion/subject.txt.j2 new file mode 100644 index 00000000000..4192a84c0bd --- /dev/null +++ b/src/email/templates/crate_deletion/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Deleted "{{ krate }}" crate \ No newline at end of file diff --git a/src/email/templates/expiry_notification/body.txt.j2 b/src/email/templates/expiry_notification/body.txt.j2 new file mode 100644 index 00000000000..0391952e7a1 --- /dev/null +++ b/src/email/templates/expiry_notification/body.txt.j2 @@ -0,0 +1,8 @@ +Hi {{ name }}, + +We noticed your token "{{ token_name }}" will expire on {{ expiry_date }}. + +If this token is still needed, visit https://crates.io/settings/tokens/new?from={{ token_id }} to generate a new one. + +Thanks, +The crates.io team \ No newline at end of file diff --git a/src/email/templates/expiry_notification/subject.txt.j2 b/src/email/templates/expiry_notification/subject.txt.j2 new file mode 100644 index 00000000000..fb5b93a4579 --- /dev/null +++ b/src/email/templates/expiry_notification/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Your API token "{{ token_name }}" is about to expire \ No newline at end of file diff --git a/src/email/templates/new_token/body.txt.j2 b/src/email/templates/new_token/body.txt.j2 new file mode 100644 index 00000000000..19d0785f063 --- /dev/null +++ b/src/email/templates/new_token/body.txt.j2 @@ -0,0 +1,5 @@ +Hello {{ user_name }}! + +A new API token with the name "{{ token_name }}" was recently added to your {{ domain }} account. + +If this wasn't you, you should revoke the token immediately: https://{{ domain }}/settings/tokens \ No newline at end of file diff --git a/src/email/templates/new_token/subject.txt.j2 b/src/email/templates/new_token/subject.txt.j2 new file mode 100644 index 00000000000..0c286a231be --- /dev/null +++ b/src/email/templates/new_token/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: New API token "{{ token_name }}" created \ No newline at end of file diff --git a/src/email/templates/owner_invite/body.txt.j2 b/src/email/templates/owner_invite/body.txt.j2 new file mode 100644 index 00000000000..21c047ca37e --- /dev/null +++ b/src/email/templates/owner_invite/body.txt.j2 @@ -0,0 +1,4 @@ +{{ inviter }} has invited you to become an owner of the crate {{ crate_name }}! + +Visit https://{{ domain }}/accept-invite/{{ token }} to accept this invitation, +or go to https://{{ domain }}/me/pending-invites to manage all of your crate ownership invitations. \ No newline at end of file diff --git a/src/email/templates/owner_invite/subject.txt.j2 b/src/email/templates/owner_invite/subject.txt.j2 new file mode 100644 index 00000000000..aa51d10c3da --- /dev/null +++ b/src/email/templates/owner_invite/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Ownership invitation for "{{ crate_name }}" \ No newline at end of file diff --git a/src/email/templates/possible_typosquat/body.txt.j2 b/src/email/templates/possible_typosquat/body.txt.j2 new file mode 100644 index 00000000000..20adc0160d3 --- /dev/null +++ b/src/email/templates/possible_typosquat/body.txt.j2 @@ -0,0 +1,9 @@ +New crate {{ crate_name }} may be typosquatting one or more other crates. + +Visit https://{{ domain }}/crates/{{ crate_name }} to see the offending crate. + +Specific squat checks that triggered: + +{% for squat in squats -%} +- {{ squat.display }} (https://{{ domain }}/crates/{{ squat.package }}) +{% endfor %} \ No newline at end of file diff --git a/src/email/templates/possible_typosquat/subject.txt.j2 b/src/email/templates/possible_typosquat/subject.txt.j2 new file mode 100644 index 00000000000..1856e22db0b --- /dev/null +++ b/src/email/templates/possible_typosquat/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Possible typosquatting in new crate "{{ crate_name }}" \ No newline at end of file diff --git a/src/email/templates/publish_notification/body.txt.j2 b/src/email/templates/publish_notification/body.txt.j2 new file mode 100644 index 00000000000..04d25e77151 --- /dev/null +++ b/src/email/templates/publish_notification/body.txt.j2 @@ -0,0 +1,5 @@ +Hello {{ recipient }}! + +A new version of the package {{ krate }} ({{ version }}) was published{{ publisher_info }} at {{ publish_time }}. + +If you have questions or security concerns, you can contact us at help@crates.io. If you would like to stop receiving these security notifications, you can disable them in your account settings. \ No newline at end of file diff --git a/src/email/templates/publish_notification/subject.txt.j2 b/src/email/templates/publish_notification/subject.txt.j2 new file mode 100644 index 00000000000..c1bb25db16a --- /dev/null +++ b/src/email/templates/publish_notification/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Successfully published {{ krate }}@{{ version }} \ No newline at end of file diff --git a/src/email/templates/token_exposed/body.txt.j2 b/src/email/templates/token_exposed/body.txt.j2 new file mode 100644 index 00000000000..3332c874ac5 --- /dev/null +++ b/src/email/templates/token_exposed/body.txt.j2 @@ -0,0 +1,11 @@ +{{ reporter }} has notified us that your crates.io API token {{ token_name }} has been exposed publicly. We have revoked this token as a precaution. + +Please review your account at https://{{ domain }} to confirm that no unexpected changes have been made to your settings or crates. + +Source type: {{ source }} + +{% if url -%} +URL where the token was found: {{ url }} +{%- else -%} +We were not informed of the URL where the token was found. +{%- endif %} diff --git a/src/email/templates/token_exposed/subject.txt.j2 b/src/email/templates/token_exposed/subject.txt.j2 new file mode 100644 index 00000000000..fcaf9e952b7 --- /dev/null +++ b/src/email/templates/token_exposed/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Your API token "{{ token_name }}" has been revoked \ No newline at end of file diff --git a/src/email/templates/trustpub_token_exposed/body.txt.j2 b/src/email/templates/trustpub_token_exposed/body.txt.j2 new file mode 100644 index 00000000000..b6c5178d5c1 --- /dev/null +++ b/src/email/templates/trustpub_token_exposed/body.txt.j2 @@ -0,0 +1,19 @@ +{{ reporter }} has notified us that one of your crates.io Trusted Publishing tokens has been exposed publicly. We have revoked this token as a precaution. + +{% if crate_names | length == 1 -%} +This token was only authorized to publish the "{{ crate_names[0] }}" crate. +{%- else -%} +This token was authorized to publish the following crates: "{{ crate_names | join('", "') }}". +{%- endif %} + +Please review your account at https://{{ domain }} and your GitHub repository settings to confirm that no unexpected changes have been made to your crates or trusted publishing configurations. + +Source type: {{ source }} + +{% if url -%} +URL where the token was found: {{ url }} +{%- else -%} +We were not informed of the URL where the token was found. +{%- endif %} + +Trusted Publishing tokens are temporary and used for automated publishing from GitHub Actions. If this exposure was unexpected, please review your repository's workflow files and secrets. \ No newline at end of file diff --git a/src/email/templates/trustpub_token_exposed/subject.txt.j2 b/src/email/templates/trustpub_token_exposed/subject.txt.j2 new file mode 100644 index 00000000000..494ae249ddb --- /dev/null +++ b/src/email/templates/trustpub_token_exposed/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Your Trusted Publishing token has been revoked \ No newline at end of file diff --git a/src/email/templates/unsubscribe_notifications/body.txt.j2 b/src/email/templates/unsubscribe_notifications/body.txt.j2 new file mode 100644 index 00000000000..034efab0d4d --- /dev/null +++ b/src/email/templates/unsubscribe_notifications/body.txt.j2 @@ -0,0 +1,5 @@ +Hello {{ user_name }}! + +You have been unsubscribed from publish notifications. + +If you would like to resubscribe, please visit https://{{ domain }}/settings/profile \ No newline at end of file diff --git a/src/email/templates/unsubscribe_notifications/subject.txt.j2 b/src/email/templates/unsubscribe_notifications/subject.txt.j2 new file mode 100644 index 00000000000..2f98da61466 --- /dev/null +++ b/src/email/templates/unsubscribe_notifications/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Unsubscribed from publish notifications \ No newline at end of file diff --git a/src/email/templates/user_confirm/body.txt.j2 b/src/email/templates/user_confirm/body.txt.j2 new file mode 100644 index 00000000000..0135821d181 --- /dev/null +++ b/src/email/templates/user_confirm/body.txt.j2 @@ -0,0 +1,4 @@ +Hello {{ user_name }}! Welcome to crates.io. Please click the +link below to verify your email address. Thank you! + +https://{{ domain }}/confirm/{{ token }} \ No newline at end of file diff --git a/src/email/templates/user_confirm/subject.txt.j2 b/src/email/templates/user_confirm/subject.txt.j2 new file mode 100644 index 00000000000..d029b4f708a --- /dev/null +++ b/src/email/templates/user_confirm/subject.txt.j2 @@ -0,0 +1 @@ +crates.io: Please confirm your email address \ No newline at end of file diff --git a/src/worker/jobs/expiry_notification.rs b/src/worker/jobs/expiry_notification.rs index 4119eab29aa..4e2e91c4b38 100644 --- a/src/worker/jobs/expiry_notification.rs +++ b/src/worker/jobs/expiry_notification.rs @@ -1,12 +1,13 @@ use crate::models::ApiToken; use crate::schema::api_tokens; -use crate::{Emails, email::Email, models::User, worker::Environment}; +use crate::{Emails, email::EmailMessage, models::User, worker::Environment}; use chrono::SecondsFormat; use crates_io_worker::BackgroundJob; use diesel::dsl::now; use diesel::prelude::*; use diesel::sql_types::Timestamptz; use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use minijinja::context; use std::sync::Arc; /// The threshold for the expiry notification. @@ -83,12 +84,15 @@ async fn handle_expiring_token( let recipient = user.email(conn).await?; if let Some(recipient) = recipient { debug!("Sending expiry notification to {}…", recipient); - let email = ExpiryNotificationEmail { - name: &user.gh_login, - token_id: token.id, - token_name: &token.name, - expiry_date: token.expired_at.unwrap(), - }; + let email = EmailMessage::from_template( + "expiry_notification", + context! { + name => user.gh_login, + token_id => token.id, + token_name => token.name, + expiry_date => token.expired_at.unwrap().to_rfc3339_opts(SecondsFormat::Secs, true) + }, + )?; emails.send(&recipient, email).await?; } else { info!( @@ -134,40 +138,6 @@ pub async fn find_expiring_tokens( .await } -#[derive(Debug, Clone)] -struct ExpiryNotificationEmail<'a> { - name: &'a str, - token_id: i32, - token_name: &'a str, - expiry_date: chrono::DateTime, -} - -impl Email for ExpiryNotificationEmail<'_> { - fn subject(&self) -> String { - format!( - "crates.io: Your API token \"{}\" is about to expire", - self.token_name - ) - } - - fn body(&self) -> String { - format!( - r#"Hi {}, - -We noticed your token "{}" will expire on {}. - -If this token is still needed, visit https://crates.io/settings/tokens/new?from={} to generate a new one. - -Thanks, -The crates.io team"#, - self.name, - self.token_name, - self.expiry_date.to_rfc3339_opts(SecondsFormat::Secs, true), - self.token_id - ) - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/worker/jobs/send_publish_notifications.rs b/src/worker/jobs/send_publish_notifications.rs index dd64c5b9042..65adbef7065 100644 --- a/src/worker/jobs/send_publish_notifications.rs +++ b/src/worker/jobs/send_publish_notifications.rs @@ -1,4 +1,4 @@ -use crate::email::Email; +use crate::email::EmailMessage; use crate::models::OwnerKind; use crate::schema::{crate_owners, crates, emails, users, versions}; use crate::worker::Environment; @@ -7,7 +7,9 @@ use chrono::{DateTime, SecondsFormat, Utc}; use crates_io_worker::BackgroundJob; use diesel::prelude::*; use diesel_async::{AsyncPgConnection, RunQueryDsl}; +use minijinja::context; use std::sync::Arc; +use tracing::warn; /// Background job that sends email notifications to all crate owners when a /// new crate version is published. @@ -83,18 +85,29 @@ impl BackgroundJob for SendPublishNotificationsJob { None => "", }; - let email = PublishNotificationEmail { - recipient, - krate, - version, - publish_time: &publish_time, - publisher_info, - }; + let email = EmailMessage::from_template( + "publish_notification", + context! { + recipient => recipient, + krate => krate, + version => version, + publish_time => publish_time, + publisher_info => publisher_info + }, + ); debug!("Sending publish notification for {krate}@{version} to {email_address}…"); - let result = ctx.emails.send(&email_address, email).await.inspect_err(|err| { - warn!("Failed to send publish notification for {krate}@{version} to {email_address}: {err}") - }); + let result = match email { + Ok(email_msg) => { + ctx.emails.send(&email_address, email_msg).await.inspect_err(|err| { + warn!("Failed to send publish notification for {krate}@{version} to {email_address}: {err}") + }).map_err(|_| ()) + } + Err(err) => { + warn!("Failed to render publish notification email template for {krate}@{version} to {email_address}: {err}"); + Err(()) + } + }; results.push(result); } @@ -153,39 +166,3 @@ impl PublishDetails { .await } } - -/// Email template for notifying crate owners about a new crate version -/// being published. -#[derive(Debug, Clone)] -struct PublishNotificationEmail<'a> { - recipient: &'a str, - krate: &'a str, - version: &'a str, - publish_time: &'a str, - publisher_info: &'a str, -} - -impl Email for PublishNotificationEmail<'_> { - fn subject(&self) -> String { - let Self { krate, version, .. } = self; - format!("crates.io: Successfully published {krate}@{version}") - } - - fn body(&self) -> String { - let Self { - recipient, - krate, - version, - publish_time, - publisher_info, - } = self; - - format!( - "Hello {recipient}! - -A new version of the package {krate} ({version}) was published{publisher_info} at {publish_time}. - -If you have questions or security concerns, you can contact us at help@crates.io. If you would like to stop receiving these security notifications, you can disable them in your account settings." - ) - } -} diff --git a/src/worker/jobs/sync_admins.rs b/src/worker/jobs/sync_admins.rs index ebf688fc97d..42073430bbb 100644 --- a/src/worker/jobs/sync_admins.rs +++ b/src/worker/jobs/sync_admins.rs @@ -1,11 +1,12 @@ -use crate::email::Email; +use crate::email::EmailMessage; use crate::schema::{emails, users}; use crate::worker::Environment; +use anyhow::Context; use crates_io_worker::BackgroundJob; use diesel::prelude::*; use diesel_async::RunQueryDsl; +use minijinja::context; use std::collections::HashSet; -use std::fmt::{Display, Formatter}; use std::sync::Arc; /// See . @@ -138,23 +139,18 @@ impl BackgroundJob for SyncAdmins { let added_admins = format_repo_admins(&added_admin_ids); let removed_admins = format_database_admins(&removed_admin_ids); - - let email = AdminAccountEmail::new(added_admins, removed_admins); + let context = context! { added_admins, removed_admins }; for database_admin in &database_admins { - let (_, _, email_address) = database_admin; + let (github_id, login, email_address) = database_admin; if let Some(email_address) = email_address { - if let Err(error) = ctx.emails.send(email_address, email.clone()).await { + if let Err(error) = send_email(&ctx, email_address, &context).await { warn!( - "Failed to send email to admin {} ({}, github_id: {}): {}", - database_admin.1, email_address, database_admin.0, error + "Failed to send email to admin {login} ({email_address}, github_id: {github_id}): {error:?}", ); } } else { - warn!( - "No email address found for admin {} (github_id: {})", - database_admin.1, database_admin.0 - ); + warn!("No email address found for admin {login} (github_id: {github_id})",); } } @@ -162,48 +158,13 @@ impl BackgroundJob for SyncAdmins { } } -#[derive(Debug, Clone)] -struct AdminAccountEmail { - added_admins: Vec, - removed_admins: Vec, -} - -impl AdminAccountEmail { - fn new(added_admins: Vec, removed_admins: Vec) -> Self { - Self { - added_admins, - removed_admins, - } - } -} - -impl Email for AdminAccountEmail { - fn subject(&self) -> String { - "crates.io: Admin account changes".into() - } - - fn body(&self) -> String { - self.to_string() - } -} - -impl Display for AdminAccountEmail { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - if !self.added_admins.is_empty() { - writeln!(f, "Granted admin access:\n")?; - for new_admin in &self.added_admins { - writeln!(f, "- {}", new_admin)?; - } - writeln!(f)?; - } - - if !self.removed_admins.is_empty() { - writeln!(f, "Revoked admin access:")?; - for obsolete_admin in &self.removed_admins { - writeln!(f, "- {}", obsolete_admin)?; - } - } - - Ok(()) - } +async fn send_email( + ctx: &Environment, + address: &str, + context: &minijinja::Value, +) -> anyhow::Result<()> { + let email = EmailMessage::from_template("admin_account", context); + let email = email.context("Failed to render email template")?; + let result = ctx.emails.send(address, email).await; + result.context("Failed to send email") } diff --git a/src/worker/jobs/typosquat.rs b/src/worker/jobs/typosquat.rs index b2e82735a7c..7be28f3f593 100644 --- a/src/worker/jobs/typosquat.rs +++ b/src/worker/jobs/typosquat.rs @@ -5,9 +5,12 @@ use diesel_async::AsyncPgConnection; use typomania::Package; use crate::Emails; -use crate::email::Email; +use crate::email::EmailMessage; use crate::typosquat::{Cache, Crate}; use crate::worker::Environment; +use anyhow::Context; +use minijinja::context; +use tracing::{error, info}; /// A job to check the name of a newly published crate against the most popular crates to see if /// the new crate might be typosquatting an existing, popular crate. @@ -55,14 +58,25 @@ async fn check( // hopefully care to check into things more closely. info!(?squats, "Found potential typosquatting"); - let email = PossibleTyposquatEmail { - domain: &emails.domain, - crate_name: name, - squats: &squats, + let squats_data: Vec<_> = squats + .iter() + .map(|squat| { + context! { + display => squat.to_string(), + package => squat.package() + } + }) + .collect(); + + let email_context = context! { + domain => emails.domain, + crate_name => name, + squats => squats_data }; for recipient in cache.iter_emails() { - if let Err(error) = emails.send(recipient, email.clone()).await { + if let Err(error) = send_notification_email(emails, recipient, &email_context).await + { error!( ?error, ?recipient, @@ -76,45 +90,18 @@ async fn check( Ok(()) } -#[derive(Debug, Clone)] -struct PossibleTyposquatEmail<'a> { - domain: &'a str, - crate_name: &'a str, - squats: &'a [typomania::checks::Squat], -} - -impl Email for PossibleTyposquatEmail<'_> { - fn subject(&self) -> String { - format!( - "crates.io: Possible typosquatting in new crate \"{}\"", - self.crate_name - ) - } - - fn body(&self) -> String { - let squats = self - .squats - .iter() - .map(|squat| { - let domain = self.domain; - let crate_name = squat.package(); - format!("- {squat} (https://{domain}/crates/{crate_name})\n") - }) - .collect::>() - .join(""); - - format!( - "New crate {crate_name} may be typosquatting one or more other crates. - -Visit https://{domain}/crates/{crate_name} to see the offending crate. - -Specific squat checks that triggered: +async fn send_notification_email( + emails: &Emails, + recipient: &str, + context: &minijinja::Value, +) -> anyhow::Result<()> { + let email = EmailMessage::from_template("possible_typosquat", context) + .context("Failed to render email template")?; -{squats}", - domain = self.domain, - crate_name = self.crate_name, - ) - } + emails + .send(recipient, email) + .await + .context("Failed to send email") } #[cfg(test)]