From 63daa2860468c5ba0300a075446011620eadeca1 Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 20 Apr 2026 14:19:10 +0200 Subject: [PATCH 1/9] makrdown email rendering --- Cargo.lock | 258 ++++++++++++++++-- crates/defguard_mail/Cargo.toml | 1 + crates/defguard_mail/src/templates.rs | 34 ++- .../templates/enrollment-welcome.mjml | 4 +- .../src/servers/enrollment.rs | 12 +- .../tests/proxy_manager/handler/polling.rs | 3 +- 6 files changed, 275 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 16a2bcd946..eb6a7a1e89 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 9ada5ddf2e..c2219ac69f 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 a9887497be..6f65cda3db 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, Parser, html}; use reqwest::Url; use serde::Serialize; use serde_json::Value; @@ -191,6 +192,34 @@ pub async fn desktop_start_mail( Ok(()) } +static MARKDOWN_EMAIL_STYLES: &str = r#" +h1 { font-size: 24px; font-weight: 600; color: #141517; line-height: 32px; font-family: Geist, Arial, sans-serif; } +h2 { font-size: 16px; font-weight: 400; color: #4A5059; line-height: 24px; font-family: Geist, Arial, sans-serif; } +p { font-size: 16px; font-weight: 400; color: #4A5059; line-height: 24px; font-family: Geist, Arial, sans-serif; } +a { color: #3961DB; text-decoration: underline; } +ul { list-style: disc; padding-left: 21px; } +li { font-size: 14px; font-weight: 400; color: #4A5059; line-height: 20px; font-family: Geist, Arial, sans-serif; } +strong, b { font-weight: 600; } +hr { border-top: 1px solid #DFE3E9; } +"#; + +/// Renders a markdown string to an inline-styled HTML fragment. +/// Raw HTML blocks andainline HTML are stripped. +pub fn markdown_to_html(content: &str) -> String { + let parser = Parser::new(content) + .filter(|event| !matches!(event, Event::Html(_) | Event::InlineHtml(_))); + 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 +232,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/templates/enrollment-welcome.mjml b/crates/defguard_mail/templates/enrollment-welcome.mjml index 0339017b17..984a9d3e37 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 d950e0dc91..ccf4615364 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 726f87b360..51192362a1 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, From 342d562bf50a340917eada9db064acc44763ef4e Mon Sep 17 00:00:00 2001 From: Aleksander <170264518+t-aleksander@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:09:18 +0200 Subject: [PATCH 2/9] strip unsupported --- crates/defguard_mail/src/templates.rs | 85 ++++++++- crates/defguard_mail/src/tests.rs | 172 ++++++++++++++++++ web/messages/en/settings.json | 9 +- .../tabs/MessageTemplatesTab.tsx | 9 +- 4 files changed, 255 insertions(+), 20 deletions(-) diff --git a/crates/defguard_mail/src/templates.rs b/crates/defguard_mail/src/templates.rs index 6f65cda3db..0790c46d31 100644 --- a/crates/defguard_mail/src/templates.rs +++ b/crates/defguard_mail/src/templates.rs @@ -6,7 +6,7 @@ use defguard_common::{ db::models::{Session, Settings, user::MFAMethod}, types::UrlParseError, }; -use pulldown_cmark::{Event, Parser, html}; +use pulldown_cmark::{Event, HeadingLevel, Parser, Tag, TagEnd, html}; use reqwest::Url; use serde::Serialize; use serde_json::Value; @@ -192,6 +192,83 @@ 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: strip + Event::Html(_) | Event::InlineHtml(_) => 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; } h2 { font-size: 16px; font-weight: 400; color: #4A5059; line-height: 24px; font-family: Geist, Arial, sans-serif; } @@ -204,10 +281,10 @@ hr { border-top: 1px solid #DFE3E9; } "#; /// Renders a markdown string to an inline-styled HTML fragment. -/// Raw HTML blocks andainline HTML are stripped. +/// 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 = Parser::new(content) - .filter(|event| !matches!(event, Event::Html(_) | Event::InlineHtml(_))); + let parser = EmailEventFilter::new(Parser::new(content)); let mut raw_html = String::new(); html::push_html(&mut raw_html, parser); diff --git a/crates/defguard_mail/src/tests.rs b/crates/defguard_mail/src/tests.rs index b857e395e9..aa86bdc991 100644 --- a/crates/defguard_mail/src/tests.rs +++ b/crates/defguard_mail/src/tests.rs @@ -394,6 +394,178 @@ 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_is_rendered_with_style() { + let html = markdown_to_html("# Heading"); + assert!(has_tag(&html, "h1"), "h1 tag missing: {html}"); + assert!( + html.contains("style="), + "h1 must carry an inline style: {html}" + ); + } + + #[test] + fn h2_is_rendered_with_style() { + let html = markdown_to_html("## Heading"); + assert!(has_tag(&html, "h2"), "h2 tag missing: {html}"); + assert!( + html.contains("style="), + "h2 must carry an inline style: {html}" + ); + } + + #[test] + fn bold_is_rendered() { + let html = markdown_to_html("**bold**"); + assert!(has_tag(&html, "strong"), "strong tag missing: {html}"); + } + + #[test] + fn unordered_list_is_rendered_with_style() { + let html = markdown_to_html("- First\n- Second"); + assert!(has_tag(&html, "ul"), "ul tag missing: {html}"); + assert!(has_tag(&html, "li"), "li tag missing: {html}"); + assert!( + html.contains("style="), + "list items must carry an inline style: {html}" + ); + } + + #[test] + fn horizontal_rule_is_rendered_with_style() { + let html = markdown_to_html("---"); + assert!(has_tag(&html, "hr"), "hr tag missing: {html}"); + assert!( + html.contains("style="), + "hr must carry an inline style: {html}" + ); + } + + #[test] + fn link_is_rendered_with_style() { + let html = markdown_to_html("[click](https://example.com)"); + assert!(has_tag(&html, "a"), "a tag missing: {html}"); + assert!(html.contains("https://example.com"), "href missing: {html}"); + assert!( + html.contains("style="), + "a must carry an inline style: {html}" + ); + } + + #[test] + fn blockquote_is_stripped() { + let html = markdown_to_html("> secret content"); + assert!( + !has_tag(&html, "blockquote"), + "blockquote must be stripped: {html}" + ); + assert!( + !html.contains("secret content"), + "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 must be stripped: {html}"); + assert!( + !html.contains("First"), + "ordered list content must not appear: {html}" + ); + } + + #[test] + fn code_block_is_stripped() { + let html = markdown_to_html("```\nsome code\n```"); + assert!(!has_tag(&html, "pre"), "pre must be stripped: {html}"); + assert!(!has_tag(&html, "code"), "code must be stripped: {html}"); + assert!( + !html.contains("some code"), + "code block content must not appear: {html}" + ); + } + + #[test] + fn nested_unsupported_block_is_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 inside blockquote must be stripped: {html}" + ); + assert!( + !html.contains("nested item"), + "nested content must not appear: {html}" + ); + } + + #[test] + fn italic_tag_is_stripped_but_text_kept() { + let html = markdown_to_html("*italic text*"); + assert!(!has_tag(&html, "em"), "em must be stripped: {html}"); + assert!( + html.contains("italic text"), + "italic text content must be preserved: {html}" + ); + } + + #[test] + fn inline_code_becomes_plain_text() { + let html = markdown_to_html("`inline code`"); + assert!(!has_tag(&html, "code"), "code tag must be stripped: {html}"); + assert!( + html.contains("inline code"), + "inline code text must be preserved: {html}" + ); + } + + #[test] + fn h3_is_degraded_to_h2() { + let html = markdown_to_html("### Heading"); + assert!(has_tag(&html, "h2"), "h3 must be degraded to h2: {html}"); + assert!(!has_tag(&html, "h3"), "h3 must not appear: {html}"); + } + + #[test] + fn h6_is_degraded_to_h2() { + let html = markdown_to_html("###### Deep heading"); + assert!(has_tag(&html, "h2"), "h6 must be degraded to h2: {html}"); + assert!(!has_tag(&html, "h6"), "h6 must not appear: {html}"); + } + + #[test] + fn raw_html_is_stripped() { + let html = markdown_to_html(" text"); + assert!( + !html.contains("\n\nSafe paragraph."); assert!( - html.contains("italic text"), - "italic text content must be preserved: {html}" + !html.contains(" text"); + 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("
- {{ welcome_message_content }} -