diff --git a/Cargo.lock b/Cargo.lock index 16a2bcd94..eb6a7a1e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -92,10 +92,10 @@ version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" dependencies = [ - "cssparser", - "html5ever", + "cssparser 0.35.0", + "html5ever 0.35.0", "maplit", - "tendril", + "tendril 0.4.3", "url", ] @@ -801,7 +801,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -811,8 +811,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", - "phf", - "phf_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", ] [[package]] @@ -1116,6 +1116,25 @@ dependencies = [ "typenum", ] +[[package]] +name = "css-inline" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94c78ae84c4ea75d3734db18f768c7ce7ef5615fbd13987245db26c4a121771d" +dependencies = [ + "cssparser 0.36.0", + "html5ever 0.39.0", + "lru", + "memchr", + "precomputed-hash", + "rayon", + "reqwest", + "rustc-hash", + "selectors", + "smallvec", + "url", +] + [[package]] name = "cssparser" version = "0.35.0" @@ -1125,7 +1144,20 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", + "smallvec", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", "smallvec", ] @@ -1528,6 +1560,7 @@ version = "0.0.0" dependencies = [ "chrono", "claims", + "css-inline", "defguard_common", "humantime", "image", @@ -2239,6 +2272,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2582,7 +2621,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -2670,10 +2720,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "markup5ever", + "markup5ever 0.35.0", "match_token", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever 0.39.0", +] + [[package]] name = "htmlparser" version = "0.2.1" @@ -3417,6 +3477,15 @@ dependencies = [ "imgref", ] +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3442,8 +3511,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", - "tendril", - "web_atoms", + "tendril 0.4.3", + "web_atoms 0.1.3", +] + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms 0.2.3", ] [[package]] @@ -4397,8 +4477,19 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -4407,8 +4498,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -4417,18 +4518,41 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", "syn", @@ -4443,6 +4567,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.11" @@ -5055,6 +5188,7 @@ dependencies = [ "cookie", "cookie_store", "encoding_rs", + "futures-channel", "futures-core", "futures-util", "h2", @@ -5394,6 +5528,25 @@ dependencies = [ "libc", ] +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + [[package]] name = "semver" version = "1.0.28" @@ -5598,6 +5751,15 @@ dependencies = [ "serde", ] +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "sha-1" version = "0.10.1" @@ -6062,19 +6224,43 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + [[package]] name = "string_cache_codegen" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", "proc-macro2", "quote", ] @@ -6225,6 +6411,16 @@ dependencies = [ "utf-8", ] +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + [[package]] name = "tera" version = "1.20.1" @@ -7159,10 +7355,22 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", ] [[package]] diff --git a/crates/defguard_mail/Cargo.toml b/crates/defguard_mail/Cargo.toml index 9ada5ddf2..c2219ac69 100644 --- a/crates/defguard_mail/Cargo.toml +++ b/crates/defguard_mail/Cargo.toml @@ -26,6 +26,7 @@ humantime.workspace = true image = "0.25" # match with qrforge mrml = "5.1" qrforge = {version = "0.1", default-features = false, features = ["image"]} +css-inline = "0.20.2" [dev-dependencies] claims.workspace = true diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index a9887497b..d5b5b0d55 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -6,6 +6,7 @@ use defguard_common::{ db::models::{Session, Settings, user::MFAMethod}, types::UrlParseError, }; +use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd, html}; use reqwest::Url; use serde::Serialize; use serde_json::Value; @@ -191,6 +192,110 @@ pub async fn desktop_start_mail( Ok(()) } +/// Iterator that enforces the supported subset of CommonMark +/// so that only elements with a corresponding rule in `MARKDOWN_EMAIL_STYLES` +/// are emitted to the HTML renderer. +struct EmailEventFilter<'a, I: Iterator>> { + iter: I, + skip_depth: usize, +} + +impl<'a, I: Iterator>> EmailEventFilter<'a, I> { + fn new(iter: I) -> Self { + Self { + iter, + skip_depth: 0, + } + } +} + +impl<'a, I: Iterator>> Iterator for EmailEventFilter<'a, I> { + type Item = Event<'a>; + + fn next(&mut self) -> Option { + loop { + let event = self.iter.next()?; + + // Inside a skipped block: track nesting depth and discard. + if self.skip_depth > 0 { + match &event { + Event::Start(_) => self.skip_depth += 1, + Event::End(_) => self.skip_depth -= 1, + _ => {} + } + continue; + } + + return Some(match event { + // block elements without styles: skip entirely + Event::Start(Tag::BlockQuote(_) | Tag::List(Some(_)) | Tag::CodeBlock(_)) => { + self.skip_depth = 1; + continue; + } + + // inline elements without styles: drop the tag, keep text + Event::Start(Tag::Emphasis | Tag::Strikethrough) + | Event::End(TagEnd::Emphasis | TagEnd::Strikethrough) => continue, + + // inline code: render as plain text + Event::Code(text) => Event::Text(text), + + // headings: degrade h3-h6 to h2 + Event::Start(Tag::Heading { + level, + id, + classes, + attrs, + }) if !matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => { + Event::Start(Tag::Heading { + level: HeadingLevel::H2, + id, + classes, + attrs, + }) + } + Event::End(TagEnd::Heading(level)) + if !matches!(level, HeadingLevel::H1 | HeadingLevel::H2) => + { + Event::End(TagEnd::Heading(HeadingLevel::H2)) + } + + // raw HTML and horizontal rules: strip + Event::Html(_) | Event::InlineHtml(_) | Event::Rule => continue, + + other => other, + }); + } + } +} + +static MARKDOWN_EMAIL_STYLES: &str = r#" +h1 { font-size: 24px; font-weight: 600; color: #141517; line-height: 32px; font-family: Geist, Arial, sans-serif; margin: 0 0 8px 0; } +h2 { font-size: 16px; font-weight: 400; color: #4A5059; line-height: 24px; font-family: Geist, Arial, sans-serif; margin: 0 0 8px 0; } +p { font-size: 14px; font-weight: 400; color: #4A5059; line-height: 20px; font-family: Geist, Arial, sans-serif; margin: 0 0 12px 0; } +a { color: #3961DB; text-decoration: underline; font-size: 14px; line-height: 20px; } +ul { list-style: disc; margin: 0 0 12px 0; padding: 0; } +li { font-size: 14px; font-weight: 400; color: #4A5059; line-height: 20px; font-family: Geist, Arial, sans-serif; margin-left: 21px; } +strong, b { font-weight: 600; } +"#; + +/// Renders a markdown string to an inline-styled HTML fragment. +/// Only elements with a corresponding rule in `MARKDOWN_EMAIL_STYLES` are +/// rendered; everything else is stripped or degraded (see `EmailEventFilter`). +pub fn markdown_to_html(content: &str) -> String { + let parser = EmailEventFilter::new(Parser::new(content)); + let mut raw_html = String::new(); + html::push_html(&mut raw_html, parser); + + match css_inline::inline_fragment(&raw_html, MARKDOWN_EMAIL_STYLES) { + Ok(styled) => styled, + Err(err) => { + warn!("Failed to apply inline styles to markdown HTML: {err}"); + raw_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( @@ -203,10 +308,7 @@ pub fn enrollment_welcome_mail( get_base_tera_mjml(Context::new(), None, ip_address, device_info)?; 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); + let html_output = markdown_to_html(content); context.insert("welcome_message_content", &html_output); diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index b857e395e..24044c6f6 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -394,6 +394,260 @@ fn send_enrollment_welcome_mail(_: PgPoolOptions, options: PgConnectOptions) { tokio::time::sleep(Duration::from_secs(2)).await; } +mod markdown_to_html { + use crate::templates::markdown_to_html; + + fn has_tag(html: &str, tag: &str) -> bool { + html.contains(&format!("<{tag}")) + } + + #[test] + fn h1_has_inline_style() { + let html = markdown_to_html("# Title"); + assert!(has_tag(&html, "h1"), "h1 tag missing: {html}"); + assert!(html.contains("font-size: 24px"), "h1 font-size: {html}"); + assert!(html.contains("#141517"), "h1 color: {html}"); + assert!(html.contains("font-weight: 600"), "h1 font-weight: {html}"); + assert!(html.contains("Title"), "h1 text missing: {html}"); + } + + #[test] + fn h2_has_inline_style() { + let html = markdown_to_html("## Subtitle"); + assert!(has_tag(&html, "h2"), "h2 tag missing: {html}"); + assert!(html.contains("font-size: 16px"), "h2 font-size: {html}"); + assert!(html.contains("#4A5059"), "h2 color: {html}"); + assert!(html.contains("Subtitle"), "h2 text missing: {html}"); + } + + #[test] + fn paragraph_has_inline_style() { + let html = markdown_to_html("Hello world"); + assert!(has_tag(&html, "p"), "p tag missing: {html}"); + assert!(html.contains("font-size: 14px"), "p font-size: {html}"); + assert!(html.contains("#4A5059"), "p color: {html}"); + assert!(html.contains("Hello world"), "p text missing: {html}"); + } + + #[test] + fn bold_has_inline_style() { + let html = markdown_to_html("**important**"); + assert!(has_tag(&html, "strong"), "strong tag missing: {html}"); + assert!( + html.contains("font-weight: 600"), + "strong font-weight: {html}" + ); + assert!(html.contains("important"), "bold text missing: {html}"); + } + + #[test] + fn link_has_inline_style_and_href() { + let html = markdown_to_html("[click here](https://example.com)"); + assert!(has_tag(&html, "a"), "a tag missing: {html}"); + assert!(html.contains("https://example.com"), "href missing: {html}"); + assert!(html.contains("#3961DB"), "a color: {html}"); + assert!(html.contains("click here"), "link text missing: {html}"); + } + + #[test] + fn unordered_list_has_inline_styles() { + let html = markdown_to_html("- Alpha\n- Beta"); + assert!(has_tag(&html, "ul"), "ul tag missing: {html}"); + assert!(has_tag(&html, "li"), "li tag missing: {html}"); + assert!(html.contains("Alpha"), "first item text missing: {html}"); + assert!(html.contains("Beta"), "second item text missing: {html}"); + assert!(html.contains("font-size: 14px"), "li font-size: {html}"); + } + + #[test] + fn h3_through_h6_are_demoted_to_h2() { + for (level, marker) in [(3, "###"), (4, "####"), (5, "#####"), (6, "######")] { + let html = markdown_to_html(&format!("{marker} Heading {level}")); + assert!( + has_tag(&html, "h2"), + "h{level} must be demoted to h2: {html}" + ); + assert!( + !has_tag(&html, &format!("h{level}")), + "h{level} must not appear: {html}" + ); + assert!( + html.contains(&format!("Heading {level}")), + "h{level} text must be preserved: {html}" + ); + } + } + + #[test] + fn horizontal_rule_is_stripped() { + let html = markdown_to_html("---"); + assert!(!has_tag(&html, "hr"), "hr must be stripped: {html}"); + } + + #[test] + fn blockquote_is_stripped() { + let html = markdown_to_html("> confidential"); + assert!( + !has_tag(&html, "blockquote"), + "blockquote tag must be stripped: {html}" + ); + assert!( + !html.contains("confidential"), + "blockquote content must not appear: {html}" + ); + } + + #[test] + fn ordered_list_is_stripped() { + let html = markdown_to_html("1. First\n2. Second"); + assert!(!has_tag(&html, "ol"), "ol tag must be stripped: {html}"); + assert!( + !html.contains("First"), + "ordered list content must not appear: {html}" + ); + } + + #[test] + fn fenced_code_block_is_stripped() { + let html = markdown_to_html("```rust\nlet x = 1;\n```"); + assert!(!has_tag(&html, "pre"), "pre tag must be stripped: {html}"); + assert!(!has_tag(&html, "code"), "code tag must be stripped: {html}"); + assert!( + !html.contains("let x"), + "code block content must not appear: {html}" + ); + } + + #[test] + fn indented_code_block_is_stripped() { + let html = markdown_to_html(" indented block"); + assert!(!has_tag(&html, "pre"), "pre tag must be stripped: {html}"); + assert!( + !html.contains("indented block"), + "indented code block content must not appear: {html}" + ); + } + + #[test] + fn inline_code_rendered_as_plain_text() { + let html = markdown_to_html("`fn main()`"); + assert!(!has_tag(&html, "code"), "code tag must be stripped: {html}"); + assert!( + html.contains("fn main()"), + "inline code text must be preserved: {html}" + ); + } + + #[test] + fn raw_block_html_is_stripped() { + let html = markdown_to_html("
content
"); + assert!( + !html.contains(" opens an HTML block consuming its whole line, so "Safe paragraph" + // must be in a separate paragraph to survive stripping. + let html = markdown_to_html("\n\nSafe paragraph."); + assert!( + !html.contains("inline after"); + assert!( + !html.contains(""), + "inline HTML b tag must be stripped: {html}" + ); + } + + #[test] + fn nested_unsupported_blocks_are_fully_stripped() { + let html = markdown_to_html("> 1. nested item"); + assert!( + !has_tag(&html, "blockquote"), + "blockquote must be stripped: {html}" + ); + assert!(!has_tag(&html, "ol"), "ol must be stripped: {html}"); + assert!( + !html.contains("nested item"), + "nested content must not appear: {html}" + ); + } + + #[test] + fn bold_and_italic_in_same_paragraph() { + let html = markdown_to_html("**bold** and *italic* together"); + assert!(has_tag(&html, "strong"), "strong must be present: {html}"); + assert!(!has_tag(&html, "em"), "em must be stripped: {html}"); + assert!(html.contains("bold"), "bold text must survive: {html}"); + assert!(html.contains("italic"), "italic text must survive: {html}"); + } + + #[test] + fn bold_inside_link() { + let html = markdown_to_html("[**bold link**](https://example.com)"); + assert!(has_tag(&html, "a"), "a tag missing: {html}"); + assert!(has_tag(&html, "strong"), "strong inside a missing: {html}"); + assert!(html.contains("https://example.com"), "href missing: {html}"); + assert!(html.contains("bold link"), "link text missing: {html}"); + } + + #[test] + fn multiple_paragraphs_are_each_styled() { + let html = markdown_to_html("First paragraph.\n\nSecond paragraph."); + assert!( + html.matches("= 2, + "expected at least two p tags: {html}" + ); + assert!(html.contains("First paragraph"), "first p text: {html}"); + assert!(html.contains("Second paragraph"), "second p text: {html}"); + } + + #[test] + fn hr_surrounded_by_paragraphs_is_stripped() { + let html = markdown_to_html("Before.\n\n---\n\nAfter."); + assert!(!has_tag(&html, "hr"), "hr must be stripped: {html}"); + assert!( + html.contains("Before"), + "text before hr must survive: {html}" + ); + assert!(html.contains("After"), "text after hr must survive: {html}"); + } + + #[test] + fn empty_input_produces_no_tags() { + let html = markdown_to_html(""); + assert!( + html.trim().is_empty(), + "empty input must produce no output: {html}" + ); + } + + #[test] + fn plain_text_is_wrapped_in_paragraph() { + let html = markdown_to_html("Just some text."); + assert!( + has_tag(&html, "p"), + "plain text must be wrapped in p: {html}" + ); + assert!( + html.contains("Just some text"), + "text must be preserved: {html}" + ); + } +} + #[test] fn test_mfa_configured_subject_totp() { // TOTP diff --git a/crates/defguard_mail/templates/enrollment-welcome.mjml b/crates/defguard_mail/templates/enrollment-welcome.mjml index 0339017b1..984a9d3e3 100644 --- a/crates/defguard_mail/templates/enrollment-welcome.mjml +++ b/crates/defguard_mail/templates/enrollment-welcome.mjml @@ -5,9 +5,7 @@ -

- {{ welcome_message_content }} -

+ {{ welcome_message_content }}
diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index d950e0dc9..ccf461536 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -369,10 +369,16 @@ impl EnrollmentServer { } debug!("Try to send welcome email..."); - enrollment + match enrollment .send_welcome_email(conn, user, ip_address, device_info) - .await?; - info!("Welcome email sent to {} at {}", user.username, user.email); + .await + { + Ok(()) => info!("Welcome email sent to {} at {}", user.username, user.email), + Err(err) => warn!( + "Failed to send enrollment welcome email to {} at {}: {err}.", + user.username, user.email + ), + } Ok(()) } diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs index 726f87b36..51192362a 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/polling.rs @@ -1,9 +1,8 @@ -use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; - use defguard_proto::{ client_types::InstanceInfoRequest, proxy::{CoreRequest, core_request, core_response}, }; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; use super::support::{ assert_error_response, clear_test_license, complete_proxy_handshake, create_device_for_user, diff --git a/web/messages/en/settings.json b/web/messages/en/settings.json index 9e41e895f..8066c6d7c 100644 --- a/web/messages/en/settings.json +++ b/web/messages/en/settings.json @@ -121,18 +121,11 @@ "settings_enrollment_template_help_admin_phone": "phone number of the administrator who initiated the enrollment process", "settings_enrollment_template_help_admin_email": "email of the administrator who initiated the enrollment process", "settings_enrollment_template_help_defguard_url": "internal Defguard URL (your Defguard instance address)", + "settings_enrollment_template_help_defguard_version": "Defguard version", "settings_enrollment_template_help_markdown_headings": "Create headings.", - "settings_enrollment_template_help_markdown_italic": "Italic text.", "settings_enrollment_template_help_markdown_bold": "Bold text.", - "settings_enrollment_template_help_markdown_bold_italic": "Bold and italic.", - "settings_enrollment_template_help_markdown_blockquote": "Blockquote.", - "settings_enrollment_template_help_markdown_lists": "Lists (unordered or ordered).", - "settings_enrollment_template_help_markdown_inline_code": "Inline code.", - "settings_enrollment_template_help_markdown_code_block": "Code block.", - "settings_enrollment_template_help_markdown_horizontal_line": "Horizontal line.", + "settings_enrollment_template_help_markdown_lists": "Unordered list.", "settings_enrollment_template_help_markdown_link": "Link.", - "settings_enrollment_template_help_markdown_tables": "Create tables.", - "settings_enrollment_template_help_markdown_escape": "Escape special characters.", "settings_enrollment_section_duration_title": "Enrollment session duration", "settings_enrollment_section_duration_description": "Configure the expiration time of the unique token sent to a newly added user. This token is used to activate the account, and in this section administrators can control how long the token remains valid.", "settings_enrollment_label_session_expires_in": "Enrollment session expires in", diff --git a/web/src/pages/EnrollmentPage/tabs/MessageTemplatesTab.tsx b/web/src/pages/EnrollmentPage/tabs/MessageTemplatesTab.tsx index 67ec820ea..638f7712a 100644 --- a/web/src/pages/EnrollmentPage/tabs/MessageTemplatesTab.tsx +++ b/web/src/pages/EnrollmentPage/tabs/MessageTemplatesTab.tsx @@ -30,21 +30,14 @@ const messageTemplatesHelpVariables = [ ['{{ admin_phone }}', m.settings_enrollment_template_help_admin_phone()], ['{{ admin_email }}', m.settings_enrollment_template_help_admin_email()], ['{{ defguard_url }}', m.settings_enrollment_template_help_defguard_url()], + ['{{ defguard_version }}', m.settings_enrollment_template_help_defguard_version()], ] as const; const messageTemplatesHelpMarkdown = [ - ['#, ##, ###', m.settings_enrollment_template_help_markdown_headings(), 'medium'], - ['*text*', m.settings_enrollment_template_help_markdown_italic()], + ['#, ##', m.settings_enrollment_template_help_markdown_headings(), 'medium'], ['**text**', m.settings_enrollment_template_help_markdown_bold()], - ['***text***', m.settings_enrollment_template_help_markdown_bold_italic()], - ['> text', m.settings_enrollment_template_help_markdown_blockquote()], - ['- item or 1. item', m.settings_enrollment_template_help_markdown_lists()], - ['`code`', m.settings_enrollment_template_help_markdown_inline_code()], - ['```code```', m.settings_enrollment_template_help_markdown_code_block()], - ['***', m.settings_enrollment_template_help_markdown_horizontal_line()], + ['- item', m.settings_enrollment_template_help_markdown_lists()], ['[text](url)', m.settings_enrollment_template_help_markdown_link()], - ['| and ---', m.settings_enrollment_template_help_markdown_tables()], - ['\\', m.settings_enrollment_template_help_markdown_escape()], ] as const; const messageTemplatesFormSchema = z.object({