From e05114eacaac975d906341a7e4c3dc55b082c04b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 09:28:11 +0100 Subject: [PATCH 01/13] MFA activation mail --- Cargo.lock | 24 +-- crates/defguard_core/src/handlers/auth.rs | 19 +- crates/defguard_core/src/handlers/mail.rs | 25 --- crates/defguard_mail/src/mail.rs | 4 +- crates/defguard_mail/src/templates.rs | 25 ++- crates/defguard_mail/src/tests.rs | 198 ++++++++++-------- .../templates/mail_desktop_start.tera | 15 -- .../templates/mfa-activation.mjml | 45 ++++ .../src/servers/enrollment.rs | 33 +-- .../20260323081850_[2.0.0]_more_mjml.down.sql | 1 + .../20260323081850_[2.0.0]_more_mjml.up.sql | 4 + 11 files changed, 225 insertions(+), 168 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_desktop_start.tera create mode 100644 crates/defguard_mail/templates/mfa-activation.mjml create mode 100644 migrations/20260323081850_[2.0.0]_more_mjml.down.sql create mode 100644 migrations/20260323081850_[2.0.0]_more_mjml.up.sql diff --git a/Cargo.lock b/Cargo.lock index fab47d9761..a8602aa676 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3084,9 +3084,9 @@ dependencies = [ [[package]] name = "iri-string" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" dependencies = [ "memchr", "serde", @@ -4679,9 +4679,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.1" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83c41efbf8f90ac44de7f3a868f0867851d261b56291732d0cbf7cceaaeb55a6" +checksum = "7c3a14896dfa883796f1cb410461aef38810ea05f2b2c33c5aded3649095fdad" dependencies = [ "bitflags 2.11.0", "getopts", @@ -4801,9 +4801,9 @@ dependencies = [ [[package]] name = "quoted_printable" -version = "0.5.1" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" +checksum = "478e0585659a122aa407eb7e3c0e1fa51b1d8a870038bd29f0cf4a8551eea972" [[package]] name = "r-efi" @@ -5271,9 +5271,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", @@ -5507,9 +5507,9 @@ dependencies = [ [[package]] name = "serde_qs" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac22439301a0b6f45a037681518e3169e8db1db76080e2e9600a08d1027df037" +checksum = "3c742cd44662647326f86b514eadcc227fff4ce684dbbdaf1943f758d5ea058c" dependencies = [ "itoa", "percent-encoding", @@ -7949,9 +7949,9 @@ dependencies = [ [[package]] name = "zune-jpeg" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" +checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6" dependencies = [ "zune-core", ] diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 4768a472d2..241bc4c830 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -20,7 +20,7 @@ use defguard_common::{ }, types::user_info::UserInfo, }; -use defguard_mail::templates::mfa_code_mail; +use defguard_mail::templates::{mfa_activation_mail, mfa_code_mail}; use sqlx::{PgPool, types::Uuid}; use time::Duration; use uaparser::Parser; @@ -41,9 +41,7 @@ use crate::{ error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ - SIGN_IN_COOKIE_NAME, cookie_domain, - mail::{send_email_mfa_activation_email, send_mfa_configured_email}, - user_for_admin_or_self, + SIGN_IN_COOKIE_NAME, cookie_domain, mail::send_mfa_configured_email, user_for_admin_or_self, }, headers::{USER_AGENT_PARSER, check_new_device_login, get_user_agent_device}, server_config, @@ -790,8 +788,19 @@ pub async fn email_mfa_init(session: SessionInfo, State(appstate): State, - session: Option<&SessionContext>, -) -> Result<(), TemplateError> { - debug!("Sending email MFA activation mail to {}", user.email); - - // generate a verification code - let code = user.generate_email_mfa_code().map_err(|err| { - error!("Failed to generate email MFA code: {err}"); - TemplateError::MfaError - })?; - - Mail::new( - &user.email, - EMAIL_MFA_ACTIVATION_EMAIL_SUBJECT, - templates::email_mfa_activation_mail(&user.into(), &code, session)?, - ) - .send_and_forget(); - - Ok(()) -} - pub fn send_password_reset_email( user: &User, service_url: Url, diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 62ae0e8a9b..5e73f46d22 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -352,7 +352,7 @@ impl MailMessage { // Self::NewDeviceOCIDLogin => "", // Self::GatewayDisconnect => "", // Self::GatewayReconnect => "", - // Self::MFAActivation => "", + Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), // Self::MFAConfigured => "", Self::MFACode => include_str!("../templates/mfa-code.mjml"), // Self::PasswordReset => "", @@ -404,7 +404,7 @@ impl MailMessage { } } } - Self::MFACode => { + Self::MFACode | Self::MFAActivation => { mail.add_png_image("date", DATE_ICON); mail.add_png_image("otp", OTP_ICON); } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 34111807d6..8fa287594e 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -41,8 +41,6 @@ static MAIL_MFA_CONFIGURED: &str = include_str!("../templates/mail_mfa_configure static MAIL_NEW_DEVICE_LOGIN: &str = include_str!("../templates/mail_new_device_login.tera"); static MAIL_NEW_DEVICE_OCID_LOGIN: &str = include_str!("../templates/mail_new_device_ocid_login.tera"); -static MAIL_EMAIL_MFA_ACTIVATION: &str = - include_str!("../templates/mail_email_mfa_activation.tera"); static MAIL_PASSWORD_RESET_START: &str = include_str!("../templates/mail_password_reset_start.tera"); static MAIL_PASSWORD_RESET_SUCCESS: &str = @@ -407,22 +405,31 @@ pub fn gateway_reconnected_mail( Ok(tera.render("mail_gateway_reconnected", &context)?) } -pub fn email_mfa_activation_mail( - user: &UserContext, +pub async fn mfa_activation_mail( + to: &str, + conn: &mut PgConnection, + first_name: &str, code: &str, session: Option<&SessionContext>, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), session, None, None)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; let settings = Settings::get_current_settings(); let timeout = humantime::format_duration(Duration::from_secs( settings.mfa_code_timeout_seconds as u64, )); context.insert("code", code); context.insert("timeout", &timeout.to_string()); - context.insert("name", &user.first_name); - tera.add_raw_template("mail_email_mfa_activation", MAIL_EMAIL_MFA_ACTIVATION)?; + context.insert("username", first_name); + context.insert( + "datetime", + &Utc::now().format(MAIL_DATETIME_FORMAT).to_string(), + ); - Ok(tera.render("mail_email_mfa_activation", &context)?) + let message = MailMessage::MFAActivation; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } pub async fn mfa_code_mail( diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 1d153ca08d..7b65057809 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -19,7 +19,8 @@ use sqlx::{ use tera::Context; use super::templates::{ - TemplateLocation, desktop_start_mail, mfa_code_mail, new_account_mail, new_device_added_mail, + TemplateLocation, desktop_start_mail, mfa_activation_mail, mfa_code_mail, new_account_mail, + new_device_added_mail, }; /// Set SMTP settings from environment variables. @@ -39,75 +40,122 @@ async fn set_smtp_settings(pool: &PgPool) { set_settings(Some(settings)); } -#[ignore = "Requires SMTP server"] +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let context = Context::new(); +// let url = Url::parse("http://localhost:8000").unwrap(); +// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; +// desktop_start_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// context, +// &url, +// token, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let device_name = "My beloved machine"; +// let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; +// let locations = &[ +// TemplateLocation { +// name: String::from("Location 1"), +// assigned_ips: String::from("192.168.1.42"), +// }, +// TemplateLocation { +// name: String::from("Location 2"), +// assigned_ips: String::from("192.168.2.69"), +// }, +// ]; +// new_device_added_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// device_name, +// public_key, +// locations, +// Some("1.2.3.4"), +// Some("unknown device"), +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let first_name = "Nebuchadnezzar"; +// let code = "123456"; +// mfa_code_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// first_name, +// code, +// None, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let url = Url::parse("http://localhost:8001").unwrap(); +// let context = Context::new(); +// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; +// new_account_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// context, +// url, +// token, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +#[ignore = "requires SMTP server"] #[sqlx::test] -fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let context = Context::new(); - let url = Url::parse("http://localhost:8000").unwrap(); - let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - desktop_start_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - context, - &url, - token, - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} - -#[ignore = "Requires SMTP server"] -#[sqlx::test] -fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let device_name = "My beloved machine"; - let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; - let locations = &[ - TemplateLocation { - name: String::from("Location 1"), - assigned_ips: String::from("192.168.1.42"), - }, - TemplateLocation { - name: String::from("Location 2"), - assigned_ips: String::from("192.168.2.69"), - }, - ]; - new_device_added_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - device_name, - public_key, - locations, - Some("1.2.3.4"), - Some("unknown device"), - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} - -#[ignore = "Requires SMTP server"] -#[sqlx::test] -fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { +fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; set_smtp_settings(&pool).await; let mut conn = pool.begin().await.unwrap(); let first_name = "Nebuchadnezzar"; let code = "123456"; - mfa_code_mail( + mfa_activation_mail( &env::var("SMTP_TO").unwrap(), &mut conn, first_name, @@ -120,27 +168,3 @@ fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } - -#[ignore = "Requires SMTP server"] -#[sqlx::test] -fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let url = Url::parse("http://localhost:8001").unwrap(); - let context = Context::new(); - let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - new_account_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - context, - url, - token, - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} diff --git a/crates/defguard_mail/templates/mail_desktop_start.tera b/crates/defguard_mail/templates/mail_desktop_start.tera deleted file mode 100644 index 4abab68963..0000000000 --- a/crates/defguard_mail/templates/mail_desktop_start.tera +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="You're receiving this email to configure a new desktop client."), -macros::paragraph(content="Please paste this URL and token in your desktop client:"), -macros::paragraph(content="URL: " ~ url), -macros::paragraph(content="Token: " ~ token), -macros::spacer(height="20px"), -macros::paragraph(content="Or use link below"), -macros::spacer(height="20px"), -macros::button_link(href="defguard://addinstance?token=" ~ token ~ "&url=" ~ url, text="Configure your desktop client") -] %} -{{ macros::text_section(content_array=section_content)}} -{% endblock %} diff --git a/crates/defguard_mail/templates/mfa-activation.mjml b/crates/defguard_mail/templates/mfa-activation.mjml new file mode 100644 index 0000000000..c511b9dd52 --- /dev/null +++ b/crates/defguard_mail/templates/mfa-activation.mjml @@ -0,0 +1,45 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + +

+ {{ code }} +

+
+
+
+ + + + + + + + + + {{ code_is_valid }} + + + + + + + + + + + + + {{ datetime }} + + + + +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index fcf44e7979..f94768c791 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -25,14 +25,11 @@ use defguard_core::{ client_version::ClientFeature, utils::{build_device_config_response, parse_client_ip_agent}, }, - handlers::{ - mail::{send_email_mfa_activation_email, send_mfa_configured_email}, - user::check_password_strength, - }, + handlers::{mail::send_mfa_configured_email, user::check_password_strength}, headers::get_device_info, is_valid_phone_number, }; -use defguard_mail::templates::{TemplateLocation, new_device_added_mail}; +use defguard_mail::templates::{TemplateLocation, mfa_activation_mail, new_device_added_mail}; use defguard_proto::proxy::{ ActivateUserRequest, AdminInfo, CodeMfaSetupFinishRequest, CodeMfaSetupFinishResponse, CodeMfaSetupStartRequest, CodeMfaSetupStartResponse, DeviceConfigResponse, @@ -914,7 +911,7 @@ impl EnrollmentServer { &self, request: CodeMfaSetupStartRequest, ) -> Result { - debug!("Begin enrollment code mfa setup start"); + debug!("Begin enrollment code MFA setup start"); let method = request.method(); if method != MfaMethod::Email && method != MfaMethod::Totp { return Err(Status::invalid_argument("Method not supported".to_string())); @@ -929,7 +926,7 @@ impl EnrollmentServer { MfaMethod::Email => { let settings = Settings::get_current_settings(); if !settings.smtp_configured() { - error!("Unable to start Email mfa setup. SMTP is not configured"); + error!("Unable to start email MFA setup; SMTP is not configured"); return Err(Status::internal("SMTP not configured".to_string())); } if user.email_mfa_enabled { @@ -942,10 +939,20 @@ impl EnrollmentServer { Status::internal("Failed to setup email mfa".to_string()) })?; info!("Created email secret for {}", &user.username); - send_email_mfa_activation_email(&user, None).map_err(|e| { - error!("Failed to send email mfa activation email.\nReason:{e}"); - Status::internal("Failed to send activation email".to_string()) + let mut transaction = self.pool.begin().await.map_err(|err| { + error!("Failed to begin database transaction\nReason:{err}"); + Status::internal("Failed begin database transaction".to_string()) + })?; + let code = user.generate_email_mfa_code().map_err(|err| { + error!("Failed to generate MFA code for {user}\nReason:{err}"); + Status::internal("Failed to generate MFA code".to_string()) })?; + mfa_activation_mail(&user.email, &mut transaction, &user.first_name, &code, None) + .await + .map_err(|err| { + error!("Failed to send MFA activation email\nReason:{err}"); + Status::internal("Failed to send activation email".to_string()) + })?; Ok(CodeMfaSetupStartResponse { totp_secret: None }) } MfaMethod::Totp => { @@ -955,10 +962,10 @@ impl EnrollmentServer { )); } let secret = user.new_totp_secret(&self.pool).await.map_err(|_| { - error!("Failed to make new totp secret"); - Status::internal(String::new()) + error!("Failed to make new TOTP secret"); + Status::internal("Failed to make new TOTP secret".to_string()) })?; - info!("New totp secret created for {}", &user.username); + info!("New TOTP secret created for {}", &user.username); Ok(CodeMfaSetupStartResponse { totp_secret: Some(secret), }) diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.down.sql b/migrations/20260323081850_[2.0.0]_more_mjml.down.sql new file mode 100644 index 0000000000..97c3f7002f --- /dev/null +++ b/migrations/20260323081850_[2.0.0]_more_mjml.down.sql @@ -0,0 +1 @@ +-- Nothing here diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql new file mode 100644 index 0000000000..4e4a1253be --- /dev/null +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -0,0 +1,4 @@ +INSERT INTO mail_context (template, section, language_tag, text) VALUES + ('mfa-activation', 'title', 'en_US', 'Hello,'), + ('mfa-activation', 'subtitle', 'en_US', 'You are activating Multi-Factor Authentication using email verification codes.'), + ('mfa-activation', 'code_is_valid', 'en_US', 'The code is valid for 1 minute'); From 26619d849ca410267ff5d3868c04aa137b13c65b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 10:13:09 +0100 Subject: [PATCH 02/13] enrollment_admin_notification --- .../defguard_core/src/db/models/enrollment.rs | 36 --- crates/defguard_mail/src/mail.rs | 8 + crates/defguard_mail/src/templates.rs | 75 ++---- crates/defguard_mail/src/tests.rs | 239 ++++++++++-------- .../enrollment-admin-notification.mjml | 22 ++ .../templates/mail_email_mfa_activation.tera | 21 -- .../mail_enrollment_admin_notification.tera | 9 - .../templates/mfa-activation.mjml | 2 +- crates/defguard_mail/templates/mfa-code.mjml | 2 +- .../src/servers/enrollment.rs | 20 +- .../20260323081850_[2.0.0]_more_mjml.up.sql | 5 +- 11 files changed, 202 insertions(+), 237 deletions(-) create mode 100644 crates/defguard_mail/templates/enrollment-admin-notification.mjml delete mode 100644 crates/defguard_mail/templates/mail_email_mfa_activation.tera delete mode 100644 crates/defguard_mail/templates/mail_enrollment_admin_notification.tera diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index 76b5b1c0f0..70a7135216 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -418,42 +418,6 @@ impl Token { } } } - - // Notify admin that a user has completed enrollment - pub async fn send_admin_notification( - admin: &User, - user: &User, - ip_address: &str, - device_info: Option<&str>, - ) -> Result<(), TokenError> { - debug!( - "Sending enrollment success notification for user {} to {}", - user.username, admin.username - ); - let mail = Mail::new( - &admin.email, - "[defguard] User enrollment completed", - templates::enrollment_admin_notification( - &user.into(), - &admin.into(), - ip_address, - device_info, - )?, - ); - match mail.send().await { - Ok(()) => { - info!( - "Sent enrollment success notification for user {} to {}", - user.username, admin.username - ); - Ok(()) - } - Err(err) => { - error!("Error sending welcome mail: {err}"); - Err(TokenError::NotificationError(err.to_string())) - } - } - } } fn enrollment_welcome_message(settings: &Settings) -> Result { diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 5e73f46d22..e9b5bb7227 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -287,6 +287,7 @@ pub enum MailMessage { GatewayDisconnect, /// Gateway has reconnected. GatewayReconnect, + /// MFA activated. MFAActivation, MFAConfigured, /// MFA code. @@ -294,6 +295,8 @@ pub enum MailMessage { PasswordReset, PasswordResetDone, UserImportBlocked, + /// Enrollment notification for admins. + EnrollmentNotification, } impl MailMessage { @@ -316,6 +319,7 @@ impl MailMessage { Self::PasswordReset => "Password reset", Self::PasswordResetDone => "Password reset success", Self::UserImportBlocked => "User import blocked", + Self::EnrollmentNotification => "Defguard: User enrollment completed", } } @@ -337,6 +341,7 @@ impl MailMessage { Self::PasswordReset => "password-reset", Self::PasswordResetDone => "password-reset-done", Self::UserImportBlocked => "user-import-blocked", + Self::EnrollmentNotification => "enrollment-admin-notification", } } @@ -358,6 +363,9 @@ impl MailMessage { // Self::PasswordReset => "", // Self::PasswordResetDone => "", Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), + Self::EnrollmentNotification => { + include_str!("../templates/enrollment-admin-notification.mjml") + } _ => "", } } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 8fa287594e..42006e5089 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -3,13 +3,7 @@ use std::{collections::HashMap, time::Duration}; use chrono::{Datelike, NaiveDateTime, Utc}; use defguard_common::{ VERSION, - db::{ - Id, - models::{ - Session, Settings, - user::{MFAMethod, User}, - }, - }, + db::models::{Session, Settings, user::MFAMethod}, types::UrlParseError, }; use reqwest::Url; @@ -31,8 +25,6 @@ static MAIL_BASE: &str = include_str!("../templates/base.tera"); static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); -static MAIL_ENROLLMENT_ADMIN_NOTIFICATION: &str = - include_str!("../templates/mail_enrollment_admin_notification.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); static MAIL_GATEWAY_DISCONNECTED: &str = include_str!("../templates/mail_gateway_disconnected.tera"); @@ -96,20 +88,6 @@ impl From for SessionContext { } } -pub struct UserContext { - last_name: String, - first_name: String, -} - -impl From<&User> for UserContext { - fn from(user: &User) -> Self { - Self { - last_name: user.last_name.clone(), - first_name: user.first_name.clone(), - } - } -} - fn get_base_tera( mut context: Context, session: Option<&SessionContext>, @@ -277,27 +255,27 @@ pub fn enrollment_welcome_mail( Ok(tera.render("mail_enrollment_welcome", &context)?) } -// Notification for admin after user completes an enrollment. -pub fn enrollment_admin_notification( - user: &UserContext, - admin: &UserContext, +/// Notification for admin after user completes an enrollment. +pub async fn enrollment_admin_notification( + to: &str, + conn: &mut PgConnection, + user_name: &str, + admin_name: &str, ip_address: &str, device_info: Option<&str>, -) -> Result { +) -> Result<(), TemplateError> { debug!("Render an admin notification mail template."); let (mut tera, mut context) = - get_base_tera(Context::new(), None, Some(ip_address), device_info)?; - - tera.add_raw_template( - "mail_enrollment_admin_notification", - MAIL_ENROLLMENT_ADMIN_NOTIFICATION, - )?; - context.insert("first_name", &user.first_name); - context.insert("last_name", &user.last_name); - context.insert("admin_first_name", &admin.first_name); - context.insert("admin_last_name", &admin.last_name); - - Ok(tera.render("mail_enrollment_admin_notification", &context)?) + get_base_tera_mjml(Context::new(), None, Some(ip_address), device_info)?; + + context.insert("user_name", user_name); + context.insert("admin_name", admin_name); + + let message = MailMessage::EnrollmentNotification; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } // message with support data @@ -611,23 +589,6 @@ mod test { )); } - #[sqlx::test] - async fn test_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - init_config(&pool).await; - let test_user = UserContext { - last_name: "test_last".into(), - first_name: "test_first".into(), - }; - - assert_ok!(enrollment_admin_notification( - &test_user, - &test_user, - "11.11.11.11", - None - )); - } - #[test] fn dg25_8_server_side_template_injection() { let mut tera = safe_tera(); diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 7b65057809..d356030664 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -19,8 +19,8 @@ use sqlx::{ use tera::Context; use super::templates::{ - TemplateLocation, desktop_start_mail, mfa_activation_mail, mfa_code_mail, new_account_mail, - new_device_added_mail, + TemplateLocation, desktop_start_mail, enrollment_admin_notification, mfa_activation_mail, + mfa_code_mail, new_account_mail, new_device_added_mail, }; /// Set SMTP settings from environment variables. @@ -40,111 +40,111 @@ async fn set_smtp_settings(pool: &PgPool) { set_settings(Some(settings)); } -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let context = Context::new(); -// let url = Url::parse("http://localhost:8000").unwrap(); -// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; -// desktop_start_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// context, -// &url, -// token, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let device_name = "My beloved machine"; -// let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; -// let locations = &[ -// TemplateLocation { -// name: String::from("Location 1"), -// assigned_ips: String::from("192.168.1.42"), -// }, -// TemplateLocation { -// name: String::from("Location 2"), -// assigned_ips: String::from("192.168.2.69"), -// }, -// ]; -// new_device_added_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// device_name, -// public_key, -// locations, -// Some("1.2.3.4"), -// Some("unknown device"), -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let first_name = "Nebuchadnezzar"; -// let code = "123456"; -// mfa_code_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// first_name, -// code, -// None, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let url = Url::parse("http://localhost:8001").unwrap(); -// let context = Context::new(); -// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; -// new_account_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// context, -// url, -// token, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let context = Context::new(); + let url = Url::parse("http://localhost:8000").unwrap(); + let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; + desktop_start_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + context, + &url, + token, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let device_name = "My beloved machine"; + let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; + let locations = &[ + TemplateLocation { + name: String::from("Location 1"), + assigned_ips: String::from("192.168.1.42"), + }, + TemplateLocation { + name: String::from("Location 2"), + assigned_ips: String::from("192.168.2.69"), + }, + ]; + new_device_added_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + device_name, + public_key, + locations, + Some("1.2.3.4"), + Some("unknown device"), + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let first_name = "Nebuchadnezzar"; + let code = "123456"; + mfa_code_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + first_name, + code, + None, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let url = Url::parse("http://localhost:8001").unwrap(); + let context = Context::new(); + let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; + new_account_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + context, + url, + token, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} #[ignore = "requires SMTP server"] #[sqlx::test] @@ -168,3 +168,28 @@ fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let user_name = "Nebuchadnezzar the Great"; + let admin_name = "Nabopolassar the Admin"; + let ip_address = "1.2.3.4"; + enrollment_admin_notification( + &env::var("SMTP_TO").unwrap(), + &mut conn, + user_name, + admin_name, + ip_address, + None, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/enrollment-admin-notification.mjml b/crates/defguard_mail/templates/enrollment-admin-notification.mjml new file mode 100644 index 0000000000..b3c3e41551 --- /dev/null +++ b/crates/defguard_mail/templates/enrollment-admin-notification.mjml @@ -0,0 +1,22 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + +

+ {{ user_name }} {{ message }} +

+

+ {{ goodday }} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/mail_email_mfa_activation.tera b/crates/defguard_mail/templates/mail_email_mfa_activation.tera deleted file mode 100644 index cc12a8174f..0000000000 --- a/crates/defguard_mail/templates/mail_email_mfa_activation.tera +++ /dev/null @@ -1,21 +0,0 @@ -{# -Requires context: -code -> 6-digit zero-padded verification code -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [ - macros::title(content="Hello, " ~ name), - macros::paragraph(content="You are activating Multi-Factor Authentication using email verification codes.", align="center"), -] %} -{{ macros::text_section(content_array=section_content) }} -{{ macros::spacer(height="40px") }} -{% set section_content = [ - macros::title(content="" ~ code ~ "", font_size="45px"), - macros::spacer(height="40px"), - macros::paragraph(content="The code is valid for " ~ timeout ~ ".", align="center", font_size="15px"), -] %} -{{ macros::text_section(content_array=section_content) }} -{{ macros::spacer(height="10px") }} -{% endblock %} diff --git a/crates/defguard_mail/templates/mail_enrollment_admin_notification.tera b/crates/defguard_mail/templates/mail_enrollment_admin_notification.tera deleted file mode 100644 index 3d775ff2d8..0000000000 --- a/crates/defguard_mail/templates/mail_enrollment_admin_notification.tera +++ /dev/null @@ -1,9 +0,0 @@ -{% import "macros" as macros %} -{% extends "base" %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="Dear " ~ admin_first_name ~ " " ~ admin_last_name), -macros::paragraph(content=first_name ~ " " ~ last_name ~ " just completed their enrollment process."), -macros::paragraph(content="Have a good day!")] %} -{{ macros::text_section(content_array=section_content) }} -{% endblock %} diff --git a/crates/defguard_mail/templates/mfa-activation.mjml b/crates/defguard_mail/templates/mfa-activation.mjml index c511b9dd52..3d7f8abf49 100644 --- a/crates/defguard_mail/templates/mfa-activation.mjml +++ b/crates/defguard_mail/templates/mfa-activation.mjml @@ -22,7 +22,7 @@ - {{ code_is_valid }} + {{ code_is_valid }} {{ timeout }} diff --git a/crates/defguard_mail/templates/mfa-code.mjml b/crates/defguard_mail/templates/mfa-code.mjml index c511b9dd52..3d7f8abf49 100644 --- a/crates/defguard_mail/templates/mfa-code.mjml +++ b/crates/defguard_mail/templates/mfa-code.mjml @@ -22,7 +22,7 @@ - {{ code_is_valid }} + {{ code_is_valid }} {{ timeout }} diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index f94768c791..46622f3aee 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -29,7 +29,9 @@ use defguard_core::{ headers::get_device_info, is_valid_phone_number, }; -use defguard_mail::templates::{TemplateLocation, mfa_activation_mail, new_device_added_mail}; +use defguard_mail::templates::{ + TemplateLocation, enrollment_admin_notification, mfa_activation_mail, new_device_added_mail, +}; use defguard_proto::proxy::{ ActivateUserRequest, AdminInfo, CodeMfaSetupFinishRequest, CodeMfaSetupFinishResponse, CodeMfaSetupStartRequest, CodeMfaSetupStartResponse, DeviceConfigResponse, @@ -439,9 +441,19 @@ impl EnrollmentServer { let admin = enrollment.fetch_admin(&mut *transaction).await?; if let Some(admin) = admin { - debug!("Send admin notification mail."); - Token::send_admin_notification(&admin, &user, &ip_address, device_info.as_deref()) - .await?; + debug!("Sending admin notification mail"); + if let Err(err) = enrollment_admin_notification( + &admin.email, + &mut transaction, + user.name().as_str(), + admin.name().as_str(), + &ip_address, + device_info.as_deref(), + ) + .await + { + error!("Failed to send admin notification mail: {err}"); + } } // Unset the enrollment-pending flag (https://github.com/DefGuard/client/issues/647). diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index 4e4a1253be..509a2b6dce 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -1,4 +1,7 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('mfa-activation', 'title', 'en_US', 'Hello,'), ('mfa-activation', 'subtitle', 'en_US', 'You are activating Multi-Factor Authentication using email verification codes.'), - ('mfa-activation', 'code_is_valid', 'en_US', 'The code is valid for 1 minute'); + ('mfa-activation', 'code_is_valid', 'en_US', 'The code is valid for:'), + ('enrollment-admin-notification', 'title', 'en_US', 'Dear,'), + ('enrollment-admin-notification', 'message', 'en_US', 'just completed their enrollment process.'), + ('enrollment-admin-notification', 'goodday', 'en_US', 'Have a good day!') From 2c50cc95109e2e203daed92585d922ee748a6196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 10:36:08 +0100 Subject: [PATCH 03/13] gateway-disconnected --- .../defguard_core/src/db/models/enrollment.rs | 5 +- crates/defguard_core/src/handlers/mail.rs | 17 +- .../defguard_gateway_manager/src/handler.rs | 16 +- crates/defguard_mail/src/mail.rs | 6 +- crates/defguard_mail/src/templates.rs | 86 ++---- crates/defguard_mail/src/tests.rs | 264 ++++++++++-------- .../templates/gateway-disconnected.mjml | 24 ++ .../templates/mail_gateway_disconnected.tera | 14 - .../20260323081850_[2.0.0]_more_mjml.up.sql | 7 +- 9 files changed, 212 insertions(+), 227 deletions(-) create mode 100644 crates/defguard_mail/templates/gateway-disconnected.mjml delete mode 100644 crates/defguard_mail/templates/mail_gateway_disconnected.tera diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index 70a7135216..ef68cd8243 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -84,7 +84,7 @@ impl From for Status { pub struct Token { pub id: String, pub user_id: Id, - pub admin_id: Option, + pub admin_id: Option, pub email: Option, pub created_at: NaiveDateTime, pub expires_at: NaiveDateTime, @@ -121,7 +121,8 @@ impl Token { E: PgExecutor<'e>, { query!( - "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, token_type, device_id) \ + "INSERT INTO token (id, user_id, admin_id, email, created_at, expires_at, used_at, \ + token_type, device_id) \ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", self.id, self.user_id, diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index b072072c5e..6e3b1fa835 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -32,12 +32,8 @@ use crate::{ static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; - static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; - static NEW_DEVICE_LOGIN_EMAIL_SUBJECT: &str = "Defguard: new device logged in to your account"; - -static GATEWAY_DISCONNECTED_SUBJECT: &str = "Defguard: Gateway disconnected"; static GATEWAY_RECONNECTED_SUBJECT: &str = "Defguard: Gateway reconnected"; pub(crate) static EMAIL_PASSWORD_RESET_START_SUBJECT: &str = "Defguard: Password reset"; @@ -175,14 +171,17 @@ pub async fn send_gateway_disconnected_email( pool: &PgPool, ) -> Result<(), WebError> { debug!("Sending gateway disconnected mail to all admin users"); - let admin_users = User::find_admins(pool).await?; + let mut conn = pool.begin().await?; + let admin_users = User::find_admins(&mut *conn).await?; for user in admin_users { - Mail::new( + templates::gateway_disconnected_mail( &user.email, - GATEWAY_DISCONNECTED_SUBJECT, - templates::gateway_disconnected_mail(&gateway_name, gateway_adress, &network_name)?, + &mut conn, + &gateway_name, + gateway_adress, + &network_name, ) - .send_and_forget(); + .await?; } Ok(()) diff --git a/crates/defguard_gateway_manager/src/handler.rs b/crates/defguard_gateway_manager/src/handler.rs index 45dd490ada..1bdea5560e 100644 --- a/crates/defguard_gateway_manager/src/handler.rs +++ b/crates/defguard_gateway_manager/src/handler.rs @@ -200,16 +200,12 @@ impl GatewayHandler { return; }; - // FIXME: Try to get rid of spawn and use something like block_on - // To return result instead of logging - tokio::spawn(async move { - if let Err(err) = send_gateway_disconnected_email(name, network.name, &url, &pool).await - { - error!("Failed to send Gateway disconnect notification: {err}"); - } else { - info!("Sent email notification about Gateway being disconnected"); - } - }); + // TODO: return result instead of logging. + if let Err(err) = send_gateway_disconnected_email(name, network.name, &url, &pool).await { + error!("Failed to send Gateway disconnect notification: {err}"); + } else { + info!("Sent email notification about Gateway being disconnected"); + } } /// Send Gateway reconnected notification. diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index e9b5bb7227..dac24ba53b 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -311,8 +311,8 @@ impl MailMessage { Self::NewDevice => "Defguard: new device added to your account", Self::NewDeviceLogin => "New device logged in to your account", Self::NewDeviceOCIDLogin => "New login to OCID application", - Self::GatewayDisconnect => "Gateway disconnected", - Self::GatewayReconnect => "Gateway reconnected", + Self::GatewayDisconnect => "Defguard: Gateway disconnected", + Self::GatewayReconnect => "Defguard: Gateway reconnected", Self::MFAActivation => "Multi-Factor Authentication activation", Self::MFAConfigured => "Multi-Factor Authentication {method} has been activated", Self::MFACode => "Defguard: Multi-Factor Authentication code for login", @@ -355,7 +355,7 @@ impl MailMessage { Self::NewDevice => include_str!("../templates/new-device.mjml"), // Self::NewDeviceLogin => "", // Self::NewDeviceOCIDLogin => "", - // Self::GatewayDisconnect => "", + Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), // Self::GatewayReconnect => "", Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), // Self::MFAConfigured => "", diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 42006e5089..6aecd06cd9 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,8 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_GATEWAY_DISCONNECTED: &str = - include_str!("../templates/mail_gateway_disconnected.tera"); static MAIL_GATEWAY_RECONNECTED: &str = include_str!("../templates/mail_gateway_reconnected.tera"); static MAIL_MFA_CONFIGURED: &str = include_str!("../templates/mail_mfa_configured.tera"); static MAIL_NEW_DEVICE_LOGIN: &str = include_str!("../templates/mail_new_device_login.tera"); @@ -268,8 +266,8 @@ pub async fn enrollment_admin_notification( let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, Some(ip_address), device_info)?; + context.insert("username", admin_name); context.insert("user_name", user_name); - context.insert("admin_name", admin_name); let message = MailMessage::EnrollmentNotification; message.fill_context(conn, &mut context).await?; @@ -357,17 +355,25 @@ pub fn new_device_ocid_login_mail( Ok(tera.render("mail_new_device_oicd_login", &context)?) } -pub fn gateway_disconnected_mail( +/// Notification about disconnected Gateway. +pub async fn gateway_disconnected_mail( + to: &str, + conn: &mut PgConnection, gateway_name: &str, - gateway_ip: &str, - network_name: &str, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), None, None, None)?; + gateway_ip_address: &str, + location_name: &str, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + context.insert("gateway_name", gateway_name); - context.insert("gateway_ip", gateway_ip); - context.insert("network_name", network_name); - tera.add_raw_template("mail_gateway_disconnected", MAIL_GATEWAY_DISCONNECTED)?; - Ok(tera.render("mail_gateway_disconnected", &context)?) + context.insert("ip_address", gateway_ip_address); + context.insert("location_name", location_name); + + let message = MailMessage::GatewayDisconnect; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } pub fn gateway_reconnected_mail( @@ -522,17 +528,6 @@ mod test { assert_ok!(test_mail(None)); } - // #[sqlx::test] - // async fn test_enrollment_start_mail(_: PgPoolOptions, options: PgConnectOptions) { - // let pool = setup_pool(options).await; - // init_config(&pool).await; - // assert_ok!(enrollment_start_mail( - // Context::new(), - // Url::parse("http://localhost:8080").unwrap(), - // "test_token" - // )); - // } - #[sqlx::test] async fn test_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -544,51 +539,6 @@ mod test { )); } - // #[sqlx::test] - // async fn test_desktop_start_mail(_: PgPoolOptions, options: PgConnectOptions) { - // let pool = setup_pool(options).await; - // init_config(&pool).await; - // let context = get_welcome_context(); - // let url = Url::parse("http://127.0.0.1:8080").unwrap(); - // let token = "TestToken"; - // let mut tranaction = pool.begin().await.unwrap(); - // assert_ok!(desktop_start_mail(&mut tranaction, context, &url, token).await); - // } - - // #[sqlx::test] - // async fn test_new_device_added_mail(_: PgPoolOptions, options: PgConnectOptions) { - // let pool = setup_pool(options).await; - // init_config(&pool).await; - // let template_locations: Vec = vec![ - // TemplateLocation { - // name: "Test 01".into(), - // assigned_ips: "10.0.0.10".into(), - // }, - // TemplateLocation { - // name: "Test 02".into(), - // assigned_ips: "10.0.0.10".into(), - // }, - // ]; - // assert_ok!(new_device_added_mail( - // "Test device", - // "TestKey", - // &template_locations, - // Some("1.1.1.1"), - // None, - // )); - // } - - #[sqlx::test] - async fn test_gateway_disconnected(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - init_config(&pool).await; - assert_ok!(gateway_disconnected_mail( - "Gateway A", - "127.0.0.1", - "Location1" - )); - } - #[test] fn dg25_8_server_side_template_injection() { let mut tera = safe_tera(); diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index d356030664..ec0a3becc2 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -19,8 +19,8 @@ use sqlx::{ use tera::Context; use super::templates::{ - TemplateLocation, desktop_start_mail, enrollment_admin_notification, mfa_activation_mail, - mfa_code_mail, new_account_mail, new_device_added_mail, + TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, + mfa_activation_mail, mfa_code_mail, new_account_mail, new_device_added_mail, }; /// Set SMTP settings from environment variables. @@ -40,126 +40,151 @@ async fn set_smtp_settings(pool: &PgPool) { set_settings(Some(settings)); } -#[ignore = "requires SMTP server"] -#[sqlx::test] -fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let context = Context::new(); - let url = Url::parse("http://localhost:8000").unwrap(); - let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - desktop_start_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - context, - &url, - token, - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let context = Context::new(); +// let url = Url::parse("http://localhost:8000").unwrap(); +// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; +// desktop_start_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// context, +// &url, +// token, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let device_name = "My beloved machine"; +// let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; +// let locations = &[ +// TemplateLocation { +// name: String::from("Location 1"), +// assigned_ips: String::from("192.168.1.42"), +// }, +// TemplateLocation { +// name: String::from("Location 2"), +// assigned_ips: String::from("192.168.2.69"), +// }, +// ]; +// new_device_added_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// device_name, +// public_key, +// locations, +// Some("1.2.3.4"), +// Some("unknown device"), +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let first_name = "Nebuchadnezzar"; +// let code = "123456"; +// mfa_code_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// first_name, +// code, +// None, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let url = Url::parse("http://localhost:8001").unwrap(); +// let context = Context::new(); +// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; +// new_account_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// context, +// url, +// token, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } + +// #[ignore = "requires SMTP server"] +// #[sqlx::test] +// fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { +// let pool = setup_pool(options).await; +// set_smtp_settings(&pool).await; + +// let mut conn = pool.begin().await.unwrap(); +// let first_name = "Nebuchadnezzar"; +// let code = "123456"; +// mfa_activation_mail( +// &env::var("SMTP_TO").unwrap(), +// &mut conn, +// first_name, +// code, +// None, +// ) +// .await +// .unwrap(); + +// // Delay, so send_and_forget() can process the message. +// tokio::time::sleep(Duration::from_secs(2)).await; +// } #[ignore = "requires SMTP server"] #[sqlx::test] -fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let device_name = "My beloved machine"; - let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; - let locations = &[ - TemplateLocation { - name: String::from("Location 1"), - assigned_ips: String::from("192.168.1.42"), - }, - TemplateLocation { - name: String::from("Location 2"), - assigned_ips: String::from("192.168.2.69"), - }, - ]; - new_device_added_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - device_name, - public_key, - locations, - Some("1.2.3.4"), - Some("unknown device"), - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} - -#[ignore = "requires SMTP server"] -#[sqlx::test] -fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let first_name = "Nebuchadnezzar"; - let code = "123456"; - mfa_code_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - first_name, - code, - None, - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} - -#[ignore = "requires SMTP server"] -#[sqlx::test] -fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - set_smtp_settings(&pool).await; - - let mut conn = pool.begin().await.unwrap(); - let url = Url::parse("http://localhost:8001").unwrap(); - let context = Context::new(); - let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - new_account_mail( - &env::var("SMTP_TO").unwrap(), - &mut conn, - context, - url, - token, - ) - .await - .unwrap(); - - // Delay, so send_and_forget() can process the message. - tokio::time::sleep(Duration::from_secs(2)).await; -} - -#[ignore = "requires SMTP server"] -#[sqlx::test] -fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { +fn send_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; set_smtp_settings(&pool).await; let mut conn = pool.begin().await.unwrap(); - let first_name = "Nebuchadnezzar"; - let code = "123456"; - mfa_activation_mail( + let user_name = "Nebuchadnezzar the Great"; + let admin_name = "Nabopolassar the Admin"; + let ip_address = "1.2.3.4"; + enrollment_admin_notification( &env::var("SMTP_TO").unwrap(), &mut conn, - first_name, - code, + user_name, + admin_name, + ip_address, None, ) .await @@ -171,21 +196,20 @@ fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { #[ignore = "requires SMTP server"] #[sqlx::test] -fn send_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOptions) { +fn send_gateway_disconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; set_smtp_settings(&pool).await; let mut conn = pool.begin().await.unwrap(); - let user_name = "Nebuchadnezzar the Great"; - let admin_name = "Nabopolassar the Admin"; + let gateway_name = "Portal"; let ip_address = "1.2.3.4"; - enrollment_admin_notification( + let location_name = "Somewhere"; + gateway_disconnected_mail( &env::var("SMTP_TO").unwrap(), &mut conn, - user_name, - admin_name, + gateway_name, ip_address, - None, + location_name, ) .await .unwrap(); diff --git a/crates/defguard_mail/templates/gateway-disconnected.mjml b/crates/defguard_mail/templates/gateway-disconnected.mjml new file mode 100644 index 0000000000..450595d287 --- /dev/null +++ b/crates/defguard_mail/templates/gateway-disconnected.mjml @@ -0,0 +1,24 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + +

+ {{ gateway_label }} {{ gateway_name }} +

+

+ {{ ip_address_label }} {{ ip_address }} +

+

+ {{ location_label }} {{ location_name }} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/mail_gateway_disconnected.tera b/crates/defguard_mail/templates/mail_gateway_disconnected.tera deleted file mode 100644 index 17b1862380..0000000000 --- a/crates/defguard_mail/templates/mail_gateway_disconnected.tera +++ /dev/null @@ -1,14 +0,0 @@ -{# -Requires context: -gateway_name -> name of gateway -gateway_ip -> gateway adress -network_name -> name of network -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="Your gateway: " ~ gateway_name ~ " (IP: " ~ gateway_ip ~ ") for VPN Location: " ~ network_name ~ " has just disconnected."), -macros::paragraph(content="Please login to your gateway server and see the logs.")] %} -{{ macros::text_section(content_array=section_content) }} -{% endblock %} diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index 509a2b6dce..562c6eb072 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -4,4 +4,9 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('mfa-activation', 'code_is_valid', 'en_US', 'The code is valid for:'), ('enrollment-admin-notification', 'title', 'en_US', 'Dear,'), ('enrollment-admin-notification', 'message', 'en_US', 'just completed their enrollment process.'), - ('enrollment-admin-notification', 'goodday', 'en_US', 'Have a good day!') + ('enrollment-admin-notification', 'goodday', 'en_US', 'Have a good day!'), + ('gateway-disconnect', 'title', 'en_US', 'Defguard Gateway has just disconnected.'), + ('gateway-disconnect', 'subtitle', 'en_US', 'Please login to your gateway server and see the logs.'), + ('gateway-disconnect', 'gateway_label', 'en_US', 'Gateway name:'), + ('gateway-disconnect', 'ip_address_label', 'en_US', 'Gateway IP address:'), + ('gateway-disconnect', 'location_label', 'en_US', 'VPN location:'); From 5db4e6c9ad1b9a07c5fe33a86e682347e4be03df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 10:44:25 +0100 Subject: [PATCH 04/13] gateway-reconnected --- crates/defguard_core/src/handlers/mail.rs | 18 +- crates/defguard_mail/src/mail.rs | 2 +- crates/defguard_mail/src/templates.rs | 27 +- crates/defguard_mail/src/tests.rs | 283 ++++++++++-------- .../templates/gateway-reconnected.mjml | 24 ++ .../templates/mail_gateway_reconnected.tera | 14 - .../20260323081850_[2.0.0]_more_mjml.up.sql | 6 +- 7 files changed, 211 insertions(+), 163 deletions(-) create mode 100644 crates/defguard_mail/templates/gateway-reconnected.mjml delete mode 100644 crates/defguard_mail/templates/mail_gateway_reconnected.tera diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 6e3b1fa835..8fd74febf6 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -34,7 +34,6 @@ static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; static NEW_DEVICE_LOGIN_EMAIL_SUBJECT: &str = "Defguard: new device logged in to your account"; -static GATEWAY_RECONNECTED_SUBJECT: &str = "Defguard: Gateway reconnected"; pub(crate) static EMAIL_PASSWORD_RESET_START_SUBJECT: &str = "Defguard: Password reset"; pub(crate) static EMAIL_PASSWORD_RESET_SUCCESS_SUBJECT: &str = "Defguard: Password reset success"; @@ -170,7 +169,7 @@ pub async fn send_gateway_disconnected_email( gateway_adress: &str, pool: &PgPool, ) -> Result<(), WebError> { - debug!("Sending gateway disconnected mail to all admin users"); + debug!("Sending Gateway disconnected mail to all admin users"); let mut conn = pool.begin().await?; let admin_users = User::find_admins(&mut *conn).await?; for user in admin_users { @@ -193,15 +192,18 @@ pub async fn send_gateway_reconnected_email( gateway_adress: &str, pool: &PgPool, ) -> Result<(), WebError> { - debug!("Sending gateway reconnect mail to all admin users"); - let admin_users = User::find_admins(pool).await?; + debug!("Sending Gateway reconnect mail to all admin users"); + let mut conn = pool.begin().await?; + let admin_users = User::find_admins(&mut *conn).await?; for user in admin_users { - Mail::new( + templates::gateway_reconnected_mail( &user.email, - GATEWAY_RECONNECTED_SUBJECT, - templates::gateway_reconnected_mail(&gateway_name, gateway_adress, &network_name)?, + &mut conn, + &gateway_name, + gateway_adress, + &network_name, ) - .send_and_forget(); + .await?; } Ok(()) diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index dac24ba53b..6387bfa086 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -356,7 +356,7 @@ impl MailMessage { // Self::NewDeviceLogin => "", // Self::NewDeviceOCIDLogin => "", Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), - // Self::GatewayReconnect => "", + Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.mjml"), Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), // Self::MFAConfigured => "", Self::MFACode => include_str!("../templates/mfa-code.mjml"), diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 6aecd06cd9..99fab706f1 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,7 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_GATEWAY_RECONNECTED: &str = include_str!("../templates/mail_gateway_reconnected.tera"); static MAIL_MFA_CONFIGURED: &str = include_str!("../templates/mail_mfa_configured.tera"); static MAIL_NEW_DEVICE_LOGIN: &str = include_str!("../templates/mail_new_device_login.tera"); static MAIL_NEW_DEVICE_OCID_LOGIN: &str = @@ -376,17 +375,25 @@ pub async fn gateway_disconnected_mail( Ok(()) } -pub fn gateway_reconnected_mail( +/// Notification about reconnected Gateway. +pub async fn gateway_reconnected_mail( + to: &str, + conn: &mut PgConnection, gateway_name: &str, - gateway_ip: &str, - network_name: &str, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), None, None, None)?; + gateway_ip_address: &str, + location_name: &str, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + context.insert("gateway_name", gateway_name); - context.insert("gateway_ip", gateway_ip); - context.insert("network_name", network_name); - tera.add_raw_template("mail_gateway_reconnected", MAIL_GATEWAY_RECONNECTED)?; - Ok(tera.render("mail_gateway_reconnected", &context)?) + context.insert("ip_address", gateway_ip_address); + context.insert("location_name", location_name); + + let message = MailMessage::GatewayReconnect; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } pub async fn mfa_activation_mail( diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index ec0a3becc2..7b35cb8a0f 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -20,7 +20,8 @@ use tera::Context; use super::templates::{ TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, - mfa_activation_mail, mfa_code_mail, new_account_mail, new_device_added_mail, + gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, new_account_mail, + new_device_added_mail, }; /// Set SMTP settings from environment variables. @@ -40,134 +41,134 @@ async fn set_smtp_settings(pool: &PgPool) { set_settings(Some(settings)); } -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let context = Context::new(); -// let url = Url::parse("http://localhost:8000").unwrap(); -// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; -// desktop_start_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// context, -// &url, -// token, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let device_name = "My beloved machine"; -// let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; -// let locations = &[ -// TemplateLocation { -// name: String::from("Location 1"), -// assigned_ips: String::from("192.168.1.42"), -// }, -// TemplateLocation { -// name: String::from("Location 2"), -// assigned_ips: String::from("192.168.2.69"), -// }, -// ]; -// new_device_added_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// device_name, -// public_key, -// locations, -// Some("1.2.3.4"), -// Some("unknown device"), -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let first_name = "Nebuchadnezzar"; -// let code = "123456"; -// mfa_code_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// first_name, -// code, -// None, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let url = Url::parse("http://localhost:8001").unwrap(); -// let context = Context::new(); -// let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; -// new_account_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// context, -// url, -// token, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } - -// #[ignore = "requires SMTP server"] -// #[sqlx::test] -// fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { -// let pool = setup_pool(options).await; -// set_smtp_settings(&pool).await; - -// let mut conn = pool.begin().await.unwrap(); -// let first_name = "Nebuchadnezzar"; -// let code = "123456"; -// mfa_activation_mail( -// &env::var("SMTP_TO").unwrap(), -// &mut conn, -// first_name, -// code, -// None, -// ) -// .await -// .unwrap(); - -// // Delay, so send_and_forget() can process the message. -// tokio::time::sleep(Duration::from_secs(2)).await; -// } +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let context = Context::new(); + let url = Url::parse("http://localhost:8000").unwrap(); + let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; + desktop_start_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + context, + &url, + token, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let device_name = "My beloved machine"; + let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; + let locations = &[ + TemplateLocation { + name: String::from("Location 1"), + assigned_ips: String::from("192.168.1.42"), + }, + TemplateLocation { + name: String::from("Location 2"), + assigned_ips: String::from("192.168.2.69"), + }, + ]; + new_device_added_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + device_name, + public_key, + locations, + Some("1.2.3.4"), + Some("unknown device"), + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let first_name = "Nebuchadnezzar"; + let code = "123456"; + mfa_code_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + first_name, + code, + None, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let url = Url::parse("http://localhost:8001").unwrap(); + let context = Context::new(); + let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; + new_account_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + context, + url, + token, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let first_name = "Nebuchadnezzar"; + let code = "123456"; + mfa_activation_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + first_name, + code, + None, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} #[ignore = "requires SMTP server"] #[sqlx::test] @@ -217,3 +218,27 @@ fn send_gateway_disconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_gateway_reconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let gateway_name = "Portal"; + let ip_address = "1.2.3.4"; + let location_name = "Somewhere"; + gateway_reconnected_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + gateway_name, + ip_address, + location_name, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/gateway-reconnected.mjml b/crates/defguard_mail/templates/gateway-reconnected.mjml new file mode 100644 index 0000000000..450595d287 --- /dev/null +++ b/crates/defguard_mail/templates/gateway-reconnected.mjml @@ -0,0 +1,24 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + +

+ {{ gateway_label }} {{ gateway_name }} +

+

+ {{ ip_address_label }} {{ ip_address }} +

+

+ {{ location_label }} {{ location_name }} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/mail_gateway_reconnected.tera b/crates/defguard_mail/templates/mail_gateway_reconnected.tera deleted file mode 100644 index a6a8282222..0000000000 --- a/crates/defguard_mail/templates/mail_gateway_reconnected.tera +++ /dev/null @@ -1,14 +0,0 @@ -{# -Requires context: -gateway_name -> name of gateway -gateway_ip -> gateway adress -network_name -> name of network -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="Your gateway: " ~ gateway_name ~ " (IP: " ~ gateway_ip ~ ") for VPN Location: " ~ network_name ~ " has just reconnected.") -] %} -{{ macros::text_section(content_array=section_content) }} -{% endblock %} diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index 562c6eb072..b2c5924b61 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -9,4 +9,8 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('gateway-disconnect', 'subtitle', 'en_US', 'Please login to your gateway server and see the logs.'), ('gateway-disconnect', 'gateway_label', 'en_US', 'Gateway name:'), ('gateway-disconnect', 'ip_address_label', 'en_US', 'Gateway IP address:'), - ('gateway-disconnect', 'location_label', 'en_US', 'VPN location:'); + ('gateway-disconnect', 'location_label', 'en_US', 'VPN location:'), + ('gateway-reconnect', 'title', 'en_US', 'Defguard Gateway has just disconnected.'), + ('gateway-reconnect', 'gateway_label', 'en_US', 'Gateway name:'), + ('gateway-reconnect', 'ip_address_label', 'en_US', 'Gateway IP address:'), + ('gateway-reconnect', 'location_label', 'en_US', 'VPN location:'); From 76cf9d6c097e0d8d337ddb7d852c4574479dd38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 11:22:28 +0100 Subject: [PATCH 05/13] mfa-configured --- crates/defguard_core/src/handlers/auth.rs | 49 ++++++++++++------- crates/defguard_core/src/handlers/mail.rs | 19 +------ crates/defguard_mail/src/mail.rs | 4 +- crates/defguard_mail/src/templates.rs | 24 +++++---- crates/defguard_mail/src/tests.rs | 26 ++++++++-- .../templates/mail_mfa_configured.tera | 11 ----- .../templates/mfa-configured.mjml | 19 +++++++ .../src/servers/enrollment.rs | 13 +++-- .../20260323081850_[2.0.0]_more_mjml.up.sql | 5 +- 9 files changed, 100 insertions(+), 70 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_mfa_configured.tera create mode 100644 crates/defguard_mail/templates/mfa-configured.mjml diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 241bc4c830..5da2614455 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -20,7 +20,7 @@ use defguard_common::{ }, types::user_info::UserInfo, }; -use defguard_mail::templates::{mfa_activation_mail, mfa_code_mail}; +use defguard_mail::templates::{mfa_activation_mail, mfa_code_mail, mfa_configured_mail}; use sqlx::{PgPool, types::Uuid}; use time::Duration; use uaparser::Parser; @@ -40,9 +40,7 @@ use crate::{ enterprise::ldap::{error::LdapError, utils::login_through_ldap}, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, - handlers::{ - SIGN_IN_COOKIE_NAME, cookie_domain, mail::send_mfa_configured_email, user_for_admin_or_self, - }, + handlers::{SIGN_IN_COOKIE_NAME, cookie_domain, user_for_admin_or_self}, headers::{USER_AGENT_PARSER, check_new_device_login, get_user_agent_device}, server_config, }; @@ -487,9 +485,15 @@ pub async fn webauthn_finish( .save(&appstate.pool) .await?; if user.mfa_method == MFAMethod::None { - send_mfa_configured_email(Some(&session.session.into()), &user, &MFAMethod::Webauthn)?; - user.set_mfa_method(&appstate.pool, MFAMethod::Webauthn) - .await?; + let mut conn = appstate.pool.begin().await?; + mfa_configured_mail( + &user.email, + &mut conn, + Some(&session.session.into()), + &MFAMethod::Webauthn, + ) + .await?; + user.set_mfa_method(&mut *conn, MFAMethod::Webauthn).await?; } info!("Finished Webauthn registration for user {}", user.username); @@ -639,15 +643,18 @@ pub async fn totp_enable( let mut user = session.user; debug!("Enabling TOTP for user {}", user.username); if user.verify_totp_code(&data.code) { - let recovery_codes = RecoveryCodes::new(user.get_recovery_codes(&appstate.pool).await?); - user.enable_totp(&appstate.pool).await?; + let mut conn = appstate.pool.begin().await?; + let recovery_codes = RecoveryCodes::new(user.get_recovery_codes(&mut *conn).await?); + user.enable_totp(&mut *conn).await?; if user.mfa_method == MFAMethod::None { - send_mfa_configured_email( + mfa_configured_mail( + &user.email, + &mut conn, Some(&session.session.into()), - &user, &MFAMethod::OneTimePassword, - )?; - user.set_mfa_method(&appstate.pool, MFAMethod::OneTimePassword) + ) + .await?; + user.set_mfa_method(&mut *conn, MFAMethod::OneTimePassword) .await?; } @@ -815,12 +822,18 @@ pub async fn email_mfa_enable( let mut user = session.user; debug!("Enabling email MFA for user {}", user.username); if user.verify_email_mfa_code(&data.code) { - let recovery_codes = RecoveryCodes::new(user.get_recovery_codes(&appstate.pool).await?); - user.enable_email_mfa(&appstate.pool).await?; + let mut conn = appstate.pool.begin().await?; + let recovery_codes = RecoveryCodes::new(user.get_recovery_codes(&mut *conn).await?); + user.enable_email_mfa(&mut *conn).await?; if user.mfa_method == MFAMethod::None { - send_mfa_configured_email(Some(&session.session.into()), &user, &MFAMethod::Email)?; - user.set_mfa_method(&appstate.pool, MFAMethod::Email) - .await?; + mfa_configured_mail( + &user.email, + &mut conn, + Some(&session.session.into()), + &MFAMethod::Email, + ) + .await?; + user.set_mfa_method(&mut *conn, MFAMethod::Email).await?; } info!("Enabled email MFA for user {}", user.username); diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 8fd74febf6..939e7067e9 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -7,7 +7,7 @@ use axum::{ use chrono::{NaiveDateTime, Utc}; use defguard_common::db::{ Id, - models::{MFAMethod, User, gateway::Gateway, proxy::Proxy}, + models::{User, gateway::Gateway, proxy::Proxy}, }; use defguard_mail::{ Attachment, Mail, @@ -268,23 +268,6 @@ pub fn send_new_device_ocid_login_email( Ok(()) } -pub fn send_mfa_configured_email( - session: Option<&SessionContext>, - user: &User, - mfa_method: &MFAMethod, -) -> Result<(), TemplateError> { - debug!("Sending MFA configured mail to {}", user.email); - - Mail::new( - &user.email, - format!("MFA method {mfa_method} has been activated on your account"), - templates::mfa_configured_mail(session, mfa_method)?, - ) - .send_and_forget(); - - Ok(()) -} - pub fn send_password_reset_email( user: &User, service_url: Url, diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 6387bfa086..c3341edaf2 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -336,7 +336,7 @@ impl MailMessage { Self::GatewayDisconnect => "gateway-disconnect", Self::GatewayReconnect => "gateway-reconnect", Self::MFAActivation => "mfa-activation", - Self::MFAConfigured => "mfa-configure", + Self::MFAConfigured => "mfa-configured", Self::MFACode => "mfa-code", Self::PasswordReset => "password-reset", Self::PasswordResetDone => "password-reset-done", @@ -358,7 +358,7 @@ impl MailMessage { Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.mjml"), Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), - // Self::MFAConfigured => "", + Self::MFAConfigured => include_str!("../templates/mfa-configured.mjml"), Self::MFACode => include_str!("../templates/mfa-code.mjml"), // Self::PasswordReset => "", // Self::PasswordResetDone => "", diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 99fab706f1..f3be253f8a 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,7 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_MFA_CONFIGURED: &str = include_str!("../templates/mail_mfa_configured.tera"); static MAIL_NEW_DEVICE_LOGIN: &str = include_str!("../templates/mail_new_device_login.tera"); static MAIL_NEW_DEVICE_OCID_LOGIN: &str = include_str!("../templates/mail_new_device_ocid_login.tera"); @@ -311,16 +310,21 @@ pub async fn new_device_added_mail( Ok(()) } -pub fn mfa_configured_mail( +pub async fn mfa_configured_mail( + to: &str, + conn: &mut PgConnection, session: Option<&SessionContext>, method: &MFAMethod, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), session, None, None)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; + context.insert("mfa_method", &method); - tera.add_raw_template("mail_base", MAIL_BASE)?; - tera.add_raw_template("mail_mfa_configured", MAIL_MFA_CONFIGURED)?; - Ok(tera.render("mail_mfa_configured", &context)?) + let message = MailMessage::MFAConfigured; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } pub fn new_device_login_mail( @@ -519,12 +523,6 @@ mod test { let _ = SERVER_CONFIG.set(config.clone()); } - #[test] - fn test_mfa_configured_mail() { - let mfa_method = MFAMethod::OneTimePassword; - assert_ok!(mfa_configured_mail(None, &mfa_method)); - } - #[test] fn test_base_mail_no_context() { assert_ok!(get_base_tera(Context::new(), None, None, None)); diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 7b35cb8a0f..3e2015852c 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -4,7 +4,7 @@ use defguard_common::{ config::{DefGuardConfig, SERVER_CONFIG}, db::{ models::{ - Settings, + MFAMethod, Settings, settings::{SmtpEncryption, initialize_current_settings, set_settings}, }, setup_pool, @@ -20,8 +20,8 @@ use tera::Context; use super::templates::{ TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, - gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, new_account_mail, - new_device_added_mail, + gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, mfa_configured_mail, + new_account_mail, new_device_added_mail, }; /// Set SMTP settings from environment variables. @@ -242,3 +242,23 @@ fn send_gateway_reconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_mfa_configured_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + mfa_configured_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + None, + &MFAMethod::Email, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/mail_mfa_configured.tera b/crates/defguard_mail/templates/mail_mfa_configured.tera deleted file mode 100644 index f9f8ed34c7..0000000000 --- a/crates/defguard_mail/templates/mail_mfa_configured.tera +++ /dev/null @@ -1,11 +0,0 @@ -{# -Requires context: -mfa_method -> what method was activated -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [macros::paragraph(content="A Multi-Factor Authorization method: " ~ mfa_method ~ " has been -activated in your account.", align="center")] %} -{{ macros::text_section(content_array=section_content) }} -{% endblock %} diff --git a/crates/defguard_mail/templates/mfa-configured.mjml b/crates/defguard_mail/templates/mfa-configured.mjml new file mode 100644 index 0000000000..b1ab3fbff0 --- /dev/null +++ b/crates/defguard_mail/templates/mfa-configured.mjml @@ -0,0 +1,19 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + +

+ {{ mfa_method_label }} {{ mfa_method }} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index 46622f3aee..b086f21ed2 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -25,12 +25,13 @@ use defguard_core::{ client_version::ClientFeature, utils::{build_device_config_response, parse_client_ip_agent}, }, - handlers::{mail::send_mfa_configured_email, user::check_password_strength}, + handlers::user::check_password_strength, headers::get_device_info, is_valid_phone_number, }; use defguard_mail::templates::{ - TemplateLocation, enrollment_admin_notification, mfa_activation_mail, new_device_added_mail, + TemplateLocation, enrollment_admin_notification, mfa_activation_mail, mfa_configured_mail, + new_device_added_mail, }; use defguard_proto::proxy::{ ActivateUserRequest, AdminInfo, CodeMfaSetupFinishRequest, CodeMfaSetupFinishResponse, @@ -1041,8 +1042,12 @@ impl EnrollmentServer { .await .map_err(|_| Status::internal("Failed to get recovery codes.".to_string()))? .ok_or_else(|| Status::internal("Recovery codes not found".to_string()))?; - if let Err(e) = send_mfa_configured_email(None, &user, &mfa_method) { - error!("Failed to send mfa configured email\nReason: {e}"); + if let Ok(mut conn) = self.pool.begin().await { + if let Err(err) = mfa_configured_mail(&user.email, &mut conn, None, &mfa_method).await { + error!("Failed to send MFA configured email\nReason: {err}"); + } + } else { + error!("Failed to begin database session"); } info!( "Successfully enabled MFA method {} for user {}", diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index b2c5924b61..dd690a936a 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -13,4 +13,7 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('gateway-reconnect', 'title', 'en_US', 'Defguard Gateway has just disconnected.'), ('gateway-reconnect', 'gateway_label', 'en_US', 'Gateway name:'), ('gateway-reconnect', 'ip_address_label', 'en_US', 'Gateway IP address:'), - ('gateway-reconnect', 'location_label', 'en_US', 'VPN location:'); + ('gateway-reconnect', 'location_label', 'en_US', 'VPN location:'), + ('mfa-configured', 'title', 'en_US', 'Hello,'), + ('mfa-configured', 'subtitle', 'en_US', 'A Multi-Factor Authentication (MFA) has been activated in your account.'), + ('mfa-configured', 'mfa_method_label', 'en_US', 'MFA method:'); From 32a542564557d417407c942a58d60cbc1602148a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 12:20:51 +0100 Subject: [PATCH 06/13] new-device-login --- crates/defguard_core/src/handlers/mail.rs | 20 +---------- crates/defguard_core/src/headers.rs | 13 ++++--- crates/defguard_mail/src/mail.rs | 6 ++-- crates/defguard_mail/src/templates.rs | 26 +++++++------- crates/defguard_mail/src/tests.rs | 19 ++++++++++- .../templates/mail_new_device_login.tera | 19 ----------- .../templates/new-device-login.mjml | 34 +++++++++++++++++++ .../20260323081850_[2.0.0]_more_mjml.up.sql | 5 ++- 8 files changed, 83 insertions(+), 59 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_new_device_login.tera create mode 100644 crates/defguard_mail/templates/new-device-login.mjml diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 939e7067e9..d3d80c39a1 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Json, State}, http::StatusCode, }; -use chrono::{NaiveDateTime, Utc}; +use chrono::Utc; use defguard_common::db::{ Id, models::{User, gateway::Gateway, proxy::Proxy}, @@ -33,7 +33,6 @@ use crate::{ static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; -static NEW_DEVICE_LOGIN_EMAIL_SUBJECT: &str = "Defguard: new device logged in to your account"; pub(crate) static EMAIL_PASSWORD_RESET_START_SUBJECT: &str = "Defguard: Password reset"; pub(crate) static EMAIL_PASSWORD_RESET_SUCCESS_SUBJECT: &str = "Defguard: Password reset success"; @@ -234,23 +233,6 @@ pub async fn send_user_import_blocked_email(pool: &PgPool) -> Result<(), WebErro Ok(()) } -pub fn send_new_device_login_email( - user_email: &str, - session: &SessionContext, - created: NaiveDateTime, -) -> Result<(), TemplateError> { - debug!("User {user_email} new device login mail to {SUPPORT_EMAIL_ADDRESS}"); - - Mail::new( - user_email, - NEW_DEVICE_LOGIN_EMAIL_SUBJECT, - templates::new_device_login_mail(session, created)?, - ) - .send_and_forget(); - - Ok(()) -} - pub fn send_new_device_ocid_login_email( user_email: &str, oauth2client_name: &str, diff --git a/crates/defguard_core/src/headers.rs b/crates/defguard_core/src/headers.rs index 778c81031f..a577cd3b2f 100644 --- a/crates/defguard_core/src/headers.rs +++ b/crates/defguard_core/src/headers.rs @@ -5,12 +5,10 @@ use defguard_common::db::{ Id, models::{DeviceLoginEvent, User}, }; -use defguard_mail::templates::{SessionContext, TemplateError}; +use defguard_mail::templates::{SessionContext, TemplateError, new_device_login_mail}; use sqlx::PgPool; use uaparser::{Client, Parser, UserAgentParser}; -use crate::handlers::mail::send_new_device_login_email; - pub(crate) const CONTENT_SECURITY_POLICY_HEADER_NAME: HeaderName = HeaderName::from_static("content-security-policy"); pub(crate) const CONTENT_SECURITY_POLICY_HEADER_VALUE: HeaderValue = @@ -102,7 +100,14 @@ pub(crate) async fn check_new_device_login( .check_if_device_already_logged_in(pool) .await { - send_new_device_login_email(&user.email, session, created_device_login_event.created)?; + let mut conn = pool.begin().await?; + new_device_login_mail( + &user.email, + &mut conn, + Some(session), + created_device_login_event.created, + ) + .await?; } Ok(()) diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index c3341edaf2..fee7634b0f 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -309,7 +309,7 @@ impl MailMessage { Self::DesktopStart => "Defguard: Desktop client configuration", Self::NewAccount => "Defguard: User enrollment", Self::NewDevice => "Defguard: new device added to your account", - Self::NewDeviceLogin => "New device logged in to your account", + Self::NewDeviceLogin => "Defguard: New device logged in to your account", Self::NewDeviceOCIDLogin => "New login to OCID application", Self::GatewayDisconnect => "Defguard: Gateway disconnected", Self::GatewayReconnect => "Defguard: Gateway reconnected", @@ -331,7 +331,7 @@ impl MailMessage { Self::DesktopStart => "desktop-start", Self::NewAccount => "new-account", Self::NewDevice => "new-device", - Self::NewDeviceLogin => "new-device-loin", + Self::NewDeviceLogin => "new-device-login", Self::NewDeviceOCIDLogin => "new-device-login-ocid", Self::GatewayDisconnect => "gateway-disconnect", Self::GatewayReconnect => "gateway-reconnect", @@ -353,7 +353,7 @@ impl MailMessage { Self::DesktopStart => include_str!("../templates/desktop-start.mjml"), Self::NewAccount => include_str!("../templates/new-account.mjml"), Self::NewDevice => include_str!("../templates/new-device.mjml"), - // Self::NewDeviceLogin => "", + Self::NewDeviceLogin => include_str!("../templates/new-device-login.mjml"), // Self::NewDeviceOCIDLogin => "", Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.mjml"), diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index f3be253f8a..c4c2a69bca 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,7 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_NEW_DEVICE_LOGIN: &str = include_str!("../templates/mail_new_device_login.tera"); static MAIL_NEW_DEVICE_OCID_LOGIN: &str = include_str!("../templates/mail_new_device_ocid_login.tera"); static MAIL_PASSWORD_RESET_START: &str = @@ -327,19 +326,22 @@ pub async fn mfa_configured_mail( Ok(()) } -pub fn new_device_login_mail( - session: &SessionContext, +/// New device login. +pub async fn new_device_login_mail( + to: &str, + conn: &mut PgConnection, + session: Option<&SessionContext>, created: NaiveDateTime, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), Some(session), None, None)?; - tera.add_raw_template("mail_base", MAIL_BASE)?; - context.insert( - "date_now", - &created.format(MAIL_DATETIME_FORMAT).to_string(), - ); +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; + + context.insert("created", &created.format(MAIL_DATETIME_FORMAT).to_string()); - tera.add_raw_template("mail_new_device_login", MAIL_NEW_DEVICE_LOGIN)?; - Ok(tera.render("mail_new_device_login", &context)?) + let message = MailMessage::NewDeviceLogin; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } pub fn new_device_ocid_login_mail( diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 3e2015852c..62c08a3412 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -1,5 +1,6 @@ use std::{env, str::FromStr, time::Duration}; +use chrono::Utc; use defguard_common::{ config::{DefGuardConfig, SERVER_CONFIG}, db::{ @@ -21,7 +22,7 @@ use tera::Context; use super::templates::{ TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, mfa_configured_mail, - new_account_mail, new_device_added_mail, + new_account_mail, new_device_added_mail, new_device_login_mail, }; /// Set SMTP settings from environment variables. @@ -262,3 +263,19 @@ fn send_mfa_configured_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_device_login_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let created = Utc::now().naive_utc(); + new_device_login_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, created) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/mail_new_device_login.tera b/crates/defguard_mail/templates/mail_new_device_login.tera deleted file mode 100644 index abb287c5ed..0000000000 --- a/crates/defguard_mail/templates/mail_new_device_login.tera +++ /dev/null @@ -1,19 +0,0 @@ -{# Requires context -device_name -> name of the device added -public_key -> Public key of device added -locations -> { -name -> location name, -assigned_ip -> ip of device in location -}[] -#} -{% extends "base" %} -{% import "macros" as macros %} - -{# mail content #} -{% block mail_content %} -{# title #} -{% set section_content = [macros::paragraph(content="Your account was just logged into from a new device:")] %} -{{ macros::text_section(content_array=section_content) }} -{{ macros::spacer(height="40px")}} -{# render device section #} -{% endblock %} diff --git a/crates/defguard_mail/templates/new-device-login.mjml b/crates/defguard_mail/templates/new-device-login.mjml new file mode 100644 index 0000000000..14c7829703 --- /dev/null +++ b/crates/defguard_mail/templates/new-device-login.mjml @@ -0,0 +1,34 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + + {% if ip_address %} + + + {{ label_device }} + + + {{ ip_address }} + + + {% endif %} + + + {{ label_date }} + + + {{ created }} + + + + + + +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index dd690a936a..029bfc03ea 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -16,4 +16,7 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('gateway-reconnect', 'location_label', 'en_US', 'VPN location:'), ('mfa-configured', 'title', 'en_US', 'Hello,'), ('mfa-configured', 'subtitle', 'en_US', 'A Multi-Factor Authentication (MFA) has been activated in your account.'), - ('mfa-configured', 'mfa_method_label', 'en_US', 'MFA method:'); + ('mfa-configured', 'mfa_method_label', 'en_US', 'MFA method:'), + ('new-device-login', 'title', 'en_US', 'Your account was just logged into from a new device.'), + ('new-device-login', 'label_device', 'en_US', 'Device name:'), + ('new-device-login', 'label_date', 'en_US', 'Date:'); From eb41fe41351e99336f5afd0b71de67bf85ee2b70 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 13:30:03 +0100 Subject: [PATCH 07/13] password-reset --- crates/defguard_core/src/handlers/mail.rs | 46 +------------------ .../defguard_core/src/handlers/openid_flow.rs | 15 +++--- crates/defguard_core/src/handlers/user.rs | 40 ++++------------ crates/defguard_mail/src/mail.rs | 10 ++-- crates/defguard_mail/src/templates.rs | 38 ++++++++------- crates/defguard_mail/src/tests.rs | 43 ++++++++++++++++- .../templates/mail_new_device_ocid_login.tera | 22 --------- .../templates/mail_password_reset_start.tera | 39 ---------------- .../templates/new-device-ocid-login.mjml | 32 +++++++++++++ .../templates/password-reset.mjml | 19 ++++++++ .../src/servers/password_reset.rs | 33 +++++++------ .../20260323081850_[2.0.0]_more_mjml.up.sql | 8 +++- 12 files changed, 164 insertions(+), 181 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_new_device_ocid_login.tera delete mode 100644 crates/defguard_mail/templates/mail_password_reset_start.tera create mode 100644 crates/defguard_mail/templates/new-device-ocid-login.mjml create mode 100644 crates/defguard_mail/templates/password-reset.mjml diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index d3d80c39a1..1b5858af7e 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -9,11 +9,7 @@ use defguard_common::db::{ Id, models::{User, gateway::Gateway, proxy::Proxy}, }; -use defguard_mail::{ - Attachment, Mail, - templates::{self, SessionContext, TemplateError, support_data_mail}, -}; -use reqwest::Url; +use defguard_mail::{Attachment, Mail, templates}; use serde_json::json; use sqlx::query_scalar; use tera::Context; @@ -33,8 +29,6 @@ use crate::{ static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; - -pub(crate) static EMAIL_PASSWORD_RESET_START_SUBJECT: &str = "Defguard: Password reset"; pub(crate) static EMAIL_PASSWORD_RESET_SUCCESS_SUBJECT: &str = "Defguard: Password reset success"; #[derive(Clone, Deserialize)] @@ -143,7 +137,7 @@ pub async fn send_support_data( let result = Mail::new( SUPPORT_EMAIL_ADDRESS, SUPPORT_EMAIL_SUBJECT, - support_data_mail()?, + templates::support_data_mail()?, ) .set_attachments(vec![components, config, logs]) .send() @@ -233,42 +227,6 @@ pub async fn send_user_import_blocked_email(pool: &PgPool) -> Result<(), WebErro Ok(()) } -pub fn send_new_device_ocid_login_email( - user_email: &str, - oauth2client_name: &str, - session: &SessionContext, -) -> Result<(), TemplateError> { - debug!("User {user_email} new device OCID login mail to {SUPPORT_EMAIL_ADDRESS}"); - - Mail::new( - user_email, - format!("New login to {oauth2client_name} application with Defguard"), - templates::new_device_ocid_login_mail(session, oauth2client_name)?, - ) - .send_and_forget(); - - Ok(()) -} - -pub fn send_password_reset_email( - user: &User, - service_url: Url, - token: &str, - ip_address: Option<&str>, - device_info: Option<&str>, -) -> Result<(), TokenError> { - debug!("Sending password reset email to {}", user.email); - - Mail::new( - &user.email, - EMAIL_PASSWORD_RESET_START_SUBJECT, - templates::email_password_reset_mail(service_url, token, ip_address, device_info)?, - ) - .send_and_forget(); - - Ok(()) -} - pub fn send_password_reset_success_email( user: &User, ip_address: Option<&str>, diff --git a/crates/defguard_core/src/handlers/openid_flow.rs b/crates/defguard_core/src/handlers/openid_flow.rs index 469fc750a0..ea3d23a4bc 100644 --- a/crates/defguard_core/src/handlers/openid_flow.rs +++ b/crates/defguard_core/src/handlers/openid_flow.rs @@ -22,6 +22,7 @@ use defguard_common::db::{ oauth2client::OAuth2Client, }, }; +use defguard_mail::templates::new_device_ocid_login_mail; use openidconnect::{ AccessToken, AdditionalClaims, Audience, AuthUrl, AuthorizationCode, EmptyAdditionalProviderMetadata, EmptyExtraTokenFields, EndUserEmail, EndUserFamilyName, @@ -48,10 +49,7 @@ use crate::{ appstate::AppState, auth::{SessionInfo, UserClaims}, error::WebError, - handlers::{ - SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, cookie_domain, - mail::send_new_device_ocid_login_email, - }, + handlers::{SIGN_IN_COOKIE_MAX_AGE, SIGN_IN_COOKIE_NAME, cookie_domain}, server_config, }; @@ -581,11 +579,14 @@ pub async fn secure_authorization( let app = OAuth2AuthorizedApp::new(session_info.user.id, oauth2client.id); app.save(&appstate.pool).await?; - send_new_device_ocid_login_email( + let mut conn = appstate.pool.begin().await?; + new_device_ocid_login_mail( &session_info.user.email, + &mut conn, + Some(&session_info.session.into()), &oauth2client.name, - &session_info.session.into(), - )?; + ) + .await?; } info!( "User {} allowed login with client {}", diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index 850b0db81c..ff4c784aa9 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -14,7 +14,7 @@ use defguard_common::{ }, types::{group_diff::GroupDiff, user_info::UserInfo}, }; -use defguard_mail::{Mail, templates}; +use defguard_mail::templates; use humantime::parse_duration; use serde_json::json; use sqlx::PgPool; @@ -22,8 +22,7 @@ use utoipa::ToSchema; use super::{ AddUserData, ApiResponse, ApiResult, PasswordChange, PasswordChangeSelf, - StartEnrollmentRequest, Username, mail::EMAIL_PASSWORD_RESET_START_SUBJECT, - user_for_admin_or_self, + StartEnrollmentRequest, Username, user_for_admin_or_self, }; use crate::{ appstate::AppState, @@ -1133,34 +1132,15 @@ pub(crate) async fn reset_password( enrollment.save(&mut *transaction).await?; let public_proxy_url = settings.proxy_public_url()?; - let result = Mail::new( - user.email.clone(), - EMAIL_PASSWORD_RESET_START_SUBJECT, - templates::email_password_reset_mail( - public_proxy_url, - enrollment.id.clone().as_str(), - None, - None, - )?, + templates::password_reset_mail( + &user.email, + &mut transaction, + public_proxy_url, + enrollment.id.clone().as_str(), + None, + None, ) - .send() - .await; - - let to = &user.email; - match result { - Ok(()) => { - info!("Password reset email for {username} sent to {to}"); - Ok(()) - } - Err(err) => { - error!( - "Failed to send password reset email for {username} to {to} with error: {err}" - ); - Err(WebError::Serialization(format!( - "Could not send password reset email to user {username}" - ))) - } - }?; + .await?; transaction.commit().await?; diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index fee7634b0f..3e6630650a 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -316,8 +316,8 @@ impl MailMessage { Self::MFAActivation => "Multi-Factor Authentication activation", Self::MFAConfigured => "Multi-Factor Authentication {method} has been activated", Self::MFACode => "Defguard: Multi-Factor Authentication code for login", - Self::PasswordReset => "Password reset", - Self::PasswordResetDone => "Password reset success", + Self::PasswordReset => "Defguard: Password reset", + Self::PasswordResetDone => "Defguard: Password reset success", Self::UserImportBlocked => "User import blocked", Self::EnrollmentNotification => "Defguard: User enrollment completed", } @@ -332,7 +332,7 @@ impl MailMessage { Self::NewAccount => "new-account", Self::NewDevice => "new-device", Self::NewDeviceLogin => "new-device-login", - Self::NewDeviceOCIDLogin => "new-device-login-ocid", + Self::NewDeviceOCIDLogin => "new-device-ocid-login", Self::GatewayDisconnect => "gateway-disconnect", Self::GatewayReconnect => "gateway-reconnect", Self::MFAActivation => "mfa-activation", @@ -354,13 +354,13 @@ impl MailMessage { Self::NewAccount => include_str!("../templates/new-account.mjml"), Self::NewDevice => include_str!("../templates/new-device.mjml"), Self::NewDeviceLogin => include_str!("../templates/new-device-login.mjml"), - // Self::NewDeviceOCIDLogin => "", + Self::NewDeviceOCIDLogin => include_str!("../templates/new-device-ocid-login.mjml"), Self::GatewayDisconnect => include_str!("../templates/gateway-disconnected.mjml"), Self::GatewayReconnect => include_str!("../templates/gateway-reconnected.mjml"), Self::MFAActivation => include_str!("../templates/mfa-activation.mjml"), Self::MFAConfigured => include_str!("../templates/mfa-configured.mjml"), Self::MFACode => include_str!("../templates/mfa-code.mjml"), - // Self::PasswordReset => "", + Self::PasswordReset => include_str!("../templates/password-reset.mjml"), // Self::PasswordResetDone => "", Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), Self::EnrollmentNotification => { diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index c4c2a69bca..0a0128a5ad 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,10 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_NEW_DEVICE_OCID_LOGIN: &str = - include_str!("../templates/mail_new_device_ocid_login.tera"); -static MAIL_PASSWORD_RESET_START: &str = - include_str!("../templates/mail_password_reset_start.tera"); static MAIL_PASSWORD_RESET_SUCCESS: &str = include_str!("../templates/mail_password_reset_success.tera"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; @@ -344,20 +340,23 @@ pub async fn new_device_login_mail( Ok(()) } -pub fn new_device_ocid_login_mail( - session: &SessionContext, +pub async fn new_device_ocid_login_mail( + to: &str, + conn: &mut PgConnection, + session: Option<&SessionContext>, oauth2client_name: &str, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), Some(session), None, None)?; - tera.add_raw_template("mail_base", MAIL_BASE)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; let url = format!("{}me", Settings::url()?); - context.insert("oauth2client_name", &oauth2client_name); context.insert("profile_url", &url); - tera.add_raw_template("mail_new_device_oicd_login", MAIL_NEW_DEVICE_OCID_LOGIN)?; - Ok(tera.render("mail_new_device_oicd_login", &context)?) + let message = MailMessage::NewDeviceOCIDLogin; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } /// Notification about disconnected Gateway. @@ -456,13 +455,16 @@ pub async fn mfa_code_mail( Ok(()) } -pub fn email_password_reset_mail( +pub async fn password_reset_mail( + to: &str, + conn: &mut PgConnection, mut service_url: Url, password_reset_token: &str, ip_address: Option<&str>, device_info: Option<&str>, -) -> Result { - let (mut tera, mut context) = get_base_tera(Context::new(), None, ip_address, device_info)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = + get_base_tera_mjml(Context::new(), None, ip_address, device_info)?; context.insert("enrollment_url", &service_url); context.insert("defguard_url", &Settings::url()?); @@ -475,9 +477,11 @@ pub fn email_password_reset_mail( context.insert("link_url", &service_url); - tera.add_raw_template("mail_passowrd_reset_start", MAIL_PASSWORD_RESET_START)?; + let message = MailMessage::PasswordReset; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); - Ok(tera.render("mail_passowrd_reset_start", &context)?) + Ok(()) } pub fn email_password_reset_success_mail( diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 62c08a3412..9e5bed65c5 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -22,7 +22,8 @@ use tera::Context; use super::templates::{ TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, mfa_configured_mail, - new_account_mail, new_device_added_mail, new_device_login_mail, + new_account_mail, new_device_added_mail, new_device_login_mail, new_device_ocid_login_mail, + password_reset_mail, }; /// Set SMTP settings from environment variables. @@ -279,3 +280,43 @@ fn send_new_device_login_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_new_device_ocid_login_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let client_name = "RemoteApp"; + new_device_ocid_login_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, client_name) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_password_reset_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let proxy_url = Url::parse("http://localhost:8000").unwrap(); + let token = "blablabla"; + password_reset_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + proxy_url, + token, + None, + None, + ) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/mail_new_device_ocid_login.tera b/crates/defguard_mail/templates/mail_new_device_ocid_login.tera deleted file mode 100644 index 46ed5f0539..0000000000 --- a/crates/defguard_mail/templates/mail_new_device_ocid_login.tera +++ /dev/null @@ -1,22 +0,0 @@ -{# Requires context -device_name -> name of the device added -public_key -> Public key of device added -locations -> { -name -> location name, -assigned_ip -> ip of device in location -}[] -#} -{% extends "base" %} -{% import "macros" as macros %} - -{# mail content #} -{% block mail_content %} -{# title #} -{% set section_content = [ - macros::paragraph(content="Your account was just logged into a system: " ~ oauth2client_name ~ " using OpenID Connect authorization."), - macros::link(content="You can deauthorize all applications that have access to your account from the web vault under (My Profile > Apps).", href=profile_url), -] %} -{{ macros::text_section(content_array=section_content) }} -{{ macros::spacer(height="40px")}} -{# render device section #} -{% endblock %} diff --git a/crates/defguard_mail/templates/mail_password_reset_start.tera b/crates/defguard_mail/templates/mail_password_reset_start.tera deleted file mode 100644 index aea11126d5..0000000000 --- a/crates/defguard_mail/templates/mail_password_reset_start.tera +++ /dev/null @@ -1,39 +0,0 @@ -{# Requires context -enrollment_url -> URL of the enrollment service -link_url -> URL of the enrollment service with the token query param included -defguard_url -> URL of defguard core Web UI -token -> enrollment token -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set client_docs_url="https://docs.defguard.net/help/desktop-client" %} -{% set client_docs_link=macros::link(content=client_docs_url, href=client_docs_url) %} -{% set release_url="https://defguard.net/download/" %} -{% set release_link=macros::link(content=release_url, href=release_url) %} -{% set section_content = [ -macros::paragraph(content="Password reset"), -macros::paragraph(content= "If you wish to reset your password, please copy & paste the following URL in your browser: "), -macros::link(content=link_url, href=link_url), -macros::paragraph(content="Or click the button below:"), -] %} -{{ macros::text_section(content_array=section_content)}} -

Reset password

-{% endblock %} diff --git a/crates/defguard_mail/templates/new-device-ocid-login.mjml b/crates/defguard_mail/templates/new-device-ocid-login.mjml new file mode 100644 index 0000000000..e312f11976 --- /dev/null +++ b/crates/defguard_mail/templates/new-device-ocid-login.mjml @@ -0,0 +1,32 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + + + + {{ label_profile }} + + + {{ profile_url }} + + + + + {{ label_oauth2client }} + + + {{ oauth2client_name }} + + + + + + +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_mail/templates/password-reset.mjml b/crates/defguard_mail/templates/password-reset.mjml new file mode 100644 index 0000000000..159e3606d6 --- /dev/null +++ b/crates/defguard_mail/templates/password-reset.mjml @@ -0,0 +1,19 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + +

+ {{ link_url }} +

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_proxy_manager/src/servers/password_reset.rs b/crates/defguard_proxy_manager/src/servers/password_reset.rs index 84ad4a81a6..51127c2ea5 100644 --- a/crates/defguard_proxy_manager/src/servers/password_reset.rs +++ b/crates/defguard_proxy_manager/src/servers/password_reset.rs @@ -4,12 +4,10 @@ use defguard_core::{ enterprise::ldap::utils::ldap_change_password, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, PasswordResetEvent}, grpc::utils::parse_client_ip_agent, - handlers::{ - mail::{send_password_reset_email, send_password_reset_success_email}, - user::check_password_strength, - }, + handlers::{mail::send_password_reset_success_email, user::check_password_strength}, headers::get_device_info, }; +use defguard_mail::templates::password_reset_mail; use defguard_proto::proxy::{ DeviceInfo, PasswordResetInitializeRequest, PasswordResetRequest, PasswordResetStartRequest, PasswordResetStartResponse, @@ -140,23 +138,28 @@ impl PasswordResetServer { ); enrollment.save(&mut *transaction).await?; - transaction.commit().await.map_err(|_| { - error!("Failed to commit transaction"); - Status::internal("unexpected error") - })?; - - let public_proxy_url = settings.proxy_public_url().map_err(|err| { + let proxy_url = settings.proxy_public_url().map_err(|err| { error!("Failed to get public proxy URL: {err}"); Status::internal("unexpected error") })?; - - send_password_reset_email( - &user, - public_proxy_url, + if let Err(err) = password_reset_mail( + &user.email, + &mut transaction, + proxy_url, &enrollment.id, Some(&ip_address), Some(&device_info), - )?; + ) + .await + { + error!("Failed to send password reset email: {err}"); + return Err(Status::internal("password reset email")); + } + + transaction.commit().await.map_err(|_| { + error!("Failed to commit transaction"); + Status::internal("unexpected error") + })?; info!( "Finished processing password reset request for user {}.", diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index 029bfc03ea..a5c21d7430 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -19,4 +19,10 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('mfa-configured', 'mfa_method_label', 'en_US', 'MFA method:'), ('new-device-login', 'title', 'en_US', 'Your account was just logged into from a new device.'), ('new-device-login', 'label_device', 'en_US', 'Device name:'), - ('new-device-login', 'label_date', 'en_US', 'Date:'); + ('new-device-login', 'label_date', 'en_US', 'Date:'), + ('new-device-ocid-login', 'title', 'en_US', 'Your account was just logged into a system using OpenID Connect authorization'), + ('new-device-ocid-login', 'subtitle', 'en_US', 'You can deauthorize all applications that have access to your account from the web vault under (My Profile > Apps).'), + ('new-device-ocid-login', 'label_profile', 'en_US', 'Profile URL:'), + ('new-device-ocid-login', 'label_oauth2client', 'en_US', 'System name:'), + ('password-reset', 'title', 'en_US', 'Password reset'), + ('password-reset', 'subtitle', 'en_US', 'If you wish to reset your password, please copy and paste the following URL in your browser:'); From 8156314d7a3f60230712437980dc8148ad591e89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 13:43:23 +0100 Subject: [PATCH 08/13] password-reset-done --- crates/defguard_core/src/handlers/mail.rs | 24 +------- crates/defguard_mail/src/mail.rs | 2 +- crates/defguard_mail/src/templates.rs | 19 +++--- crates/defguard_mail/src/tests.rs | 59 ++++++++++++------- .../mail_password_reset_success.tera | 15 ----- .../templates/password-reset-done.mjml | 18 ++++++ .../src/servers/password_reset.rs | 17 ++++-- .../20260323081850_[2.0.0]_more_mjml.up.sql | 4 +- 8 files changed, 85 insertions(+), 73 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_password_reset_success.tera create mode 100644 crates/defguard_mail/templates/password-reset-done.mjml diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 1b5858af7e..0b08810431 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -5,10 +5,7 @@ use axum::{ http::StatusCode, }; use chrono::Utc; -use defguard_common::db::{ - Id, - models::{User, gateway::Gateway, proxy::Proxy}, -}; +use defguard_common::db::models::{User, gateway::Gateway, proxy::Proxy}; use defguard_mail::{Attachment, Mail, templates}; use serde_json::json; use sqlx::query_scalar; @@ -20,7 +17,6 @@ use crate::{ PgPool, appstate::AppState, auth::{AdminRole, SessionInfo}, - db::models::enrollment::TokenError, error::WebError, server_config, support::dump_config, @@ -29,7 +25,6 @@ use crate::{ static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; -pub(crate) static EMAIL_PASSWORD_RESET_SUCCESS_SUBJECT: &str = "Defguard: Password reset success"; #[derive(Clone, Deserialize)] pub struct TestMail { @@ -226,20 +221,3 @@ pub async fn send_user_import_blocked_email(pool: &PgPool) -> Result<(), WebErro Ok(()) } - -pub fn send_password_reset_success_email( - user: &User, - ip_address: Option<&str>, - device_info: Option<&str>, -) -> Result<(), TokenError> { - debug!("Sending password reset success email to {}", user.email); - - Mail::new( - &user.email, - EMAIL_PASSWORD_RESET_SUCCESS_SUBJECT, - templates::email_password_reset_success_mail(ip_address, device_info)?, - ) - .send_and_forget(); - - Ok(()) -} diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index 3e6630650a..b40965f336 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -361,7 +361,7 @@ impl MailMessage { Self::MFAConfigured => include_str!("../templates/mfa-configured.mjml"), Self::MFACode => include_str!("../templates/mfa-code.mjml"), Self::PasswordReset => include_str!("../templates/password-reset.mjml"), - // Self::PasswordResetDone => "", + Self::PasswordResetDone => include_str!("../templates/password-reset-done.mjml"), Self::UserImportBlocked => include_str!("../templates/plain-notification.mjml"), Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.mjml") diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 0a0128a5ad..f67727bd47 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -26,8 +26,6 @@ static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); -static MAIL_PASSWORD_RESET_SUCCESS: &str = - include_str!("../templates/mail_password_reset_success.tera"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; #[derive(Debug, Error)] @@ -455,6 +453,7 @@ pub async fn mfa_code_mail( Ok(()) } +/// Password reset email. pub async fn password_reset_mail( to: &str, conn: &mut PgConnection, @@ -484,15 +483,21 @@ pub async fn password_reset_mail( Ok(()) } -pub fn email_password_reset_success_mail( +/// Successful password reset email. +pub async fn password_reset_success_mail( + to: &str, + conn: &mut PgConnection, ip_address: Option<&str>, device_info: Option<&str>, -) -> Result { - let (mut tera, context) = get_base_tera(Context::new(), None, ip_address, device_info)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = + get_base_tera_mjml(Context::new(), None, ip_address, device_info)?; - tera.add_raw_template("mail_passowrd_reset_success", MAIL_PASSWORD_RESET_SUCCESS)?; + let message = MailMessage::PasswordResetDone; + message.fill_context(conn, &mut context).await?; + message.mail(&mut tera, &context, to)?.send_and_forget(); - Ok(tera.render("mail_passowrd_reset_success", &context)?) + Ok(()) } #[cfg(test)] diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 9e5bed65c5..6dc2e91a9f 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -19,12 +19,7 @@ use sqlx::{ }; use tera::Context; -use super::templates::{ - TemplateLocation, desktop_start_mail, enrollment_admin_notification, gateway_disconnected_mail, - gateway_reconnected_mail, mfa_activation_mail, mfa_code_mail, mfa_configured_mail, - new_account_mail, new_device_added_mail, new_device_login_mail, new_device_ocid_login_mail, - password_reset_mail, -}; +use super::templates; /// Set SMTP settings from environment variables. async fn set_smtp_settings(pool: &PgPool) { @@ -53,7 +48,7 @@ fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { let context = Context::new(); let url = Url::parse("http://localhost:8000").unwrap(); let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - desktop_start_mail( + templates::desktop_start_mail( &env::var("SMTP_TO").unwrap(), &mut conn, context, @@ -77,16 +72,16 @@ fn send_new_device_added(_: PgPoolOptions, options: PgConnectOptions) { let device_name = "My beloved machine"; let public_key = "6N8h7HILMcQ6nqEfQMBAYQH26X+y3t/WdWSOW4bNNxw="; let locations = &[ - TemplateLocation { + templates::TemplateLocation { name: String::from("Location 1"), assigned_ips: String::from("192.168.1.42"), }, - TemplateLocation { + templates::TemplateLocation { name: String::from("Location 2"), assigned_ips: String::from("192.168.2.69"), }, ]; - new_device_added_mail( + templates::new_device_added_mail( &env::var("SMTP_TO").unwrap(), &mut conn, device_name, @@ -111,7 +106,7 @@ fn send_mfa_code(_: PgPoolOptions, options: PgConnectOptions) { let mut conn = pool.begin().await.unwrap(); let first_name = "Nebuchadnezzar"; let code = "123456"; - mfa_code_mail( + templates::mfa_code_mail( &env::var("SMTP_TO").unwrap(), &mut conn, first_name, @@ -135,7 +130,7 @@ fn send_new_account(_: PgPoolOptions, options: PgConnectOptions) { let url = Url::parse("http://localhost:8001").unwrap(); let context = Context::new(); let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; - new_account_mail( + templates::new_account_mail( &env::var("SMTP_TO").unwrap(), &mut conn, context, @@ -158,7 +153,7 @@ fn send_mfa_activation(_: PgPoolOptions, options: PgConnectOptions) { let mut conn = pool.begin().await.unwrap(); let first_name = "Nebuchadnezzar"; let code = "123456"; - mfa_activation_mail( + templates::mfa_activation_mail( &env::var("SMTP_TO").unwrap(), &mut conn, first_name, @@ -182,7 +177,7 @@ fn send_enrollment_admin_notification(_: PgPoolOptions, options: PgConnectOption let user_name = "Nebuchadnezzar the Great"; let admin_name = "Nabopolassar the Admin"; let ip_address = "1.2.3.4"; - enrollment_admin_notification( + templates::enrollment_admin_notification( &env::var("SMTP_TO").unwrap(), &mut conn, user_name, @@ -207,7 +202,7 @@ fn send_gateway_disconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { let gateway_name = "Portal"; let ip_address = "1.2.3.4"; let location_name = "Somewhere"; - gateway_disconnected_mail( + templates::gateway_disconnected_mail( &env::var("SMTP_TO").unwrap(), &mut conn, gateway_name, @@ -231,7 +226,7 @@ fn send_gateway_reconnected_mail(_: PgPoolOptions, options: PgConnectOptions) { let gateway_name = "Portal"; let ip_address = "1.2.3.4"; let location_name = "Somewhere"; - gateway_reconnected_mail( + templates::gateway_reconnected_mail( &env::var("SMTP_TO").unwrap(), &mut conn, gateway_name, @@ -252,7 +247,7 @@ fn send_mfa_configured_mail(_: PgPoolOptions, options: PgConnectOptions) { set_smtp_settings(&pool).await; let mut conn = pool.begin().await.unwrap(); - mfa_configured_mail( + templates::mfa_configured_mail( &env::var("SMTP_TO").unwrap(), &mut conn, None, @@ -273,7 +268,7 @@ fn send_new_device_login_mail(_: PgPoolOptions, options: PgConnectOptions) { let mut conn = pool.begin().await.unwrap(); let created = Utc::now().naive_utc(); - new_device_login_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, created) + templates::new_device_login_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, created) .await .unwrap(); @@ -289,9 +284,14 @@ fn send_new_device_ocid_login_mail(_: PgPoolOptions, options: PgConnectOptions) let mut conn = pool.begin().await.unwrap(); let client_name = "RemoteApp"; - new_device_ocid_login_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, client_name) - .await - .unwrap(); + templates::new_device_ocid_login_mail( + &env::var("SMTP_TO").unwrap(), + &mut conn, + None, + client_name, + ) + .await + .unwrap(); // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; @@ -306,7 +306,7 @@ fn send_password_reset_mail(_: PgPoolOptions, options: PgConnectOptions) { let mut conn = pool.begin().await.unwrap(); let proxy_url = Url::parse("http://localhost:8000").unwrap(); let token = "blablabla"; - password_reset_mail( + templates::password_reset_mail( &env::var("SMTP_TO").unwrap(), &mut conn, proxy_url, @@ -320,3 +320,18 @@ fn send_password_reset_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_password_reset_success_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + templates::password_reset_success_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None, None) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/mail_password_reset_success.tera b/crates/defguard_mail/templates/mail_password_reset_success.tera deleted file mode 100644 index 3facf0f85a..0000000000 --- a/crates/defguard_mail/templates/mail_password_reset_success.tera +++ /dev/null @@ -1,15 +0,0 @@ -{# Requires context -enrollment_url -> URL of the enrollment service -link_url -> URL of the enrollment service with the token query param included -defguard_url -> URL of defguard core Web UI -token -> enrollment token -#} -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="Password reset"), -macros::paragraph(content= "Your password has been successfully changed."), -] %} -{{ macros::text_section(content_array=section_content)}} -{% endblock %} diff --git a/crates/defguard_mail/templates/password-reset-done.mjml b/crates/defguard_mail/templates/password-reset-done.mjml new file mode 100644 index 0000000000..c56e0b4f8c --- /dev/null +++ b/crates/defguard_mail/templates/password-reset-done.mjml @@ -0,0 +1,18 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + + + + +

+

+
+
+
+ +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/crates/defguard_proxy_manager/src/servers/password_reset.rs b/crates/defguard_proxy_manager/src/servers/password_reset.rs index 51127c2ea5..742de3162c 100644 --- a/crates/defguard_proxy_manager/src/servers/password_reset.rs +++ b/crates/defguard_proxy_manager/src/servers/password_reset.rs @@ -4,10 +4,10 @@ use defguard_core::{ enterprise::ldap::utils::ldap_change_password, events::{BidiRequestContext, BidiStreamEvent, BidiStreamEventType, PasswordResetEvent}, grpc::utils::parse_client_ip_agent, - handlers::{mail::send_password_reset_success_email, user::check_password_strength}, + handlers::user::check_password_strength, headers::get_device_info, }; -use defguard_mail::templates::password_reset_mail; +use defguard_mail::templates::{password_reset_mail, password_reset_success_mail}; use defguard_proto::proxy::{ DeviceInfo, PasswordResetInitializeRequest, PasswordResetRequest, PasswordResetStartRequest, PasswordResetStartResponse, @@ -292,6 +292,17 @@ impl PasswordResetServer { Status::internal("unexpected error") })?; + if let Err(err) = password_reset_success_mail( + &user.email, + &mut transaction, + Some(&ip_address), + Some(&device_info), + ) + .await + { + error!("Failed to send password reset success email: {err}"); + } + transaction.commit().await.map_err(|_| { error!("Failed to commit transaction"); Status::internal("unexpected error") @@ -299,8 +310,6 @@ impl PasswordResetServer { ldap_change_password(&mut user, &request.password, &self.pool).await; - send_password_reset_success_email(&user, Some(&ip_address), Some(&device_info))?; - // Prepare event context and push the event let (ip, user_agent) = parse_client_ip_agent(&req_device_info).map_err(Status::internal)?; let context = BidiRequestContext::new(user.id, user.username, ip, user_agent); diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index a5c21d7430..661f7138b4 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -25,4 +25,6 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('new-device-ocid-login', 'label_profile', 'en_US', 'Profile URL:'), ('new-device-ocid-login', 'label_oauth2client', 'en_US', 'System name:'), ('password-reset', 'title', 'en_US', 'Password reset'), - ('password-reset', 'subtitle', 'en_US', 'If you wish to reset your password, please copy and paste the following URL in your browser:'); + ('password-reset', 'subtitle', 'en_US', 'If you wish to reset your password, please copy and paste the following URL in your browser:'), + ('password-reset-done', 'title', 'en_US', 'Password reset'), + ('password-reset-done', 'subtitle', 'en_US', 'Your password has been successfully changed.'); From 77b1cc05c75708d378152f3bf0aaa4cdcb8d7f35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Mon, 23 Mar 2026 14:23:33 +0100 Subject: [PATCH 09/13] test-mail --- crates/defguard_core/src/handlers/mail.rs | 27 +++++------- crates/defguard_mail/src/mail.rs | 6 +-- crates/defguard_mail/src/templates.rs | 33 +++++++------- crates/defguard_mail/src/tests.rs | 15 +++++++ crates/defguard_mail/templates/test.mjml | 43 +++---------------- .../20260323081850_[2.0.0]_more_mjml.up.sql | 4 +- 6 files changed, 53 insertions(+), 75 deletions(-) diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index 0b08810431..e704f4ed84 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -22,7 +22,6 @@ use crate::{ support::dump_config, }; -static TEST_MAIL_SUBJECT: &str = "Defguard email test"; static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; @@ -40,9 +39,10 @@ fn internal_error(to: &str, subject: &str, error: impl Display) -> ApiResponse { ) } -pub async fn test_mail( +pub(crate) async fn test_mail( _admin: AdminRole, session: SessionInfo, + State(appstate): State, Json(data): Json, ) -> ApiResult { debug!( @@ -50,22 +50,15 @@ pub async fn test_mail( session.user.username, data.to ); - let result = Mail::new( - &data.to, - TEST_MAIL_SUBJECT, - templates::test_mail(Some(&session.session.into()))?, - ) - .send() - .await; + let mut conn = appstate.pool.begin().await?; + templates::test_mail(&data.to, &mut conn, Some(&session.session.into())).await?; - let (to, subject) = (&data.to, TEST_MAIL_SUBJECT); - match result { - Ok(()) => { - info!("User {} sent test mail to {to}", session.user.username); - Ok(ApiResponse::with_status(StatusCode::OK)) - } - Err(err) => Ok(internal_error(to, subject, &err)), - } + info!( + "User {} sent test mail to {}", + session.user.username, data.to + ); + + Ok(ApiResponse::with_status(StatusCode::OK)) } async fn read_logs() -> String { diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index b40965f336..bbfe05686a 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -303,9 +303,9 @@ impl MailMessage { /// Email subject. pub(crate) const fn subject(&self) -> &'static str { match self { - Self::Test => "Test message", + Self::Test => "Defguard: Test message", Self::Welcome => "Welcome message after enrollment", - Self::Support => "Support data", + Self::Support => "Defguard: Support data", Self::DesktopStart => "Defguard: Desktop client configuration", Self::NewAccount => "Defguard: User enrollment", Self::NewDevice => "Defguard: new device added to your account", @@ -347,7 +347,7 @@ impl MailMessage { pub(crate) const fn mjml_template(&self) -> &str { match self { - // Self::Test => "", + Self::Test => include_str!("../templates/test.mjml"), // Self::Welcome => "", // Self::Support => "", Self::DesktopStart => include_str!("../templates/desktop-start.mjml"), diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index f67727bd47..856b66319d 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -12,7 +12,7 @@ use serde_json::Value; use sqlx::PgConnection; use tera::{Context, Function, Tera}; use thiserror::Error; -use tracing::debug; +use tracing::{debug, warn}; use crate::mail::MailMessage; @@ -23,7 +23,6 @@ static MACROS_MJML: &str = include_str!("../templates/macros.mjml"); static MAIL_BASE: &str = include_str!("../templates/base.tera"); static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); -static MAIL_TEST: &str = include_str!("../templates/test.mjml"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; @@ -141,18 +140,22 @@ fn get_base_tera_mjml( Ok((tera, context)) } -// Sends test message when requested during SMTP configuration process. -pub fn test_mail(session: Option<&SessionContext>) -> Result { - let (mut tera, context) = get_base_tera_mjml(Context::new(), session, None, None)?; - tera.add_raw_template("mail_test", MAIL_TEST)?; - - let processed = tera.render("mail_test", &context)?; +/// Sends test message when requested during SMTP configuration process. +/// Note: this function waits for the result. +pub async fn test_mail( + to: &str, + conn: &mut PgConnection, + session: Option<&SessionContext>, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), session, None, None)?; - let parsed = mrml::parse(processed)?; - let opts = mrml::prelude::render::RenderOptions::default(); - let html = parsed.element.render(&opts)?; + let message = MailMessage::Test; + message.fill_context(conn, &mut context).await?; + if let Err(err) = message.mail(&mut tera, &context, to)?.send().await { + warn!("Failed to send test email: {err}"); + } - Ok(html) + Ok(()) } pub async fn user_import_blocked_mail( @@ -338,6 +341,7 @@ pub async fn new_device_login_mail( Ok(()) } +/// New device login from OpenID Connect. pub async fn new_device_ocid_login_mail( to: &str, conn: &mut PgConnection, @@ -539,11 +543,6 @@ mod test { assert_ok!(get_base_tera(Context::new(), None, None, None)); } - #[test] - fn test_test_mail() { - assert_ok!(test_mail(None)); - } - #[sqlx::test] async fn test_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 6dc2e91a9f..e5cf5f2bb5 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -335,3 +335,18 @@ fn send_password_reset_success_mail(_: PgPoolOptions, options: PgConnectOptions) // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_test_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + templates::test_mail(&env::var("SMTP_TO").unwrap(), &mut conn, None) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/test.mjml b/crates/defguard_mail/templates/test.mjml index ebb58aae38..d42aa1b34f 100644 --- a/crates/defguard_mail/templates/test.mjml +++ b/crates/defguard_mail/templates/test.mjml @@ -1,40 +1,9 @@ - - +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} - - - - Defguard - - - +{{ macros::email_header() }} - - - - Defguard: subject - +{{ macros::footer_divider() }} - - Lorem ipsum dolor sit amet, consectetur adipiscing elit. Proin rutrum enim - eget magna efficitur, eu semper augue semper. Aliquam erat volutpat. Cras - id dui lectus. Vestibulum sed finibus lectus, sit amet suscipit nibh. - Proin nec commodo purus. Sed eget nulla elit. Nulla aliquet mollis - faucibus. - - - Learn more - - - - +{% endblock content %} diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index 661f7138b4..bbdd9004a1 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -27,4 +27,6 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('password-reset', 'title', 'en_US', 'Password reset'), ('password-reset', 'subtitle', 'en_US', 'If you wish to reset your password, please copy and paste the following URL in your browser:'), ('password-reset-done', 'title', 'en_US', 'Password reset'), - ('password-reset-done', 'subtitle', 'en_US', 'Your password has been successfully changed.'); + ('password-reset-done', 'subtitle', 'en_US', 'Your password has been successfully changed.'), + ('test', 'title', 'en_US', 'This is test email from Defguard system.'), + ('test', 'subtitle', 'en_US', 'If you received it, your SMTP configuration is correct.'); From b99c744254b46d62eb96cb82c9d7271adfed3a31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Tue, 24 Mar 2026 10:02:10 +0100 Subject: [PATCH 10/13] support-data --- Cargo.lock | 20 ++--- crates/defguard_common/src/db/models/user.rs | 7 +- crates/defguard_core/src/error.rs | 5 +- crates/defguard_core/src/handlers/mail.rs | 76 ++++++++----------- crates/defguard_core/src/handlers/support.rs | 18 ++++- crates/defguard_core/src/support.rs | 35 +++++---- crates/defguard_mail/src/mail.rs | 11 ++- crates/defguard_mail/src/templates.rs | 31 +++++--- crates/defguard_mail/src/tests.rs | 21 ++++- .../templates/mail_support_data.tera | 6 -- crates/defguard_mail/templates/mail_test.tera | 8 -- .../defguard_mail/templates/support-data.mjml | 9 +++ .../20260323081850_[2.0.0]_more_mjml.up.sql | 4 +- 13 files changed, 136 insertions(+), 115 deletions(-) delete mode 100644 crates/defguard_mail/templates/mail_support_data.tera delete mode 100644 crates/defguard_mail/templates/mail_test.tera create mode 100644 crates/defguard_mail/templates/support-data.mjml diff --git a/Cargo.lock b/Cargo.lock index 151f5a0064..550a08a6b0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -558,18 +558,18 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitfields" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d866f92dc1574aa8da443eacb06ad8fbe4056dbc1b7c3aae508cbccd46c7e706" +checksum = "ef6e59298da389bc0649c7463856b34c6e17fe542f88939426ede4436c6b1195" dependencies = [ "bitfields-impl", ] [[package]] name = "bitfields-impl" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c09459e6af3016ea58af8332e31d5da117d33a621bad7019355eefccc4a567d4" +checksum = "f2c044f98f86f15414668d6c8187c7e4fadab1ad2b31680f648703e0fe07c555" dependencies = [ "proc-macro2", "quote", @@ -6450,18 +6450,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.1+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b320e741db58cac564e26c607d3cc1fdc4a88fd36c879568c07856ed83ff3e9" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.5+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca1a40644a28bce036923f6a431df0b34236949d111cc07cb6dca830c9ef2e1" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap 2.13.0", "toml_datetime", @@ -6471,9 +6471,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.10+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df25b4befd31c4816df190124375d5a20c6b6921e2cad937316de3fccd63420" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] diff --git a/crates/defguard_common/src/db/models/user.rs b/crates/defguard_common/src/db/models/user.rs index 6509b9e5f7..4dc04df95c 100644 --- a/crates/defguard_common/src/db/models/user.rs +++ b/crates/defguard_common/src/db/models/user.rs @@ -585,14 +585,17 @@ impl User { /// Select all users without sensitive data. // FIXME: Remove it when Model macro will support SecretString - pub async fn all_without_sensitive_data(pool: &PgPool) -> sqlx::Result> { + pub async fn all_without_sensitive_data<'e, E>(executor: E) -> sqlx::Result> + where + E: PgExecutor<'e>, + { let users = query!( "SELECT id, mfa_enabled, totp_enabled, email_mfa_enabled, \ mfa_method \"mfa_method: MFAMethod\", password_hash, is_active, openid_sub, \ from_ldap, ldap_pass_randomized, ldap_rdn \ FROM \"user\"" ) - .fetch_all(pool) + .fetch_all(executor) .await?; let res = users .iter() diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 1067667439..6d00bfcbae 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -126,10 +126,11 @@ impl From for WebError { match error { DeviceError::PubkeyConflict(..) => Self::PubkeyValidation(error.to_string()), DeviceError::DatabaseError(_) => Self::DbError(error.to_string()), - DeviceError::NetworkIpAssignmentError(_) => Self::ModelError(error.to_string()), DeviceError::Unexpected(_) => Self::Http(StatusCode::INTERNAL_SERVER_ERROR), DeviceError::NetworkFull(_) => Self::NetworkFull(error.to_string()), - DeviceError::ModelError(_) => Self::ModelError(error.to_string()), + DeviceError::NetworkIpAssignmentError(_) | DeviceError::ModelError(_) => { + Self::ModelError(error.to_string()) + } } } } diff --git a/crates/defguard_core/src/handlers/mail.rs b/crates/defguard_core/src/handlers/mail.rs index e704f4ed84..2a02d3ef82 100644 --- a/crates/defguard_core/src/handlers/mail.rs +++ b/crates/defguard_core/src/handlers/mail.rs @@ -1,12 +1,13 @@ -use std::fmt::Display; - use axum::{ extract::{Json, State}, http::StatusCode, }; use chrono::Utc; use defguard_common::db::models::{User, gateway::Gateway, proxy::Proxy}; -use defguard_mail::{Attachment, Mail, templates}; +use defguard_mail::{ + Attachment, + templates::{self, SUPPORT_EMAIL_ADDRESS}, +}; use serde_json::json; use sqlx::query_scalar; use tera::Context; @@ -22,23 +23,11 @@ use crate::{ support::dump_config, }; -static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; -static SUPPORT_EMAIL_SUBJECT: &str = "Defguard: Support data"; - #[derive(Clone, Deserialize)] pub struct TestMail { pub to: String, } -/// Handles logging the error and returns ApiResponse that contains it -fn internal_error(to: &str, subject: &str, error: impl Display) -> ApiResponse { - error!("Error sending mail to {to}, subject: {subject}, error: {error}"); - ApiResponse::new( - json!({"error": error.to_string()}), - StatusCode::INTERNAL_SERVER_ERROR, - ) -} - pub(crate) async fn test_mail( _admin: AdminRole, session: SessionInfo, @@ -81,13 +70,11 @@ pub async fn send_support_data( session: SessionInfo, State(appstate): State, ) -> ApiResult { - debug!( - "User {} sending support mail to {SUPPORT_EMAIL_ADDRESS}", - session.user.username - ); + debug!("User {} sending support mail", session.user.username); - let proxies = Proxy::all(&appstate.pool).await?; - let gateways = Gateway::all(&appstate.pool).await?; + let mut conn = appstate.pool.begin().await?; + let proxies = Proxy::all(&mut *conn).await?; + let gateways = Gateway::all(&mut *conn).await?; let components_info = json!({ "proxies": proxies.iter().map(|p| json!({ @@ -108,40 +95,37 @@ pub async fn send_support_data( "connected_at": g.connected_at, })).collect::>(), }); - + let now = Utc::now(); let components_json = serde_json::to_vec(&components_info).unwrap_or(b"JSON formatting error".into()); - - let components = Attachment::new( - format!("defguard-components-{}.json", Utc::now()), - components_json, - ); - - let config = dump_config(&appstate.pool).await; + let components = Attachment::new(format!("defguard-components-{now}.json"), components_json); + let config = dump_config(&mut conn) + .await + .unwrap_or(json!({"err": "Failed to dump configuration"})); let config = serde_json::to_vec_pretty(&config).unwrap_or(b"JSON formatting error".into()); - let config = Attachment::new(format!("defguard-support-data-{}.json", Utc::now()), config); + let config = Attachment::new(format!("defguard-support-data-{now}.json"), config); let logs = read_logs().await; - let logs = Attachment::new(format!("defguard-logs-{}.txt", Utc::now()), logs.into()); - let result = Mail::new( + let logs = Attachment::new(format!("defguard-logs-{now}.txt"), logs.into()); + + let result = templates::support_data_mail( SUPPORT_EMAIL_ADDRESS, - SUPPORT_EMAIL_SUBJECT, - templates::support_data_mail()?, + &mut conn, + vec![components, config, logs], ) - .set_attachments(vec![components, config, logs]) - .send() .await; - - let (to, subject) = (SUPPORT_EMAIL_ADDRESS, SUPPORT_EMAIL_SUBJECT); - match result { + Ok(match result { Ok(()) => { - info!( - "User {} sent support mail to {SUPPORT_EMAIL_ADDRESS}", - session.user.username - ); - Ok(ApiResponse::with_status(StatusCode::OK)) + info!("User {} sent support mail", session.user.username); + ApiResponse::with_status(StatusCode::OK) } - Err(err) => Ok(internal_error(to, subject, &err)), - } + Err(err) => { + error!("Error sending support mail: {err}"); + ApiResponse::new( + json!({"error": err.to_string()}), + StatusCode::INTERNAL_SERVER_ERROR, + ) + } + }) } pub async fn send_gateway_disconnected_email( diff --git a/crates/defguard_core/src/handlers/support.rs b/crates/defguard_core/src/handlers/support.rs index 64e370113f..131967075f 100644 --- a/crates/defguard_core/src/handlers/support.rs +++ b/crates/defguard_core/src/handlers/support.rs @@ -15,9 +15,21 @@ pub async fn configuration( session: SessionInfo, ) -> ApiResult { debug!("User {} dumping app configuration", session.user.username); - let config = dump_config(&appstate.pool).await; - info!("User {} dumped app configuration", session.user.username); - Ok(ApiResponse::new(config, StatusCode::OK)) + + let mut conn = appstate.pool.begin().await?; + Ok(match dump_config(&mut conn).await { + Ok(config) => { + info!("User {} dumped app configuration", session.user.username); + ApiResponse::new(config, StatusCode::OK) + } + Err(err) => { + warn!("Failed to dump app configuration: {err}"); + ApiResponse::json( + serde_json::json!({"err": err.to_string()}), + StatusCode::BAD_REQUEST, + ) + } + }) } pub async fn logs(_admin: AdminRole, session: SessionInfo) -> Result { diff --git a/crates/defguard_core/src/support.rs b/crates/defguard_core/src/support.rs index 667dbe1cdc..806ed6e9c1 100644 --- a/crates/defguard_core/src/support.rs +++ b/crates/defguard_core/src/support.rs @@ -12,22 +12,22 @@ use defguard_common::{ }; use serde::Serialize; use serde_json::{Value, json, value::to_value}; -use sqlx::PgPool; +use sqlx::PgConnection; use crate::server_config; /// Unwraps the result returning a JSON representation of value or error -fn unwrap_json(result: Result) -> Value { - match result { - Ok(value) => to_value(value).expect("conversion to JSON failed"), +fn unwrap_json(result: Result) -> Result { + Ok(match result { + Ok(value) => to_value(value)?, Err(err) => json!({"error": err.to_string()}), - } + }) } /// Dumps all data that could be used for debugging. -pub(crate) async fn dump_config(db: &PgPool) -> Value { +pub(crate) async fn dump_config(conn: &mut PgConnection) -> Result { // App settings DB records - let settings = match Settings::get(db).await { + let settings = match Settings::get(&mut *conn).await { Ok(Some(mut settings)) => { settings.smtp_password = None; json!(settings) @@ -36,26 +36,25 @@ pub(crate) async fn dump_config(db: &PgPool) -> Value { Err(err) => json!({"error": err.to_string()}), }; // Networks - let (networks, devices) = match WireguardNetwork::all(db).await { + let (networks, devices) = match WireguardNetwork::all(&mut *conn).await { Ok(networks) => { // Devices for each network let mut devices = HashMap::::new(); for network in &networks { devices.insert( network.id, - unwrap_json(WireguardNetworkDevice::all_for_network(db, network.id).await), + unwrap_json( + WireguardNetworkDevice::all_for_network(&mut *conn, network.id).await, + )?, ); } - ( - to_value(networks).expect("JSON serialization error"), - to_value(devices).expect("JSON serialization error"), - ) + (to_value(networks)?, to_value(devices)?) } Err(err) => (json!({"error": err.to_string()}), Value::Null), }; - let users_diagnostic_data = unwrap_json(User::all_without_sensitive_data(db).await); + let users_diagnostic_data = unwrap_json(User::all_without_sensitive_data(&mut *conn).await)?; - let proxies = match Proxy::all(db).await { + let proxies = match Proxy::all(&mut *conn).await { Ok(proxies) => json!( proxies .iter() @@ -71,7 +70,7 @@ pub(crate) async fn dump_config(db: &PgPool) -> Value { Err(err) => json!({"error": err.to_string()}), }; - let gateways = match Gateway::all(db).await { + let gateways = match Gateway::all(&mut *conn).await { Ok(gateways) => json!( gateways .iter() @@ -89,7 +88,7 @@ pub(crate) async fn dump_config(db: &PgPool) -> Value { Err(err) => json!({"error": err.to_string()}), }; - json!({ + Ok(json!({ "settings": settings, "networks": networks, "version": VERSION, @@ -98,5 +97,5 @@ pub(crate) async fn dump_config(db: &PgPool) -> Value { "config": server_config(), "proxies": proxies, "gateways": gateways, - }) + })) } diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index bbfe05686a..d096c372a5 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -276,7 +276,7 @@ pub enum MailMessage { Test, Welcome, /// Information for Defguard support. - Support, + SupportData, DesktopStart, /// Information after starting an enrollment. NewAccount, @@ -305,7 +305,7 @@ impl MailMessage { match self { Self::Test => "Defguard: Test message", Self::Welcome => "Welcome message after enrollment", - Self::Support => "Defguard: Support data", + Self::SupportData => "Defguard: Support data", Self::DesktopStart => "Defguard: Desktop client configuration", Self::NewAccount => "Defguard: User enrollment", Self::NewDevice => "Defguard: new device added to your account", @@ -327,7 +327,7 @@ impl MailMessage { match self { Self::Test => "test", Self::Welcome => "welcome", - Self::Support => "support", + Self::SupportData => "support-data", Self::DesktopStart => "desktop-start", Self::NewAccount => "new-account", Self::NewDevice => "new-device", @@ -348,8 +348,8 @@ impl MailMessage { pub(crate) const fn mjml_template(&self) -> &str { match self { Self::Test => include_str!("../templates/test.mjml"), - // Self::Welcome => "", - // Self::Support => "", + Self::Welcome => "", + Self::SupportData => include_str!("../templates/support-data.mjml"), Self::DesktopStart => include_str!("../templates/desktop-start.mjml"), Self::NewAccount => include_str!("../templates/new-account.mjml"), Self::NewDevice => include_str!("../templates/new-device.mjml"), @@ -366,7 +366,6 @@ impl MailMessage { Self::EnrollmentNotification => { include_str!("../templates/enrollment-admin-notification.mjml") } - _ => "", } } diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 856b66319d..ec6feae427 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -14,17 +14,18 @@ use tera::{Context, Function, Tera}; use thiserror::Error; use tracing::{debug, warn}; -use crate::mail::MailMessage; +use crate::{Attachment, mail::MailMessage}; pub(crate) const DEFAULT_LANG: &str = "en_US"; +pub static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; + static BASE_MJML: &str = include_str!("../templates/base.mjml"); static MACROS_MJML: &str = include_str!("../templates/macros.mjml"); static MAIL_BASE: &str = include_str!("../templates/base.tera"); static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); -static MAIL_SUPPORT_DATA: &str = include_str!("../templates/mail_support_data.tera"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; #[derive(Debug, Error)] @@ -270,11 +271,22 @@ pub async fn enrollment_admin_notification( Ok(()) } -// message with support data -pub fn support_data_mail() -> Result { - let (mut tera, context) = get_base_tera(Context::new(), None, None, None)?; - tera.add_raw_template("mail_support_data", MAIL_SUPPORT_DATA)?; - Ok(tera.render("mail_support_data", &context)?) +/// Email with support data +pub async fn support_data_mail( + to: &str, + conn: &mut PgConnection, + attachments: Vec, +) -> Result<(), TemplateError> { + let (mut tera, mut context) = get_base_tera_mjml(Context::new(), None, None, None)?; + + let message = MailMessage::SupportData; + message.fill_context(conn, &mut context).await?; + message + .mail(&mut tera, &context, to)? + .set_attachments(attachments) + .send_and_forget(); + + Ok(()) } #[derive(Serialize)] @@ -538,11 +550,6 @@ mod test { let _ = SERVER_CONFIG.set(config.clone()); } - #[test] - fn test_base_mail_no_context() { - assert_ok!(get_base_tera(Context::new(), None, None, None)); - } - #[sqlx::test] async fn test_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index e5cf5f2bb5..e54cbe68b2 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -19,7 +19,7 @@ use sqlx::{ }; use tera::Context; -use super::templates; +use super::{Attachment, templates}; /// Set SMTP settings from environment variables. async fn set_smtp_settings(pool: &PgPool) { @@ -350,3 +350,22 @@ fn send_test_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_support_data_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let mut conn = pool.begin().await.unwrap(); + let config = Attachment::new( + "defguard-support-data-test.json".into(), + b"{\"key\":\"value\"}".into(), + ); + templates::support_data_mail(&env::var("SMTP_TO").unwrap(), &mut conn, vec![config]) + .await + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/mail_support_data.tera b/crates/defguard_mail/templates/mail_support_data.tera deleted file mode 100644 index 726808ac0e..0000000000 --- a/crates/defguard_mail/templates/mail_support_data.tera +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [macros::paragraph(content="Support data in attachments.")] %} -{{ macros::text_section(content_array=section_content)}} -{% endblock %} diff --git a/crates/defguard_mail/templates/mail_test.tera b/crates/defguard_mail/templates/mail_test.tera deleted file mode 100644 index c90de0c5b1..0000000000 --- a/crates/defguard_mail/templates/mail_test.tera +++ /dev/null @@ -1,8 +0,0 @@ -{% import "macros" as macros %} -{% extends "base" %} -{% block mail_content %} -{% set section_content = [ -macros::paragraph(content="This is test email from Defguard system."), -macros::paragraph(content="If you received it, your SMTP configuration is ok.")] %} -{{ macros::text_section(content_array=section_content)}} -{% endblock %} diff --git a/crates/defguard_mail/templates/support-data.mjml b/crates/defguard_mail/templates/support-data.mjml new file mode 100644 index 0000000000..d42aa1b34f --- /dev/null +++ b/crates/defguard_mail/templates/support-data.mjml @@ -0,0 +1,9 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + +{{ macros::email_header() }} + +{{ macros::footer_divider() }} + +{% endblock content %} diff --git a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql index bbdd9004a1..9f4c14030b 100644 --- a/migrations/20260323081850_[2.0.0]_more_mjml.up.sql +++ b/migrations/20260323081850_[2.0.0]_more_mjml.up.sql @@ -29,4 +29,6 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('password-reset-done', 'title', 'en_US', 'Password reset'), ('password-reset-done', 'subtitle', 'en_US', 'Your password has been successfully changed.'), ('test', 'title', 'en_US', 'This is test email from Defguard system.'), - ('test', 'subtitle', 'en_US', 'If you received it, your SMTP configuration is correct.'); + ('test', 'subtitle', 'en_US', 'If you received it, your SMTP configuration is correct.'), + ('support-data', 'title', 'en_US', 'Support data'), + ('support-data', 'subtitle', 'en_US', 'Support data can be found in the attachment.'); From 39ecdbfd62531a0e55c2b37df81748867fb63bf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Tue, 24 Mar 2026 11:04:47 +0100 Subject: [PATCH 11/13] enrollment-welcome --- .../defguard_common/src/db/models/settings.rs | 2 +- .../defguard_core/src/db/models/enrollment.rs | 74 ++++-------- crates/defguard_core/src/handlers/support.rs | 4 +- crates/defguard_mail/src/mail.rs | 26 +++-- crates/defguard_mail/src/templates.rs | 110 ++---------------- crates/defguard_mail/src/tests.rs | 22 ++++ .../templates/enrollment-welcome.mjml | 15 +++ .../templates/gateway-reconnected.mjml | 3 +- .../templates/mail_enrollment_welcome.tera | 6 - .../src/servers/enrollment.rs | 105 +---------------- 10 files changed, 94 insertions(+), 273 deletions(-) create mode 100644 crates/defguard_mail/templates/enrollment-welcome.mjml delete mode 100644 crates/defguard_mail/templates/mail_enrollment_welcome.tera diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index c83048f292..8a7175d839 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -867,7 +867,7 @@ Sent by Defguard {{ defguard_version }} Star us on GitHub! https://github.com/defguard/defguard\ "; - pub static WELCOME_EMAIL_SUBJECT: &str = "[defguard] Welcome message after enrollment"; + pub static WELCOME_EMAIL_SUBJECT: &str = "Defguard: Welcome message after enrollment"; } #[cfg(test)] diff --git a/crates/defguard_core/src/db/models/enrollment.rs b/crates/defguard_core/src/db/models/enrollment.rs index ef68cd8243..083f89da62 100644 --- a/crates/defguard_core/src/db/models/enrollment.rs +++ b/crates/defguard_core/src/db/models/enrollment.rs @@ -3,16 +3,13 @@ use defguard_common::{ VERSION, db::{ Id, - models::{Settings, settings::defaults::WELCOME_EMAIL_SUBJECT, user::User}, + models::{Settings, user::User}, }, random::gen_alphanumeric, types::UrlParseError, }; -use defguard_mail::{ - Mail, - templates::{self, TemplateError, safe_tera}, -}; -use sqlx::{PgConnection, PgExecutor, PgPool, Transaction, query, query_as}; +use defguard_mail::templates; +use sqlx::{PgConnection, PgExecutor, PgPool, query, query_as}; use tera::Context; use thiserror::Error; use tonic::{Code, Status}; @@ -49,7 +46,7 @@ pub enum TokenError { #[error(transparent)] TemplateErrorInternal(#[from] tera::Error), #[error(transparent)] - TemplateError(#[from] TemplateError), + TemplateError(#[from] templates::TemplateError), #[error(transparent)] UrlParseError(#[from] UrlParseError), } @@ -322,15 +319,15 @@ impl Token { /// - admin_phone pub(crate) async fn get_welcome_message_context( &self, - transaction: &mut PgConnection, + conn: &mut PgConnection, ) -> Result { debug!( "Preparing welcome message context for enrollment token {}", self.id ); - let user = self.fetch_user(&mut *transaction).await?; - let admin = self.fetch_admin(&mut *transaction).await?; + let user = self.fetch_user(&mut *conn).await?; + let admin = self.fetch_admin(&mut *conn).await?; let url = Settings::url()?; let mut context = Context::new(); context.insert("first_name", &user.first_name); @@ -353,71 +350,40 @@ impl Token { // to be displayed on final enrollment page pub async fn get_welcome_page_content( &self, - transaction: &mut PgConnection, + conn: &mut PgConnection, ) -> Result { let settings = Settings::get_current_settings(); // load configured content as template - let mut tera = safe_tera(); + let mut tera = templates::safe_tera(); tera.add_raw_template("welcome_page", &enrollment_welcome_message(&settings)?)?; - let context = self.get_welcome_message_context(&mut *transaction).await?; + let context = self.get_welcome_message_context(&mut *conn).await?; Ok(tera.render("welcome_page", &context)?) } - // Render welcome email content - pub(crate) async fn get_welcome_email_content( + /// Send configured welcome email to a user after finishing enrollment. + pub async fn send_welcome_email( &self, - transaction: &mut PgConnection, + conn: &mut PgConnection, + user: &User, ip_address: &str, device_info: Option<&str>, - ) -> Result { + ) -> Result<(), TokenError> { + debug!("Sending welcome mail to {}", user.username); let settings = Settings::get_current_settings(); // load configured content as template - let mut tera = safe_tera(); + let mut tera = templates::safe_tera(); tera.add_raw_template("welcome_email", &enrollment_welcome_email(&settings)?)?; - let context = self.get_welcome_message_context(&mut *transaction).await?; + let context = self.get_welcome_message_context(conn).await?; let content = tera.render("welcome_email", &context)?; - Ok(templates::enrollment_welcome_mail( - &content, - Some(ip_address), - device_info, - )?) - } + templates::enrollment_welcome_mail(&user.email, &content, Some(ip_address), device_info)?; - // Send configured welcome email to user after finishing enrollment - pub async fn send_welcome_email( - &self, - transaction: &mut Transaction<'_, sqlx::Postgres>, - user: &User, - settings: &Settings, - ip_address: &str, - device_info: Option<&str>, - ) -> Result<(), TokenError> { - debug!("Sending welcome mail to {}", user.username); - let mail = Mail::new( - &user.email, - settings - .enrollment_welcome_email_subject - .as_deref() - .unwrap_or(WELCOME_EMAIL_SUBJECT), - self.get_welcome_email_content(&mut *transaction, ip_address, device_info) - .await?, - ); - match mail.send().await { - Ok(()) => { - info!("Sent enrollment welcome mail to {}", user.username); - Ok(()) - } - Err(err) => { - error!("Error sending welcome mail: {err}"); - Err(TokenError::NotificationError(err.to_string())) - } - } + Ok(()) } } diff --git a/crates/defguard_core/src/handlers/support.rs b/crates/defguard_core/src/handlers/support.rs index 131967075f..fd906f8254 100644 --- a/crates/defguard_core/src/handlers/support.rs +++ b/crates/defguard_core/src/handlers/support.rs @@ -9,7 +9,7 @@ use crate::{ support::dump_config, }; -pub async fn configuration( +pub(crate) async fn configuration( _admin: AdminRole, State(appstate): State, session: SessionInfo, @@ -32,7 +32,7 @@ pub async fn configuration( }) } -pub async fn logs(_admin: AdminRole, session: SessionInfo) -> Result { +pub(crate) async fn logs(_admin: AdminRole, session: SessionInfo) -> Result { debug!("User {} dumping app logs", session.user.username); if let Some(ref log_file) = server_config().log_file { match tokio::fs::read_to_string(log_file).await { diff --git a/crates/defguard_mail/src/mail.rs b/crates/defguard_mail/src/mail.rs index d096c372a5..5f20e62c4d 100644 --- a/crates/defguard_mail/src/mail.rs +++ b/crates/defguard_mail/src/mail.rs @@ -1,6 +1,9 @@ use std::{str::FromStr, time::Duration}; -use defguard_common::db::models::{Settings, settings::SmtpEncryption}; +use defguard_common::db::models::{ + Settings, + settings::{SmtpEncryption, defaults::WELCOME_EMAIL_SUBJECT}, +}; use lettre::{ AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, message::{Body, Mailbox, MultiPart, SinglePart, header::ContentType}, @@ -89,10 +92,9 @@ pub struct Mail { impl Mail { /// Create new [`Mail`]. #[must_use] - pub fn new(to: T, subject: S, content: String) -> Mail + pub fn new(to: T, subject: String, content: String) -> Mail where T: Into, - S: Into, { // Append images used in all templates. let images = vec![ @@ -104,7 +106,7 @@ impl Mail { Self { to: to.into(), - subject: subject.into(), + subject, content, context: Context::new(), attachments: Vec::new(), @@ -246,7 +248,7 @@ impl Mail { tokio::spawn(self.send()); } - /// Builds mailer object with specified configuration + /// Builds mailer object with specified configuration. fn mailer(settings: SmtpSettings) -> Result, MailError> { type Builder = AsyncSmtpTransport; @@ -301,10 +303,17 @@ pub enum MailMessage { impl MailMessage { /// Email subject. - pub(crate) const fn subject(&self) -> &'static str { + pub(crate) fn subject(&self) -> String { + // Welcome message's subject should be taken from settings. + if let Self::Welcome = self { + let settings = Settings::get_current_settings(); + if let Some(subject) = settings.enrollment_welcome_email_subject { + return subject; + } + } match self { Self::Test => "Defguard: Test message", - Self::Welcome => "Welcome message after enrollment", + Self::Welcome => WELCOME_EMAIL_SUBJECT, Self::SupportData => "Defguard: Support data", Self::DesktopStart => "Defguard: Desktop client configuration", Self::NewAccount => "Defguard: User enrollment", @@ -321,6 +330,7 @@ impl MailMessage { Self::UserImportBlocked => "User import blocked", Self::EnrollmentNotification => "Defguard: User enrollment completed", } + .to_string() } pub(crate) const fn template_name(&self) -> &str { @@ -348,7 +358,7 @@ impl MailMessage { pub(crate) const fn mjml_template(&self) -> &str { match self { Self::Test => include_str!("../templates/test.mjml"), - Self::Welcome => "", + Self::Welcome => include_str!("../templates/enrollment-welcome.mjml"), Self::SupportData => include_str!("../templates/support-data.mjml"), Self::DesktopStart => include_str!("../templates/desktop-start.mjml"), Self::NewAccount => include_str!("../templates/new-account.mjml"), diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index ec6feae427..6d23a8f44b 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -22,10 +22,6 @@ pub static SUPPORT_EMAIL_ADDRESS: &str = "support@defguard.net"; static BASE_MJML: &str = include_str!("../templates/base.mjml"); static MACROS_MJML: &str = include_str!("../templates/macros.mjml"); - -static MAIL_BASE: &str = include_str!("../templates/base.tera"); -static MAIL_MACROS: &str = include_str!("../templates/macros.tera"); -static MAIL_ENROLLMENT_WELCOME: &str = include_str!("../templates/mail_enrollment_welcome.tera"); static MAIL_DATETIME_FORMAT: &str = "%A, %B %d, %Y at %r"; #[derive(Debug, Error)] @@ -77,38 +73,6 @@ impl From for SessionContext { } } -fn get_base_tera( - mut context: Context, - session: Option<&SessionContext>, - ip_address: Option<&str>, - device_info: Option<&str>, -) -> Result<(Tera, Context), TemplateError> { - let mut tera = safe_tera(); - tera.add_raw_template("base", MAIL_BASE)?; - tera.add_raw_template("macros", MAIL_MACROS)?; - // Supply context for the base template. - context.insert("application_version", &VERSION); - let now = Utc::now(); - context.insert("current_year", &now.year().to_string()); - context.insert("date_now", &now.format(MAIL_DATETIME_FORMAT).to_string()); - - if let Some(current_session) = session { - let device_info = ¤t_session.device_info; - context.insert("device_type", &device_info); - context.insert("ip_address", ¤t_session.ip_address); - } - - if let Some(ip) = ip_address { - context.insert("ip_address", ip); - } - - if let Some(device_info) = device_info { - context.insert("device_type", device_info); - } - - Ok((tera, context)) -} - fn get_base_tera_mjml( mut context: Context, session: Option<&SessionContext>, @@ -227,25 +191,29 @@ pub async fn desktop_start_mail( Ok(()) } -// Welcome message sent when activating an account through enrollment -// content is stored in markdown, so it's parsed into HTML. +/// Welcome message sent when activating an account through enrollment. +/// Its content is stored in markdown, so it's parsed into HTML and plain text. pub fn enrollment_welcome_mail( + to: &str, content: &str, ip_address: Option<&str>, device_info: Option<&str>, -) -> Result { - debug!("Render a welcome mail template for user enrollment."); - let (mut tera, mut context) = get_base_tera(Context::new(), None, ip_address, device_info)?; - tera.add_raw_template("mail_enrollment_welcome", MAIL_ENROLLMENT_WELCOME)?; +) -> Result<(), TemplateError> { + let (mut tera, mut context) = + get_base_tera_mjml(Context::new(), None, ip_address, device_info)?; - // convert content to HTML + debug!("Render welcome mail template for user enrollment"); + // Convert content to HTML. let parser = pulldown_cmark::Parser::new(content); let mut html_output = String::new(); pulldown_cmark::html::push_html(&mut html_output, parser); context.insert("welcome_message_content", &html_output); - Ok(tera.render("mail_enrollment_welcome", &context)?) + let message = MailMessage::Welcome; + message.mail(&mut tera, &context, to)?.send_and_forget(); + + Ok(()) } /// Notification for admin after user completes an enrollment. @@ -515,57 +483,3 @@ pub async fn password_reset_success_mail( Ok(()) } - -#[cfg(test)] -mod test { - use claims::assert_ok; - use defguard_common::{ - config::{DefGuardConfig, SERVER_CONFIG}, - db::{models::settings::initialize_current_settings, setup_pool}, - }; - use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - - use super::*; - - // fn get_welcome_context() -> Context { - // let mut context = Context::new(); - // context.insert("first_name", "test_first"); - // context.insert("last_name", "test_last"); - // context.insert("username", "username"); - // context.insert("defguard_url", "test_url"); - // context.insert("defguard_version", &VERSION); - // context.insert("admin_first_name", "test_first_name"); - // context.insert("admin_last_name", "test_last_name"); - // context.insert("admin_email", "test_email"); - // context.insert("admin_phone", "test_phone"); - // context - // } - - async fn init_config(pool: &sqlx::PgPool) { - let mut config = DefGuardConfig::new_test_config(); - initialize_current_settings(pool) - .await - .expect("Could not initialize current settings in the database"); - config.initialize_post_settings(); - let _ = SERVER_CONFIG.set(config.clone()); - } - - #[sqlx::test] - async fn test_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - init_config(&pool).await; - assert_ok!(enrollment_welcome_mail( - "Hi there! Welcome to Defguard.", - None, - None - )); - } - - #[test] - fn dg25_8_server_side_template_injection() { - let mut tera = safe_tera(); - tera.add_raw_template("text", "PATH={{ get_env(name=\"PATH\") }}") - .unwrap(); - assert!(tera.render("text", &Context::new()).is_err()); - } -} diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index e54cbe68b2..758e1d7a40 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -21,6 +21,14 @@ use tera::Context; use super::{Attachment, templates}; +#[test] +fn dg25_8_server_side_template_injection() { + let mut tera = templates::safe_tera(); + tera.add_raw_template("text", "PATH={{ get_env(name=\"PATH\") }}") + .unwrap(); + assert!(tera.render("text", &Context::new()).is_err()); +} + /// Set SMTP settings from environment variables. async fn set_smtp_settings(pool: &PgPool) { let config = DefGuardConfig::new_test_config(); @@ -369,3 +377,17 @@ fn send_support_data_mail(_: PgPoolOptions, options: PgConnectOptions) { // Delay, so send_and_forget() can process the message. tokio::time::sleep(Duration::from_secs(2)).await; } + +#[ignore = "requires SMTP server"] +#[sqlx::test] +fn send_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + set_smtp_settings(&pool).await; + + let markdown = "Paragraph **bold** _italic_."; + templates::enrollment_welcome_mail(&env::var("SMTP_TO").unwrap(), markdown, None, None) + .unwrap(); + + // Delay, so send_and_forget() can process the message. + tokio::time::sleep(Duration::from_secs(2)).await; +} diff --git a/crates/defguard_mail/templates/enrollment-welcome.mjml b/crates/defguard_mail/templates/enrollment-welcome.mjml new file mode 100644 index 0000000000..1a79df75f4 --- /dev/null +++ b/crates/defguard_mail/templates/enrollment-welcome.mjml @@ -0,0 +1,15 @@ +{% import "macros.mjml" as macros %} +{% extends "base.mjml" %} +{% block content %} + + + + +

+ {{ welcome_message_content }} +

+
+
+
+ +{% endblock content %} diff --git a/crates/defguard_mail/templates/gateway-reconnected.mjml b/crates/defguard_mail/templates/gateway-reconnected.mjml index 450595d287..e9755328ff 100644 --- a/crates/defguard_mail/templates/gateway-reconnected.mjml +++ b/crates/defguard_mail/templates/gateway-reconnected.mjml @@ -4,7 +4,8 @@ {{ macros::email_header() }} - + +

{{ gateway_label }} {{ gateway_name }} diff --git a/crates/defguard_mail/templates/mail_enrollment_welcome.tera b/crates/defguard_mail/templates/mail_enrollment_welcome.tera deleted file mode 100644 index 93298d0cef..0000000000 --- a/crates/defguard_mail/templates/mail_enrollment_welcome.tera +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "base" %} -{% import "macros" as macros %} -{% block mail_content %} -{% set section_content = [macros::paragraph(content=welcome_message_content)] %} -{{ macros::text_section(content_array=section_content)}} -{% endblock %} diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index b086f21ed2..3725e2d87b 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -417,22 +417,12 @@ impl EnrollmentServer { Status::internal("unexpected error") })?; debug!("Updating user details ended with success."); - let _ = update_counts(&self.pool).await; - - debug!("Retriving settings to send welcome email..."); - let settings = Settings::get_current_settings(); - debug!("Settings successfully retrieved."); + let _ = update_counts(&mut *transaction).await; // send welcome email debug!("Try to send welcome email..."); enrollment - .send_welcome_email( - &mut transaction, - &user, - &settings, - &ip_address, - device_info.as_deref(), - ) + .send_welcome_email(&mut transaction, &user, &ip_address, device_info.as_deref()) .await?; // send success notification to admin @@ -1117,94 +1107,3 @@ pub async fn new_polling_token(pool: &PgPool, device: &Device) -> Result Date: Tue, 24 Mar 2026 11:23:18 +0100 Subject: [PATCH 12/13] Fix desktop-start --- crates/defguard_mail/src/tests.rs | 2 +- crates/defguard_mail/templates/base.tera | 554 ------------------ .../templates/desktop-start.mjml | 2 +- .../templates/desktop-start.text | 4 +- crates/defguard_mail/templates/macros.tera | 272 --------- 5 files changed, 5 insertions(+), 829 deletions(-) delete mode 100644 crates/defguard_mail/templates/base.tera delete mode 100644 crates/defguard_mail/templates/macros.tera diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index 758e1d7a40..420ca01509 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -54,7 +54,7 @@ fn send_desktop_start(_: PgPoolOptions, options: PgConnectOptions) { let mut conn = pool.begin().await.unwrap(); let context = Context::new(); - let url = Url::parse("http://localhost:8000").unwrap(); + let url = Url::parse("http://localhost:8001").unwrap(); let token = "zXc6N1ndXpWFeyBuogiFp1bD1UomAbZc"; templates::desktop_start_mail( &env::var("SMTP_TO").unwrap(), diff --git a/crates/defguard_mail/templates/base.tera b/crates/defguard_mail/templates/base.tera deleted file mode 100644 index 10b0f07e41..0000000000 --- a/crates/defguard_mail/templates/base.tera +++ /dev/null @@ -1,554 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - -

- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
-
-
- -
-
- -
- - - - - - -
- -
- -
- - - - - - -
- - - - - - -
- - - - - - -
- Defguard logo -
-
-
-
- -
- -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
-
-
- -
-
- - {% block mail_content %} - {% endblock %} - - -
- - - - - - -
-
- - - - - - -
- - - - - - -
-
- {% if date_now %} -

- Date: {{ date_now | safe }} -

- {% endif %} - {% if ip_address %} -

- IP Address: {{ ip_address | safe }} -

- {% endif %} - {% if device_type %} -

- Device type: {{ device_type }} -

- {% endif %} -
-
-
-
-
-
- - - -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
- - - - - - - -
- - - - - - -
- - Github - -
-
- - - - - - - -
- - - - - - -
- - Matrix - -
-
- - - - - - - -
- - - - - - -
- - Mastodon - -
-
- - - - - - - -
- - - - - - -
- - Twitter - -
-
- -
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
Copyright © {{ current_year }} teonite
-
Sent by Defguard v.{{ application_version }}
-
-
-
-
- -
-
- -
- - - - - - -
- -
- - - - - - -
- - - - - - -
-
-
-
-
- -
-
- -
- - - diff --git a/crates/defguard_mail/templates/desktop-start.mjml b/crates/defguard_mail/templates/desktop-start.mjml index 484e3b5c3b..683afa990f 100644 --- a/crates/defguard_mail/templates/desktop-start.mjml +++ b/crates/defguard_mail/templates/desktop-start.mjml @@ -28,7 +28,7 @@
- + {{ configure }} diff --git a/crates/defguard_mail/templates/desktop-start.text b/crates/defguard_mail/templates/desktop-start.text index 6418a22fda..06915b2bce 100644 --- a/crates/defguard_mail/templates/desktop-start.text +++ b/crates/defguard_mail/templates/desktop-start.text @@ -1,5 +1,7 @@ -{{ header }} +{{ title }} {{ subtitle }} {{ label_url }}: {{ url }} {{ label_token }}: {{ token }} + +{{ configure }}: {{ url }}/open-desktop?token={{ token }} diff --git a/crates/defguard_mail/templates/macros.tera b/crates/defguard_mail/templates/macros.tera deleted file mode 100644 index 1d82a9a0e8..0000000000 --- a/crates/defguard_mail/templates/macros.tera +++ /dev/null @@ -1,272 +0,0 @@ -{% macro text_section(content_array) %} -
- - - - - - -
-
- - - - - - -
- - - - - - -
-
- {% for content in content_array %} - {{ content | safe }} - {% endfor %} -
-
-
-
-
-
-{% endmacro text_section %} - -{% macro inline_image(src, height="100px", width="100px", alt="") %} -
- - - - - - -
-
- - - - - - -
- - - - - - -
-
- {{ alt }} -
-
-
-
-
-
-{% endmacro inline_image %} - -{% macro paragraph(content="", color="#222", font_size="12px", align="left", line_height="120%", font_weight="400") %} -

- {{ content | safe }} -

-{% endmacro paragraph %} - -{% macro paragraph_with_title(title, content="", color="#222", font_size="12px", align="left", line_height="120%", margin="auto") %} -

- {{ title | safe }} {{ content | safe }} -

-{% endmacro paragraph_with_title %} - -{% macro spacer(height="20px") %} -
- - - - - - -
-
- - - - - - -
- - - - - - -
-
-   -
-
-
-
-
-
-{% endmacro spacer %} - -{% macro link(content="", href="", color="#222", decoration="underline", decoration_color="#222", family="Roboto", -size="12px", line_height="120%", weight="400") %} - - {{ content | safe }} - -{% endmacro link %} - -{% macro title(content="", font_size="28px") %} -
- - - - - - -
-
- - - - - - -
- - - - - - -
-
- {{ content | safe }} -
-
-
-
-
-
-{% endmacro title %} - -{% macro button_link(href="", text="") %} -

- {{ text }} - -

-{% endmacro button_link %} From 9c05a9ad40c77b7c82fc40fbabeb1d2b77111b52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Ciarcin=CC=81ski?= Date: Tue, 24 Mar 2026 11:56:20 +0100 Subject: [PATCH 13/13] Final touch --- crates/defguard_core/src/handlers/auth.rs | 3 +++ migrations/20260209083940_[2.0.0]_mjml.up.sql | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index 5da2614455..0e2a774c45 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -494,6 +494,7 @@ pub async fn webauthn_finish( ) .await?; user.set_mfa_method(&mut *conn, MFAMethod::Webauthn).await?; + conn.commit().await?; } info!("Finished Webauthn registration for user {}", user.username); @@ -656,6 +657,7 @@ pub async fn totp_enable( .await?; user.set_mfa_method(&mut *conn, MFAMethod::OneTimePassword) .await?; + conn.commit().await?; } info!("Enabled TOTP for user {}", user.username); @@ -834,6 +836,7 @@ pub async fn email_mfa_enable( ) .await?; user.set_mfa_method(&mut *conn, MFAMethod::Email).await?; + conn.commit().await?; } info!("Enabled email MFA for user {}", user.username); diff --git a/migrations/20260209083940_[2.0.0]_mjml.up.sql b/migrations/20260209083940_[2.0.0]_mjml.up.sql index f2d1a94866..1895a451f5 100644 --- a/migrations/20260209083940_[2.0.0]_mjml.up.sql +++ b/migrations/20260209083940_[2.0.0]_mjml.up.sql @@ -30,4 +30,4 @@ INSERT INTO mail_context (template, section, language_tag, text) VALUES ('new-device', 'label_pubkey', 'en_US', 'Public key'), ('mfa-code', 'title', 'en_US', 'Hello,'), ('mfa-code', 'subtitle', 'en_US', 'It seems like you are trying to login to Defguard. Here is the code you need to access your account.'), - ('mfa-code', 'code_is_valid', 'en_US', 'The code is valid for 1 minute'); + ('mfa-code', 'code_is_valid', 'en_US', 'The code is valid for');