From ea19c2250e0812659461c8abaf8f1a85e69c7423 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 30 Sep 2022 19:14:26 +0200 Subject: [PATCH 01/15] attach images to email Set SMTP_EMBED_IMAGES option to false if you don't want to attach images to the mail. NOTE: If you have customized the template files `email_header.hbs` and `email_footer.hbs` you can replace `{url}/vw_static/` to `{img_url}` to support both URL schemes --- .env.template | 3 ++ src/config.rs | 12 +++++ src/mail.rs | 55 +++++++++++++++++++-- src/static/templates/email/email_footer.hbs | 2 +- src/static/templates/email/email_header.hbs | 2 +- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 60b5b73b..1e5ff101 100644 --- a/.env.template +++ b/.env.template @@ -367,6 +367,9 @@ ## but might need to be changed in case it trips some anti-spam filters # HELO_NAME= +## Embed images as email attachments +# SMTP_EMBED_IMAGES=false + ## SMTP debugging ## When set to true this will output very detailed SMTP messages. ## WARNING: This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! diff --git a/src/config.rs b/src/config.rs index 3a2cf958..4cac70eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -602,6 +602,10 @@ make_config! { smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; + /// Embed images as email attachments. + smtp_embed_images: bool, true, def, true; + /// Internal + _smtp_img_src: String, false, gen, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! smtp_debug: bool, false, def, false; /// Accept Invalid Certs (Know the risks!) |> DANGEROUS: Allow invalid certificates. This option introduces significant vulnerabilities to man-in-the-middle attacks! @@ -759,6 +763,14 @@ fn extract_url_path(url: &str) -> String { } } +fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { + if embed_images { + "cid:".to_string() + } else { + format!("{}/vw_static/", domain) + } +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { diff --git a/src/mail.rs b/src/mail.rs index 5cc12658..e613da6f 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -4,7 +4,7 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use lettre::{ - message::{Mailbox, Message, MultiPart}, + message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, @@ -117,7 +117,14 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes "email/pw_hint_none" }; - let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?; + let (subject, body_html, body_text) = get_text( + template_name, + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "hint": hint, + }), + )?; send_email(address, &subject, body_html, body_text).await } @@ -130,6 +137,7 @@ pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult { "email/delete_account", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": delete_token, @@ -147,6 +155,7 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult { "email/verify_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": verify_email_token, @@ -161,6 +170,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { "email/welcome", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -175,6 +185,7 @@ pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult "email/welcome_must_verify", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "token": verify_email_token, }), @@ -188,6 +199,7 @@ pub async fn send_2fa_removed_from_org(address: &str, org_name: &str) -> EmptyRe "email/send_2fa_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -200,6 +212,7 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> "email/send_single_org_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -228,6 +241,7 @@ pub async fn send_invite( "email/send_org_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_id": org_id.as_deref().unwrap_or("_"), "org_user_id": org_user_id.as_deref().unwrap_or("_"), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), @@ -260,6 +274,7 @@ pub async fn send_emergency_access_invite( "email/send_emergency_access_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "emer_id": emer_id.unwrap_or_else(|| "_".to_string()), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "grantor_name": grantor_name, @@ -275,6 +290,7 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: "email/emergency_access_invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_email": grantee_email, }), )?; @@ -287,6 +303,7 @@ pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: "email/emergency_access_invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -299,6 +316,7 @@ pub async fn send_emergency_access_recovery_approved(address: &str, grantor_name "email/emergency_access_recovery_approved", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -316,6 +334,7 @@ pub async fn send_emergency_access_recovery_initiated( "email/emergency_access_recovery_initiated", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "wait_time_days": wait_time_days, @@ -335,6 +354,7 @@ pub async fn send_emergency_access_recovery_reminder( "email/emergency_access_recovery_reminder", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "days_left": days_left, @@ -349,6 +369,7 @@ pub async fn send_emergency_access_recovery_rejected(address: &str, grantor_name "email/emergency_access_recovery_rejected", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -361,6 +382,7 @@ pub async fn send_emergency_access_recovery_timed_out(address: &str, grantee_nam "email/emergency_access_recovery_timed_out", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, }), @@ -374,6 +396,7 @@ pub async fn send_invite_accepted(new_user_email: &str, address: &str, org_name: "email/invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "email": new_user_email, "org_name": org_name, }), @@ -387,6 +410,7 @@ pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult "email/invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -403,6 +427,7 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi "email/new_device_logged_in", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -421,6 +446,7 @@ pub async fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTi "email/incomplete_2fa_login", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -436,6 +462,7 @@ pub async fn send_token(address: &str, token: &str) -> EmptyResult { "email/twofactor_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -448,6 +475,7 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { "email/change_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -460,6 +488,7 @@ pub async fn send_test(address: &str) -> EmptyResult { "email/smtp_test", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -467,13 +496,33 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { + let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); + let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); + + let body = if CONFIG.smtp_embed_images() { + MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( + MultiPart::related() + .singlepart(SinglePart::html(body_html)) + .singlepart( + Attachment::new_inline(String::from("logo-gray.png")) + .body(logo_gray_body, "image/png".parse().unwrap()), + ) + .singlepart( + Attachment::new_inline(String::from("mail-github.png")) + .body(mail_github_body, "image/png".parse().unwrap()), + ), + ) + } else { + MultiPart::alternative_plain_html(body_text, body_html) + }; + let email = Message::builder() .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::>()[1]))) .to(Mailbox::new(None, Address::from_str(address)?)) .from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?)) .subject(subject) - .multipart(MultiPart::alternative_plain_html(body_text, body_html))?; + .multipart(body)?; match mailer().send(email).await { Ok(_) => Ok(()), diff --git a/src/static/templates/email/email_footer.hbs b/src/static/templates/email/email_footer.hbs index 33177317..7bf30682 100644 --- a/src/static/templates/email/email_footer.hbs +++ b/src/static/templates/email/email_footer.hbs @@ -10,7 +10,7 @@ - +
GitHubGitHub
diff --git a/src/static/templates/email/email_header.hbs b/src/static/templates/email/email_header.hbs index a1e7cc27..811f997d 100644 --- a/src/static/templates/email/email_header.hbs +++ b/src/static/templates/email/email_header.hbs @@ -81,7 +81,7 @@ From 4289663a1697bd8d78743e2c8eaef255cb811e1b Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Thu, 6 Oct 2022 11:59:47 +0200 Subject: [PATCH 02/15] use static_files() for email attachments Apply suggestions from code review Co-authored-by: Mathijs van Veluw --- src/api/mod.rs | 1 + src/api/web.rs | 2 +- src/config.rs | 2 +- src/mail.rs | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index b9e9f38c..7bff978b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ pub use crate::api::{ notifications::{start_notification_server, Notify, UpdateType}, web::catchers as web_catchers, web::routes as web_routes, + web::static_files, }; use crate::util; diff --git a/src/api/web.rs b/src/api/web.rs index 2ad94db8..cfc4b9e0 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -89,7 +89,7 @@ fn alive(_conn: DbConn) -> Json { } #[get("/vw_static/")] -fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { +pub fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { match filename.as_ref() { "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), diff --git a/src/config.rs b/src/config.rs index 4cac70eb..936f15df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -767,7 +767,7 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { "cid:".to_string() } else { - format!("{}/vw_static/", domain) + format!("{domain}/vw_static/") } } diff --git a/src/mail.rs b/src/mail.rs index e613da6f..fce76e17 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -496,11 +496,11 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { - let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); - let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); let body = if CONFIG.smtp_embed_images() { + let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png".to_string()).unwrap().1.to_vec()); + let mail_github_body = Body::new(crate::api::static_files("mail-github.png".to_string()).unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) From 2215cfefb9d2affd55a5773bde49b37efeb11a32 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 27 Sep 2022 23:19:35 +0200 Subject: [PATCH 03/15] fix invitations of new users when mail is disabled If you add a new user that has already been Invited to another organization they will be Accepted automatically. This should not be possible because they cannot be Confirmed until they have completed their registration. It is also not necessary because their invitation will be accepted automatically once they register. --- src/api/core/organizations.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dca4f393..9f2178e7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -600,11 +600,7 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = if CONFIG.mail_enabled() { - UserOrgStatus::Invited as i32 - } else { - UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites - }; + let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -622,13 +618,16 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi let mut user = User::new(email.clone()); user.save(&conn).await?; - user_org_status = UserOrgStatus::Invited as i32; user } Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {}", email)) } else { + // automatically accept existing users if mail is disabled + if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { + user_org_status = UserOrgStatus::Accepted as i32; + } user } } From f41ba2a60f161dde69f0a42ad9cf5d896a64e874 Mon Sep 17 00:00:00 2001 From: BlackDex Date: Mon, 17 Oct 2022 17:23:21 +0200 Subject: [PATCH 04/15] Fix master password hint update not working. - The Master Password Hint input has changed it's location to the password update form. This PR updates the the code to process this. - Also changed the `ProfileData` struct to exclude `Culture` and `MasterPasswordHint`, since both are not used at all, and when not defined they will also not be allocated. Fixes #2833 --- src/api/core/accounts.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a980271b..a43ca4b0 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -193,9 +193,8 @@ async fn profile(headers: Headers, conn: DbConn) -> Json { #[derive(Deserialize, Debug)] #[allow(non_snake_case)] struct ProfileData { - #[serde(rename = "Culture")] - _Culture: String, // Ignored, always use en-US - MasterPasswordHint: Option, + // Culture: String, // Ignored, always use en-US + // MasterPasswordHint: Option, // Ignored, has been moved to ChangePassData Name: String, } @@ -216,8 +215,6 @@ async fn post_profile(data: JsonUpcase, headers: Headers, conn: DbC let mut user = headers.user; user.name = data.Name; - user.password_hint = clean_password_hint(&data.MasterPasswordHint); - enforce_password_hint_setting(&user.password_hint)?; user.save(&conn).await?; Ok(Json(user.to_json(&conn).await)) @@ -260,6 +257,7 @@ async fn post_keys(data: JsonUpcase, headers: Headers, conn: DbConn) - struct ChangePassData { MasterPasswordHash: String, NewMasterPasswordHash: String, + MasterPasswordHint: Option, Key: String, } @@ -272,6 +270,9 @@ async fn post_password(data: JsonUpcase, headers: Headers, conn: err!("Invalid password") } + user.password_hint = clean_password_hint(&data.MasterPasswordHint); + enforce_password_hint_setting(&user.password_hint)?; + user.set_password( &data.NewMasterPasswordHash, Some(vec![String::from("post_rotatekey"), String::from("get_contacts"), String::from("get_public_keys")]), From aa5a05960ed504de9df41c58d454467c132c00bf Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Thu, 6 Oct 2022 00:18:20 +0200 Subject: [PATCH 05/15] allow registration without invite link if signups are allowed invited users should be able to complete their registration even when they don't have the invite link at hand. --- src/api/core/accounts.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a980271b..02390761 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -101,11 +101,7 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { - if CONFIG.is_signup_allowed(&email) { - err!("User already exists") - } else { - err!("Registration not allowed or user already exists") - } + err!("Registration not allowed or user already exists") } if let Some(token) = data.Token { @@ -121,10 +117,10 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { user_org.save(&conn).await?; } user - } else if EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some() { + } else if CONFIG.is_signup_allowed(&email) + || EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some() + { user - } else if CONFIG.is_signup_allowed(&email) { - err!("Account with this email already exists") } else { err!("Registration not allowed or user already exists") } From ed6e8529048d5272a37e88bc5a5e65988c88f081 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 27 Sep 2022 23:19:35 +0200 Subject: [PATCH 06/15] fix invitations of new users when mail is disabled If you add a new user that has already been Invited to another organization they will be Accepted automatically. This should not be possible because they cannot be Confirmed until they have completed their registration. It is also not necessary because their invitation will be accepted automatically once they register. --- src/api/core/organizations.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dca4f393..9f2178e7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -600,11 +600,7 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = if CONFIG.mail_enabled() { - UserOrgStatus::Invited as i32 - } else { - UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites - }; + let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -622,13 +618,16 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi let mut user = User::new(email.clone()); user.save(&conn).await?; - user_org_status = UserOrgStatus::Invited as i32; user } Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {}", email)) } else { + // automatically accept existing users if mail is disabled + if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { + user_org_status = UserOrgStatus::Accepted as i32; + } user } } From a2d716aec34bd5b480af8bc943a497040282043c Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 27 Sep 2022 23:19:35 +0200 Subject: [PATCH 07/15] fix invitations of new users when mail is disabled If you add a new user that has already been Invited to another organization they will be Accepted automatically. This should not be possible because they cannot be Confirmed until they have completed their registration. It is also not necessary because their invitation will be accepted automatically once they register. --- src/api/core/organizations.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dca4f393..9f2178e7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -600,11 +600,7 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = if CONFIG.mail_enabled() { - UserOrgStatus::Invited as i32 - } else { - UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites - }; + let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -622,13 +618,16 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi let mut user = User::new(email.clone()); user.save(&conn).await?; - user_org_status = UserOrgStatus::Invited as i32; user } Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {}", email)) } else { + // automatically accept existing users if mail is disabled + if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { + user_org_status = UserOrgStatus::Accepted as i32; + } user } } From a0c6a7c0de29ea18590ed506ba7dd9b2e49c1511 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 30 Sep 2022 19:14:26 +0200 Subject: [PATCH 08/15] attach images to email Set SMTP_EMBED_IMAGES option to false if you don't want to attach images to the mail. NOTE: If you have customized the template files `email_header.hbs` and `email_footer.hbs` you can replace `{url}/vw_static/` to `{img_url}` to support both URL schemes --- .env.template | 3 ++ src/config.rs | 12 +++++ src/mail.rs | 55 +++++++++++++++++++-- src/static/templates/email/email_footer.hbs | 2 +- src/static/templates/email/email_header.hbs | 2 +- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 60b5b73b..1e5ff101 100644 --- a/.env.template +++ b/.env.template @@ -367,6 +367,9 @@ ## but might need to be changed in case it trips some anti-spam filters # HELO_NAME= +## Embed images as email attachments +# SMTP_EMBED_IMAGES=false + ## SMTP debugging ## When set to true this will output very detailed SMTP messages. ## WARNING: This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! diff --git a/src/config.rs b/src/config.rs index 3a2cf958..4cac70eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -602,6 +602,10 @@ make_config! { smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; + /// Embed images as email attachments. + smtp_embed_images: bool, true, def, true; + /// Internal + _smtp_img_src: String, false, gen, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! smtp_debug: bool, false, def, false; /// Accept Invalid Certs (Know the risks!) |> DANGEROUS: Allow invalid certificates. This option introduces significant vulnerabilities to man-in-the-middle attacks! @@ -759,6 +763,14 @@ fn extract_url_path(url: &str) -> String { } } +fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { + if embed_images { + "cid:".to_string() + } else { + format!("{}/vw_static/", domain) + } +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { diff --git a/src/mail.rs b/src/mail.rs index 5cc12658..e613da6f 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -4,7 +4,7 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use lettre::{ - message::{Mailbox, Message, MultiPart}, + message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, @@ -117,7 +117,14 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes "email/pw_hint_none" }; - let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?; + let (subject, body_html, body_text) = get_text( + template_name, + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "hint": hint, + }), + )?; send_email(address, &subject, body_html, body_text).await } @@ -130,6 +137,7 @@ pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult { "email/delete_account", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": delete_token, @@ -147,6 +155,7 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult { "email/verify_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": verify_email_token, @@ -161,6 +170,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { "email/welcome", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -175,6 +185,7 @@ pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult "email/welcome_must_verify", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "token": verify_email_token, }), @@ -188,6 +199,7 @@ pub async fn send_2fa_removed_from_org(address: &str, org_name: &str) -> EmptyRe "email/send_2fa_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -200,6 +212,7 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> "email/send_single_org_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -228,6 +241,7 @@ pub async fn send_invite( "email/send_org_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_id": org_id.as_deref().unwrap_or("_"), "org_user_id": org_user_id.as_deref().unwrap_or("_"), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), @@ -260,6 +274,7 @@ pub async fn send_emergency_access_invite( "email/send_emergency_access_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "emer_id": emer_id.unwrap_or_else(|| "_".to_string()), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "grantor_name": grantor_name, @@ -275,6 +290,7 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: "email/emergency_access_invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_email": grantee_email, }), )?; @@ -287,6 +303,7 @@ pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: "email/emergency_access_invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -299,6 +316,7 @@ pub async fn send_emergency_access_recovery_approved(address: &str, grantor_name "email/emergency_access_recovery_approved", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -316,6 +334,7 @@ pub async fn send_emergency_access_recovery_initiated( "email/emergency_access_recovery_initiated", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "wait_time_days": wait_time_days, @@ -335,6 +354,7 @@ pub async fn send_emergency_access_recovery_reminder( "email/emergency_access_recovery_reminder", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "days_left": days_left, @@ -349,6 +369,7 @@ pub async fn send_emergency_access_recovery_rejected(address: &str, grantor_name "email/emergency_access_recovery_rejected", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -361,6 +382,7 @@ pub async fn send_emergency_access_recovery_timed_out(address: &str, grantee_nam "email/emergency_access_recovery_timed_out", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, }), @@ -374,6 +396,7 @@ pub async fn send_invite_accepted(new_user_email: &str, address: &str, org_name: "email/invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "email": new_user_email, "org_name": org_name, }), @@ -387,6 +410,7 @@ pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult "email/invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -403,6 +427,7 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi "email/new_device_logged_in", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -421,6 +446,7 @@ pub async fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTi "email/incomplete_2fa_login", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -436,6 +462,7 @@ pub async fn send_token(address: &str, token: &str) -> EmptyResult { "email/twofactor_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -448,6 +475,7 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { "email/change_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -460,6 +488,7 @@ pub async fn send_test(address: &str) -> EmptyResult { "email/smtp_test", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -467,13 +496,33 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { + let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); + let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); + + let body = if CONFIG.smtp_embed_images() { + MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( + MultiPart::related() + .singlepart(SinglePart::html(body_html)) + .singlepart( + Attachment::new_inline(String::from("logo-gray.png")) + .body(logo_gray_body, "image/png".parse().unwrap()), + ) + .singlepart( + Attachment::new_inline(String::from("mail-github.png")) + .body(mail_github_body, "image/png".parse().unwrap()), + ), + ) + } else { + MultiPart::alternative_plain_html(body_text, body_html) + }; + let email = Message::builder() .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::>()[1]))) .to(Mailbox::new(None, Address::from_str(address)?)) .from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?)) .subject(subject) - .multipart(MultiPart::alternative_plain_html(body_text, body_html))?; + .multipart(body)?; match mailer().send(email).await { Ok(_) => Ok(()), diff --git a/src/static/templates/email/email_footer.hbs b/src/static/templates/email/email_footer.hbs index 33177317..7bf30682 100644 --- a/src/static/templates/email/email_footer.hbs +++ b/src/static/templates/email/email_footer.hbs @@ -10,7 +10,7 @@ diff --git a/src/static/templates/email/email_header.hbs b/src/static/templates/email/email_header.hbs index a1e7cc27..811f997d 100644 --- a/src/static/templates/email/email_header.hbs +++ b/src/static/templates/email/email_header.hbs @@ -81,7 +81,7 @@
From 3b9bfe55d05d43172cd19afb9563b163c8c75b1e Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Thu, 6 Oct 2022 11:59:47 +0200 Subject: [PATCH 09/15] use static_files() for email attachments Apply suggestions from code review Co-authored-by: Mathijs van Veluw --- src/api/mod.rs | 1 + src/api/web.rs | 2 +- src/config.rs | 2 +- src/mail.rs | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index b9e9f38c..7bff978b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ pub use crate::api::{ notifications::{start_notification_server, Notify, UpdateType}, web::catchers as web_catchers, web::routes as web_routes, + web::static_files, }; use crate::util; diff --git a/src/api/web.rs b/src/api/web.rs index 2ad94db8..cfc4b9e0 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -89,7 +89,7 @@ fn alive(_conn: DbConn) -> Json { } #[get("/vw_static/")] -fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { +pub fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { match filename.as_ref() { "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), diff --git a/src/config.rs b/src/config.rs index 4cac70eb..936f15df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -767,7 +767,7 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { "cid:".to_string() } else { - format!("{}/vw_static/", domain) + format!("{domain}/vw_static/") } } diff --git a/src/mail.rs b/src/mail.rs index e613da6f..fce76e17 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -496,11 +496,11 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { - let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); - let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); let body = if CONFIG.smtp_embed_images() { + let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png".to_string()).unwrap().1.to_vec()); + let mail_github_body = Body::new(crate::api::static_files("mail-github.png".to_string()).unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) From 6576914e5541b49181c3a7bfd987b5c15fe81a58 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 27 Sep 2022 23:19:35 +0200 Subject: [PATCH 10/15] fix invitations of new users when mail is disabled If you add a new user that has already been Invited to another organization they will be Accepted automatically. This should not be possible because they cannot be Confirmed until they have completed their registration. It is also not necessary because their invitation will be accepted automatically once they register. --- src/api/core/organizations.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/api/core/organizations.rs b/src/api/core/organizations.rs index dca4f393..9f2178e7 100644 --- a/src/api/core/organizations.rs +++ b/src/api/core/organizations.rs @@ -600,11 +600,7 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi for email in data.Emails.iter() { let email = email.to_lowercase(); - let mut user_org_status = if CONFIG.mail_enabled() { - UserOrgStatus::Invited as i32 - } else { - UserOrgStatus::Accepted as i32 // Automatically mark user as accepted if no email invites - }; + let mut user_org_status = UserOrgStatus::Invited as i32; let user = match User::find_by_mail(&email, &conn).await { None => { if !CONFIG.invitations_allowed() { @@ -622,13 +618,16 @@ async fn send_invite(org_id: String, data: JsonUpcase, headers: Admi let mut user = User::new(email.clone()); user.save(&conn).await?; - user_org_status = UserOrgStatus::Invited as i32; user } Some(user) => { if UserOrganization::find_by_user_and_org(&user.uuid, &org_id, &conn).await.is_some() { err!(format!("User already in organization: {}", email)) } else { + // automatically accept existing users if mail is disabled + if !CONFIG.mail_enabled() && !user.password_hash.is_empty() { + user_org_status = UserOrgStatus::Accepted as i32; + } user } } From 4d1b860dada9bf56d71aa8beea87419fb0e087d3 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 30 Sep 2022 19:14:26 +0200 Subject: [PATCH 11/15] attach images to email Set SMTP_EMBED_IMAGES option to false if you don't want to attach images to the mail. NOTE: If you have customized the template files `email_header.hbs` and `email_footer.hbs` you can replace `{url}/vw_static/` to `{img_url}` to support both URL schemes --- .env.template | 3 ++ src/config.rs | 12 +++++ src/mail.rs | 55 +++++++++++++++++++-- src/static/templates/email/email_footer.hbs | 2 +- src/static/templates/email/email_header.hbs | 2 +- 5 files changed, 69 insertions(+), 5 deletions(-) diff --git a/.env.template b/.env.template index 60b5b73b..1e5ff101 100644 --- a/.env.template +++ b/.env.template @@ -367,6 +367,9 @@ ## but might need to be changed in case it trips some anti-spam filters # HELO_NAME= +## Embed images as email attachments +# SMTP_EMBED_IMAGES=false + ## SMTP debugging ## When set to true this will output very detailed SMTP messages. ## WARNING: This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! diff --git a/src/config.rs b/src/config.rs index 3a2cf958..4cac70eb 100644 --- a/src/config.rs +++ b/src/config.rs @@ -602,6 +602,10 @@ make_config! { smtp_timeout: u64, true, def, 15; /// Server name sent during HELO |> By default this value should be is on the machine's hostname, but might need to be changed in case it trips some anti-spam filters helo_name: String, true, option; + /// Embed images as email attachments. + smtp_embed_images: bool, true, def, true; + /// Internal + _smtp_img_src: String, false, gen, |c| generate_smtp_img_src(c.smtp_embed_images, &c.domain); /// Enable SMTP debugging (Know the risks!) |> DANGEROUS: Enabling this will output very detailed SMTP messages. This could contain sensitive information like passwords and usernames! Only enable this during troubleshooting! smtp_debug: bool, false, def, false; /// Accept Invalid Certs (Know the risks!) |> DANGEROUS: Allow invalid certificates. This option introduces significant vulnerabilities to man-in-the-middle attacks! @@ -759,6 +763,14 @@ fn extract_url_path(url: &str) -> String { } } +fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { + if embed_images { + "cid:".to_string() + } else { + format!("{}/vw_static/", domain) + } +} + /// Generate the correct URL for the icon service. /// This will be used within icons.rs to call the external icon service. fn generate_icon_service_url(icon_service: &str) -> String { diff --git a/src/mail.rs b/src/mail.rs index 5cc12658..e613da6f 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -4,7 +4,7 @@ use chrono::NaiveDateTime; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use lettre::{ - message::{Mailbox, Message, MultiPart}, + message::{Attachment, Body, Mailbox, Message, MultiPart, SinglePart}, transport::smtp::authentication::{Credentials, Mechanism as SmtpAuthMechanism}, transport::smtp::client::{Tls, TlsParameters}, transport::smtp::extension::ClientId, @@ -117,7 +117,14 @@ pub async fn send_password_hint(address: &str, hint: Option) -> EmptyRes "email/pw_hint_none" }; - let (subject, body_html, body_text) = get_text(template_name, json!({ "hint": hint, "url": CONFIG.domain() }))?; + let (subject, body_html, body_text) = get_text( + template_name, + json!({ + "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), + "hint": hint, + }), + )?; send_email(address, &subject, body_html, body_text).await } @@ -130,6 +137,7 @@ pub async fn send_delete_account(address: &str, uuid: &str) -> EmptyResult { "email/delete_account", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": delete_token, @@ -147,6 +155,7 @@ pub async fn send_verify_email(address: &str, uuid: &str) -> EmptyResult { "email/verify_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "token": verify_email_token, @@ -161,6 +170,7 @@ pub async fn send_welcome(address: &str) -> EmptyResult { "email/welcome", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -175,6 +185,7 @@ pub async fn send_welcome_must_verify(address: &str, uuid: &str) -> EmptyResult "email/welcome_must_verify", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "user_id": uuid, "token": verify_email_token, }), @@ -188,6 +199,7 @@ pub async fn send_2fa_removed_from_org(address: &str, org_name: &str) -> EmptyRe "email/send_2fa_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -200,6 +212,7 @@ pub async fn send_single_org_removed_from_org(address: &str, org_name: &str) -> "email/send_single_org_removed_from_org", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -228,6 +241,7 @@ pub async fn send_invite( "email/send_org_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_id": org_id.as_deref().unwrap_or("_"), "org_user_id": org_user_id.as_deref().unwrap_or("_"), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), @@ -260,6 +274,7 @@ pub async fn send_emergency_access_invite( "email/send_emergency_access_invite", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "emer_id": emer_id.unwrap_or_else(|| "_".to_string()), "email": percent_encode(address.as_bytes(), NON_ALPHANUMERIC).to_string(), "grantor_name": grantor_name, @@ -275,6 +290,7 @@ pub async fn send_emergency_access_invite_accepted(address: &str, grantee_email: "email/emergency_access_invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_email": grantee_email, }), )?; @@ -287,6 +303,7 @@ pub async fn send_emergency_access_invite_confirmed(address: &str, grantor_name: "email/emergency_access_invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -299,6 +316,7 @@ pub async fn send_emergency_access_recovery_approved(address: &str, grantor_name "email/emergency_access_recovery_approved", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -316,6 +334,7 @@ pub async fn send_emergency_access_recovery_initiated( "email/emergency_access_recovery_initiated", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "wait_time_days": wait_time_days, @@ -335,6 +354,7 @@ pub async fn send_emergency_access_recovery_reminder( "email/emergency_access_recovery_reminder", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, "days_left": days_left, @@ -349,6 +369,7 @@ pub async fn send_emergency_access_recovery_rejected(address: &str, grantor_name "email/emergency_access_recovery_rejected", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantor_name": grantor_name, }), )?; @@ -361,6 +382,7 @@ pub async fn send_emergency_access_recovery_timed_out(address: &str, grantee_nam "email/emergency_access_recovery_timed_out", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "grantee_name": grantee_name, "atype": atype, }), @@ -374,6 +396,7 @@ pub async fn send_invite_accepted(new_user_email: &str, address: &str, org_name: "email/invite_accepted", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "email": new_user_email, "org_name": org_name, }), @@ -387,6 +410,7 @@ pub async fn send_invite_confirmed(address: &str, org_name: &str) -> EmptyResult "email/invite_confirmed", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "org_name": org_name, }), )?; @@ -403,6 +427,7 @@ pub async fn send_new_device_logged_in(address: &str, ip: &str, dt: &NaiveDateTi "email/new_device_logged_in", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -421,6 +446,7 @@ pub async fn send_incomplete_2fa_login(address: &str, ip: &str, dt: &NaiveDateTi "email/incomplete_2fa_login", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "ip": ip, "device": device, "datetime": crate::util::format_naive_datetime_local(dt, fmt), @@ -436,6 +462,7 @@ pub async fn send_token(address: &str, token: &str) -> EmptyResult { "email/twofactor_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -448,6 +475,7 @@ pub async fn send_change_email(address: &str, token: &str) -> EmptyResult { "email/change_email", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), "token": token, }), )?; @@ -460,6 +488,7 @@ pub async fn send_test(address: &str) -> EmptyResult { "email/smtp_test", json!({ "url": CONFIG.domain(), + "img_src": CONFIG._smtp_img_src(), }), )?; @@ -467,13 +496,33 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { + let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); + let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); + + let body = if CONFIG.smtp_embed_images() { + MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( + MultiPart::related() + .singlepart(SinglePart::html(body_html)) + .singlepart( + Attachment::new_inline(String::from("logo-gray.png")) + .body(logo_gray_body, "image/png".parse().unwrap()), + ) + .singlepart( + Attachment::new_inline(String::from("mail-github.png")) + .body(mail_github_body, "image/png".parse().unwrap()), + ), + ) + } else { + MultiPart::alternative_plain_html(body_text, body_html) + }; + let email = Message::builder() .message_id(Some(format!("<{}@{}>", crate::util::get_uuid(), smtp_from.split('@').collect::>()[1]))) .to(Mailbox::new(None, Address::from_str(address)?)) .from(Mailbox::new(Some(CONFIG.smtp_from_name()), Address::from_str(smtp_from)?)) .subject(subject) - .multipart(MultiPart::alternative_plain_html(body_text, body_html))?; + .multipart(body)?; match mailer().send(email).await { Ok(_) => Ok(()), diff --git a/src/static/templates/email/email_footer.hbs b/src/static/templates/email/email_footer.hbs index 33177317..7bf30682 100644 --- a/src/static/templates/email/email_footer.hbs +++ b/src/static/templates/email/email_footer.hbs @@ -10,7 +10,7 @@ diff --git a/src/static/templates/email/email_header.hbs b/src/static/templates/email/email_header.hbs index a1e7cc27..811f997d 100644 --- a/src/static/templates/email/email_header.hbs +++ b/src/static/templates/email/email_header.hbs @@ -81,7 +81,7 @@
From 0e6f6e612ab22c975ea9cbedaac17c403e691ee7 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Thu, 6 Oct 2022 11:59:47 +0200 Subject: [PATCH 12/15] use static_files() for email attachments Apply suggestions from code review Co-authored-by: Mathijs van Veluw --- src/api/mod.rs | 1 + src/api/web.rs | 2 +- src/config.rs | 2 +- src/mail.rs | 4 ++-- 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/api/mod.rs b/src/api/mod.rs index b9e9f38c..7bff978b 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -22,6 +22,7 @@ pub use crate::api::{ notifications::{start_notification_server, Notify, UpdateType}, web::catchers as web_catchers, web::routes as web_routes, + web::static_files, }; use crate::util; diff --git a/src/api/web.rs b/src/api/web.rs index 2ad94db8..cfc4b9e0 100644 --- a/src/api/web.rs +++ b/src/api/web.rs @@ -89,7 +89,7 @@ fn alive(_conn: DbConn) -> Json { } #[get("/vw_static/")] -fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { +pub fn static_files(filename: String) -> Result<(ContentType, &'static [u8]), Error> { match filename.as_ref() { "mail-github.png" => Ok((ContentType::PNG, include_bytes!("../static/images/mail-github.png"))), "logo-gray.png" => Ok((ContentType::PNG, include_bytes!("../static/images/logo-gray.png"))), diff --git a/src/config.rs b/src/config.rs index 4cac70eb..936f15df 100644 --- a/src/config.rs +++ b/src/config.rs @@ -767,7 +767,7 @@ fn generate_smtp_img_src(embed_images: bool, domain: &str) -> String { if embed_images { "cid:".to_string() } else { - format!("{}/vw_static/", domain) + format!("{domain}/vw_static/") } } diff --git a/src/mail.rs b/src/mail.rs index e613da6f..fce76e17 100644 --- a/src/mail.rs +++ b/src/mail.rs @@ -496,11 +496,11 @@ pub async fn send_test(address: &str) -> EmptyResult { } async fn send_email(address: &str, subject: &str, body_html: String, body_text: String) -> EmptyResult { - let logo_gray_body = Body::new(include_bytes!("static/images/logo-gray.png").to_vec()); - let mail_github_body = Body::new(include_bytes!("static/images/mail-github.png").to_vec()); let smtp_from = &CONFIG.smtp_from(); let body = if CONFIG.smtp_embed_images() { + let logo_gray_body = Body::new(crate::api::static_files("logo-gray.png".to_string()).unwrap().1.to_vec()); + let mail_github_body = Body::new(crate::api::static_files("mail-github.png".to_string()).unwrap().1.to_vec()); MultiPart::alternative().singlepart(SinglePart::plain(body_text)).multipart( MultiPart::related() .singlepart(SinglePart::html(body_html)) From 23f1f8a5768c1f361cd83f0f4035b329848d5e3a Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Thu, 6 Oct 2022 00:18:20 +0200 Subject: [PATCH 13/15] allow registration without invite link if signups are allowed invited users should be able to complete their registration even when they don't have the invite link at hand. --- src/api/core/accounts.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index a43ca4b0..df8bfccf 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -101,11 +101,7 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { let mut user = match User::find_by_mail(&email, &conn).await { Some(user) => { if !user.password_hash.is_empty() { - if CONFIG.is_signup_allowed(&email) { - err!("User already exists") - } else { - err!("Registration not allowed or user already exists") - } + err!("Registration not allowed or user already exists") } if let Some(token) = data.Token { @@ -121,10 +117,10 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { user_org.save(&conn).await?; } user - } else if EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some() { + } else if CONFIG.is_signup_allowed(&email) + || EmergencyAccess::find_invited_by_grantee_email(&email, &conn).await.is_some() + { user - } else if CONFIG.is_signup_allowed(&email) { - err!("Account with this email already exists") } else { err!("Registration not allowed or user already exists") } From 0c267d073f35180e9913fffdbd777cd949a7e436 Mon Sep 17 00:00:00 2001 From: Jeremy Lin Date: Wed, 19 Oct 2022 12:30:42 -0700 Subject: [PATCH 14/15] Sync global_domains.json to bitwarden/server@ea300b2 (Amazon) --- src/static/global_domains.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/static/global_domains.json b/src/static/global_domains.json index 06df70a3..20a60949 100644 --- a/src/static/global_domains.json +++ b/src/static/global_domains.json @@ -186,6 +186,7 @@ "Type": 18, "Domains": [ "amazon.com", + "amazon.com.be", "amazon.ae", "amazon.ca", "amazon.co.uk", From 64ae5d4f8188b16a51fe7d96a023d08fc0e13c69 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 7 Oct 2022 07:26:53 +0200 Subject: [PATCH 15/15] verify email on registration via invite link if `SIGNUPS_VERIFY` is enabled new users that have been invited have their onboarding flow interrupted because they have to first verify their mail address before they can join an organization. we can skip the extra verication of the email address when signing up because a valid invitation token already means that the email address is working and we don't allow invited users to signup with a different address. unfortunately, this is not possible with emergency access invitations at the moment as they are handled differently. --- src/api/core/accounts.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index df8bfccf..252c2c99 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -98,8 +98,10 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { let password_hint = clean_password_hint(&data.MasterPasswordHint); enforce_password_hint_setting(&password_hint)?; + let mut verified_by_invite = false; + let mut user = match User::find_by_mail(&email, &conn).await { - Some(user) => { + Some(mut user) => { if !user.password_hash.is_empty() { err!("Registration not allowed or user already exists") } @@ -107,6 +109,9 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { if let Some(token) = data.Token { let claims = decode_invite(&token)?; if claims.email == email { + // Verify the email address when signing up via a valid invite token + verified_by_invite = true; + user.verified_at = Some(Utc::now().naive_utc()); user } else { err!("Registration email does not match invite email") @@ -163,7 +168,7 @@ async fn register(data: JsonUpcase, conn: DbConn) -> JsonResult { } if CONFIG.mail_enabled() { - if CONFIG.signups_verify() { + if CONFIG.signups_verify() && !verified_by_invite { if let Err(e) = mail::send_welcome_must_verify(&user.email, &user.uuid).await { error!("Error sending welcome email: {:#?}", e); }