From ea19c2250e0812659461c8abaf8f1a85e69c7423 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Fri, 30 Sep 2022 19:14:26 +0200 Subject: [PATCH 1/3] 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 2/3] 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 ed6e8529048d5272a37e88bc5a5e65988c88f081 Mon Sep 17 00:00:00 2001 From: Stefan Melmuk Date: Tue, 27 Sep 2022 23:19:35 +0200 Subject: [PATCH 3/3] 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 } }