aster_forge_mail/
template.rs

1//! Product-neutral mail template registration and rendering helpers.
2//!
3//! Products still own template codes, default subject/body content, runtime configuration keys,
4//! payload types, URLs, and localization. This module only provides the shared mechanics around a
5//! registered template catalog: variable metadata, placeholder substitution, HTML escaping, and
6//! text fallback generation.
7
8use std::collections::HashSet;
9use std::error::Error;
10use std::fmt;
11
12/// Rendered mail bodies produced from a template and placeholder values.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RenderedMail {
15    /// Rendered message subject.
16    pub subject: String,
17    /// Plain-text fallback body.
18    pub text_body: String,
19    /// Rendered HTML body.
20    pub html_body: String,
21}
22
23/// Variable metadata exposed to product admin UIs.
24#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
25#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
26pub struct TemplateVariableItem {
27    /// Placeholder token displayed in UI, such as `{{username}}`.
28    pub token: String,
29    /// Product-owned i18n label key.
30    pub label_i18n_key: String,
31    /// Product-owned i18n description key.
32    pub description_i18n_key: String,
33}
34
35/// Variable metadata for one registered template.
36#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
37#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
38pub struct TemplateVariableGroup {
39    /// Product-owned configuration category.
40    pub category: String,
41    /// Stable product template code.
42    pub template_code: String,
43    /// Product-owned i18n group label key.
44    pub label_i18n_key: String,
45    /// Variables accepted by the template.
46    pub variables: Vec<TemplateVariableItem>,
47}
48
49/// One placeholder accepted by a template.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct TemplateVariableSpec {
52    /// Placeholder key without braces.
53    pub key: &'static str,
54    /// Product-owned i18n label key.
55    pub label_i18n_key: &'static str,
56    /// Product-owned i18n description key.
57    pub description_i18n_key: &'static str,
58}
59
60impl TemplateVariableSpec {
61    /// Creates a variable spec.
62    #[must_use]
63    pub const fn new(
64        key: &'static str,
65        label_i18n_key: &'static str,
66        description_i18n_key: &'static str,
67    ) -> Self {
68        Self {
69            key,
70            label_i18n_key,
71            description_i18n_key,
72        }
73    }
74}
75
76/// Registered metadata for one product mail template.
77#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MailTemplateDefinition {
79    /// Stable product template code.
80    pub code: &'static str,
81    /// Product-owned configuration category.
82    pub category: &'static str,
83    /// Product-owned i18n group label key.
84    pub label_i18n_key: &'static str,
85    /// Variables accepted by the template.
86    pub variables: &'static [TemplateVariableSpec],
87}
88
89impl MailTemplateDefinition {
90    /// Creates a registered template definition.
91    #[must_use]
92    pub const fn new(
93        code: &'static str,
94        category: &'static str,
95        label_i18n_key: &'static str,
96        variables: &'static [TemplateVariableSpec],
97    ) -> Self {
98        Self {
99            code,
100            category,
101            label_i18n_key,
102            variables,
103        }
104    }
105
106    /// Converts this definition into API-facing variable metadata.
107    #[must_use]
108    pub fn variable_group(&self) -> TemplateVariableGroup {
109        TemplateVariableGroup {
110            category: self.category.to_string(),
111            template_code: self.code.to_string(),
112            label_i18n_key: self.label_i18n_key.to_string(),
113            variables: self
114                .variables
115                .iter()
116                .map(|variable| TemplateVariableItem {
117                    token: format!("{{{{{}}}}}", variable.key),
118                    label_i18n_key: variable.label_i18n_key.to_string(),
119                    description_i18n_key: variable.description_i18n_key.to_string(),
120                })
121                .collect(),
122        }
123    }
124}
125
126/// A product-owned template registry.
127#[derive(Debug, Clone, Copy)]
128pub struct MailTemplateRegistry {
129    definitions: &'static [MailTemplateDefinition],
130}
131
132impl MailTemplateRegistry {
133    /// Creates a registry from static product definitions.
134    #[must_use]
135    pub const fn new(definitions: &'static [MailTemplateDefinition]) -> Self {
136        Self { definitions }
137    }
138
139    /// Returns registered definitions in product order.
140    #[must_use]
141    pub const fn definitions(&self) -> &'static [MailTemplateDefinition] {
142        self.definitions
143    }
144
145    /// Returns variable groups in product registration order.
146    pub fn variable_groups(&self) -> Vec<TemplateVariableGroup> {
147        self.definitions
148            .iter()
149            .map(MailTemplateDefinition::variable_group)
150            .collect()
151    }
152
153    /// Looks up a template definition by code.
154    #[must_use]
155    pub fn get(&self, code: &str) -> Option<&'static MailTemplateDefinition> {
156        self.definitions
157            .iter()
158            .find(|definition| definition.code == code)
159    }
160
161    /// Validates that this static registry has unique template codes and variable keys.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`MailTemplateRegistryError`] when template codes or variable definitions are invalid.
166    pub fn validate(&self) -> Result<(), MailTemplateRegistryError> {
167        validate_definitions(self.definitions.iter())
168    }
169}
170
171/// Runtime-composed template registry built from product and subsystem registrations.
172#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct MailTemplateCatalog {
174    definitions: Vec<&'static MailTemplateDefinition>,
175}
176
177/// Function exported by a product subsystem to register its mail templates.
178pub type MailTemplateRegistrar = fn(&mut MailTemplateCatalogBuilder);
179
180impl MailTemplateCatalog {
181    /// Creates an empty catalog builder.
182    #[must_use]
183    pub fn builder() -> MailTemplateCatalogBuilder {
184        MailTemplateCatalogBuilder::new()
185    }
186
187    /// Builds a catalog from subsystem registrar functions.
188    ///
189    /// Each registrar receives the same builder and can add one or more static template
190    /// definitions. This keeps product bootstrapping declarative without forcing subsystems to
191    /// share a concrete registry type.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`MailTemplateRegistryError`] when any registrar adds an invalid or duplicate template.
196    pub fn from_registrars(
197        registrars: &[MailTemplateRegistrar],
198    ) -> Result<Self, MailTemplateRegistryError> {
199        let mut builder = Self::builder();
200        for registrar in registrars {
201            registrar(&mut builder);
202        }
203        builder.build()
204    }
205
206    /// Returns registered definitions in registration order.
207    #[must_use]
208    pub fn definitions(&self) -> &[&'static MailTemplateDefinition] {
209        &self.definitions
210    }
211
212    /// Returns variable groups in registration order.
213    #[must_use]
214    pub fn variable_groups(&self) -> Vec<TemplateVariableGroup> {
215        self.definitions
216            .iter()
217            .map(|definition| definition.variable_group())
218            .collect()
219    }
220
221    /// Looks up a template definition by code.
222    #[must_use]
223    pub fn get(&self, code: &str) -> Option<&'static MailTemplateDefinition> {
224        self.definitions
225            .iter()
226            .copied()
227            .find(|definition| definition.code == code)
228    }
229}
230
231/// Builder used by products to assemble a mail template catalog from multiple subsystems.
232#[derive(Debug, Clone, Default, PartialEq, Eq)]
233pub struct MailTemplateCatalogBuilder {
234    definitions: Vec<&'static MailTemplateDefinition>,
235}
236
237impl MailTemplateCatalogBuilder {
238    /// Creates an empty builder.
239    #[must_use]
240    pub fn new() -> Self {
241        Self::default()
242    }
243
244    /// Registers one template definition.
245    pub fn register(&mut self, definition: &'static MailTemplateDefinition) -> &mut Self {
246        self.definitions.push(definition);
247        self
248    }
249
250    /// Registers all definitions from a static slice.
251    pub fn register_all(&mut self, definitions: &'static [MailTemplateDefinition]) -> &mut Self {
252        self.definitions.extend(definitions.iter());
253        self
254    }
255
256    /// Builds a catalog after validating duplicate template codes and variable keys.
257    ///
258    /// # Errors
259    ///
260    /// Returns [`MailTemplateRegistryError`] when the accumulated template catalog is invalid.
261    pub fn build(self) -> Result<MailTemplateCatalog, MailTemplateRegistryError> {
262        validate_definitions(self.definitions.iter().copied())?;
263        Ok(MailTemplateCatalog {
264            definitions: self.definitions,
265        })
266    }
267}
268
269/// Validation error returned when a template registry has ambiguous registrations.
270#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum MailTemplateRegistryError {
272    /// A template code is empty.
273    EmptyTemplateCode,
274    /// The same template code was registered more than once.
275    DuplicateTemplateCode {
276        /// Duplicated template code.
277        code: &'static str,
278    },
279    /// A variable key is empty for a template.
280    EmptyVariableKey {
281        /// Template code containing the empty variable key.
282        template_code: &'static str,
283    },
284    /// The same variable key was registered more than once for one template.
285    DuplicateVariableKey {
286        /// Template code containing the duplicated variable key.
287        template_code: &'static str,
288        /// Duplicated variable key.
289        key: &'static str,
290    },
291}
292
293impl fmt::Display for MailTemplateRegistryError {
294    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
295        match self {
296            Self::EmptyTemplateCode => formatter.write_str("mail template code must not be empty"),
297            Self::DuplicateTemplateCode { code } => {
298                write!(
299                    formatter,
300                    "mail template code `{code}` is registered more than once"
301                )
302            }
303            Self::EmptyVariableKey { template_code } => write!(
304                formatter,
305                "mail template `{template_code}` contains an empty variable key"
306            ),
307            Self::DuplicateVariableKey { template_code, key } => write!(
308                formatter,
309                "mail template `{template_code}` registers variable `{key}` more than once"
310            ),
311        }
312    }
313}
314
315impl Error for MailTemplateRegistryError {}
316
317fn validate_definitions<'a, I>(definitions: I) -> Result<(), MailTemplateRegistryError>
318where
319    I: IntoIterator<Item = &'a MailTemplateDefinition>,
320{
321    let mut template_codes = HashSet::new();
322
323    for definition in definitions {
324        if definition.code.is_empty() {
325            return Err(MailTemplateRegistryError::EmptyTemplateCode);
326        }
327        if !template_codes.insert(definition.code) {
328            return Err(MailTemplateRegistryError::DuplicateTemplateCode {
329                code: definition.code,
330            });
331        }
332
333        let mut variable_keys = HashSet::new();
334        for variable in definition.variables {
335            if variable.key.is_empty() {
336                return Err(MailTemplateRegistryError::EmptyVariableKey {
337                    template_code: definition.code,
338                });
339            }
340            if !variable_keys.insert(variable.key) {
341                return Err(MailTemplateRegistryError::DuplicateVariableKey {
342                    template_code: definition.code,
343                    key: variable.key,
344                });
345            }
346        }
347    }
348
349    Ok(())
350}
351
352/// Placeholder values for a render pass.
353#[derive(Debug, Clone, PartialEq, Eq)]
354pub struct TemplatePlaceholderSet {
355    text_values: Vec<(&'static str, String)>,
356    html_values: Vec<(&'static str, String)>,
357}
358
359impl TemplatePlaceholderSet {
360    /// Creates a placeholder set from separate text and HTML values.
361    #[must_use]
362    pub fn new(
363        text_values: Vec<(&'static str, String)>,
364        html_values: Vec<(&'static str, String)>,
365    ) -> Self {
366        Self {
367            text_values,
368            html_values,
369        }
370    }
371
372    /// Returns placeholder values for plain text and subject rendering.
373    #[must_use]
374    pub fn text_values(&self) -> &[(&'static str, String)] {
375        &self.text_values
376    }
377
378    /// Returns placeholder values for HTML rendering.
379    #[must_use]
380    pub fn html_values(&self) -> &[(&'static str, String)] {
381        &self.html_values
382    }
383}
384
385/// Renders a subject and HTML template with placeholder values and derives text fallback.
386#[must_use]
387pub fn render_template(
388    subject_template: &str,
389    html_template: &str,
390    placeholders: &TemplatePlaceholderSet,
391) -> RenderedMail {
392    let subject = render_placeholders(subject_template, placeholders.text_values());
393    let html_body = render_placeholders(html_template, placeholders.html_values());
394    let text_body = html_to_text(&html_body);
395
396    RenderedMail {
397        subject,
398        text_body,
399        html_body,
400    }
401}
402
403/// Replaces `{{key}}` placeholders with provided values.
404///
405/// Replacement is a single left-to-right pass: substituted values are never
406/// re-scanned, so a user-controlled value containing `{{another_key}}` cannot
407/// trigger a second expansion. Unknown or unterminated tokens stay literal.
408#[must_use]
409pub fn render_placeholders(template: &str, values: &[(&'static str, String)]) -> String {
410    let mut rendered = String::with_capacity(template.len());
411    let mut rest = template;
412
413    while let Some(open) = rest.find("{{") {
414        let key_start = open + 2;
415        let Some(close) = rest[key_start..].find("}}") else {
416            // Unterminated `{{`: emit the remainder literally.
417            break;
418        };
419        let token_end = key_start + close + 2;
420        let key = &rest[key_start..key_start + close];
421
422        rendered.push_str(&rest[..open]);
423        match values.iter().find(|(name, _)| *name == key) {
424            Some((_, value)) => rendered.push_str(value),
425            None => rendered.push_str(&rest[open..token_end]),
426        }
427        rest = &rest[token_end..];
428    }
429
430    rendered.push_str(rest);
431    rendered
432}
433
434/// Escapes text for insertion into HTML templates.
435#[must_use]
436pub fn escape_html(value: &str) -> String {
437    aster_forge_utils::html::escape_html(value)
438}
439
440/// Converts simple HTML email content into a plain-text fallback.
441#[must_use]
442pub fn html_to_text(html: &str) -> String {
443    let mut output = String::with_capacity(html.len());
444    let mut in_tag = false;
445    let mut tag = String::new();
446    let mut ignored_tags = Vec::new();
447
448    for ch in html.chars() {
449        if in_tag {
450            if ch == '>' {
451                if let Some(parsed_tag) = parse_tag(&tag) {
452                    if ignored_tags.is_empty() {
453                        apply_tag_to_text(&mut output, &parsed_tag);
454                    }
455                    update_ignored_tags(&mut ignored_tags, &parsed_tag);
456                }
457                tag.clear();
458                in_tag = false;
459            } else {
460                tag.push(ch);
461            }
462            continue;
463        }
464
465        if ch == '<' {
466            in_tag = true;
467            continue;
468        }
469
470        if ignored_tags.is_empty() {
471            output.push(ch);
472        }
473    }
474
475    let decoded = decode_html_entities(&output);
476    normalize_text_fallback(&decoded)
477}
478
479fn apply_tag_to_text(output: &mut String, tag: &ParsedTag) {
480    if tag.is_closing {
481        return;
482    }
483
484    if tag.name == "li" && !output.ends_with("- ") {
485        if !output.is_empty() && !output.ends_with('\n') {
486            output.push('\n');
487        }
488        output.push_str("- ");
489        return;
490    }
491
492    let needs_newline = matches!(
493        tag.name.as_str(),
494        "p" | "div"
495            | "section"
496            | "article"
497            | "header"
498            | "footer"
499            | "tr"
500            | "table"
501            | "br"
502            | "h1"
503            | "h2"
504            | "h3"
505            | "h4"
506            | "h5"
507            | "h6"
508    );
509
510    if needs_newline && !output.is_empty() && !output.ends_with('\n') {
511        output.push('\n');
512    }
513}
514
515fn parse_tag(tag: &str) -> Option<ParsedTag> {
516    let trimmed = tag.trim();
517    if trimmed.is_empty() || trimmed.starts_with('!') || trimmed.starts_with('?') {
518        return None;
519    }
520
521    let is_closing = trimmed.starts_with('/');
522    let content = if is_closing { &trimmed[1..] } else { trimmed };
523    let is_self_closing = content.ends_with('/');
524    let name = content
525        .trim_end_matches('/')
526        .split_whitespace()
527        .next()?
528        .to_ascii_lowercase();
529
530    Some(ParsedTag {
531        name,
532        is_closing,
533        is_self_closing,
534    })
535}
536
537fn update_ignored_tags(ignored_tags: &mut Vec<String>, tag: &ParsedTag) {
538    if !is_ignored_text_tag(&tag.name) || tag.is_self_closing {
539        return;
540    }
541
542    if tag.is_closing {
543        // Mis-nested ignored tags (e.g. `<script><style></script>`) must not
544        // wedge the stack: pop through the nearest matching open tag, or the
545        // rest of the document would be silently dropped from the text
546        // fallback.
547        if let Some(position) = ignored_tags.iter().rposition(|name| name == &tag.name) {
548            ignored_tags.truncate(position);
549        }
550        return;
551    }
552
553    ignored_tags.push(tag.name.clone());
554}
555
556fn is_ignored_text_tag(name: &str) -> bool {
557    matches!(name, "head" | "script" | "style" | "title")
558}
559
560fn decode_html_entities(value: &str) -> String {
561    // "&amp;" must decode last: it is the escape introducer for every other
562    // entity, so decoding it first would double-decode inputs like "&amp;lt;"
563    // (the encoding of the literal text "&lt;") into a bare "<".
564    value
565        .replace("&nbsp;", " ")
566        .replace("&lt;", "<")
567        .replace("&gt;", ">")
568        .replace("&quot;", "\"")
569        .replace("&#39;", "'")
570        .replace("&amp;", "&")
571}
572
573fn normalize_text_fallback(value: &str) -> String {
574    let mut normalized = String::new();
575    let mut last_blank = true;
576
577    for line in value.lines() {
578        let trimmed = line.trim();
579        if trimmed.is_empty() {
580            if !last_blank {
581                normalized.push('\n');
582            }
583            last_blank = true;
584            continue;
585        }
586
587        if !normalized.is_empty() && !normalized.ends_with('\n') {
588            normalized.push('\n');
589        }
590        normalized.push_str(trimmed);
591        last_blank = false;
592    }
593
594    normalized.trim().to_string()
595}
596
597struct ParsedTag {
598    name: String,
599    is_closing: bool,
600    is_self_closing: bool,
601}
602
603#[cfg(test)]
604mod tests {
605    use super::{
606        MailTemplateCatalog, MailTemplateCatalogBuilder, MailTemplateDefinition,
607        MailTemplateRegistry, MailTemplateRegistryError, TemplatePlaceholderSet,
608        TemplateVariableSpec, escape_html, html_to_text, render_placeholders, render_template,
609    };
610
611    const VARIABLES: &[TemplateVariableSpec] = &[
612        TemplateVariableSpec::new("username", "username_label", "username_desc"),
613        TemplateVariableSpec::new("site_name", "site_name_label", "site_name_desc"),
614    ];
615    const DEFINITIONS: &[MailTemplateDefinition] = &[MailTemplateDefinition::new(
616        "welcome",
617        "mail_template",
618        "welcome_label",
619        VARIABLES,
620    )];
621    const SECOND_DEFINITION: MailTemplateDefinition = MailTemplateDefinition::new(
622        "password_reset",
623        "mail_template",
624        "password_reset_label",
625        VARIABLES,
626    );
627    const DUPLICATE_CODE_DEFINITIONS: &[MailTemplateDefinition] = &[
628        MailTemplateDefinition::new("welcome", "mail_template", "welcome_label", VARIABLES),
629        MailTemplateDefinition::new("welcome", "mail_template", "welcome_label", VARIABLES),
630    ];
631    const DUPLICATE_VARIABLES: &[TemplateVariableSpec] = &[
632        TemplateVariableSpec::new("username", "username_label", "username_desc"),
633        TemplateVariableSpec::new("username", "username_label", "username_desc"),
634    ];
635    const DUPLICATE_VARIABLE_DEFINITIONS: &[MailTemplateDefinition] =
636        &[MailTemplateDefinition::new(
637            "welcome",
638            "mail_template",
639            "welcome_label",
640            DUPLICATE_VARIABLES,
641        )];
642
643    #[test]
644    fn registry_returns_variable_groups_in_definition_order() {
645        let registry = MailTemplateRegistry::new(DEFINITIONS);
646
647        let groups = registry.variable_groups();
648
649        assert_eq!(groups.len(), 1);
650        assert_eq!(groups[0].category, "mail_template");
651        assert_eq!(groups[0].template_code, "welcome");
652        assert_eq!(groups[0].label_i18n_key, "welcome_label");
653        assert_eq!(groups[0].variables[0].token, "{{username}}");
654        assert_eq!(
655            registry.get("welcome").map(|definition| definition.code),
656            Some("welcome")
657        );
658        assert!(registry.get("missing").is_none());
659        registry.validate().unwrap();
660    }
661
662    #[test]
663    fn catalog_builder_registers_multiple_sources_in_order() {
664        let mut builder = MailTemplateCatalog::builder();
665        builder.register_all(DEFINITIONS);
666        builder.register(&SECOND_DEFINITION);
667        let catalog = builder.build().unwrap();
668
669        let codes = catalog
670            .definitions()
671            .iter()
672            .map(|definition| definition.code)
673            .collect::<Vec<_>>();
674
675        assert_eq!(codes, vec!["welcome", "password_reset"]);
676        assert_eq!(
677            catalog
678                .variable_groups()
679                .into_iter()
680                .map(|group| group.template_code)
681                .collect::<Vec<_>>(),
682            vec!["welcome", "password_reset"]
683        );
684        assert_eq!(
685            catalog
686                .get("password_reset")
687                .map(|definition| definition.code),
688            Some("password_reset")
689        );
690    }
691
692    #[test]
693    fn catalog_can_be_built_from_registrars() {
694        fn register_welcome(builder: &mut MailTemplateCatalogBuilder) {
695            builder.register_all(DEFINITIONS);
696        }
697
698        fn register_password_reset(builder: &mut MailTemplateCatalogBuilder) {
699            builder.register(&SECOND_DEFINITION);
700        }
701
702        let catalog =
703            MailTemplateCatalog::from_registrars(&[register_welcome, register_password_reset])
704                .unwrap();
705
706        assert_eq!(
707            catalog
708                .definitions()
709                .iter()
710                .map(|definition| definition.code)
711                .collect::<Vec<_>>(),
712            vec!["welcome", "password_reset"]
713        );
714    }
715
716    #[test]
717    fn registry_validation_rejects_duplicate_template_codes() {
718        let registry = MailTemplateRegistry::new(DUPLICATE_CODE_DEFINITIONS);
719
720        assert_eq!(
721            registry.validate(),
722            Err(MailTemplateRegistryError::DuplicateTemplateCode { code: "welcome" })
723        );
724    }
725
726    #[test]
727    fn catalog_builder_rejects_duplicate_variable_keys() {
728        let mut builder = MailTemplateCatalog::builder();
729        builder.register_all(DUPLICATE_VARIABLE_DEFINITIONS);
730        let error = builder.build().unwrap_err();
731
732        assert_eq!(
733            error,
734            MailTemplateRegistryError::DuplicateVariableKey {
735                template_code: "welcome",
736                key: "username",
737            }
738        );
739    }
740
741    #[test]
742    fn render_template_replaces_subject_html_and_text_placeholders() {
743        let rendered = render_template(
744            "Hello {{username}}",
745            "<p>Hello {{username}}</p><p>{{site_name}}</p>",
746            &TemplatePlaceholderSet::new(
747                vec![
748                    ("username", "A&B".to_string()),
749                    ("site_name", "Aster".to_string()),
750                ],
751                vec![
752                    ("username", escape_html("A&B")),
753                    ("site_name", escape_html("Aster")),
754                ],
755            ),
756        );
757
758        assert_eq!(rendered.subject, "Hello A&B");
759        assert_eq!(rendered.html_body, "<p>Hello A&amp;B</p><p>Aster</p>");
760        assert_eq!(rendered.text_body, "Hello A&B\nAster");
761    }
762
763    #[test]
764    fn html_to_text_ignores_head_script_and_style_content() {
765        let html = "<!doctype html><html><head><title>Ignore</title><style>.x {}</style></head><body><p>Hello</p><script>bad()</script><ul><li>One</li></ul></body></html>";
766
767        assert_eq!(html_to_text(html), "Hello\n- One");
768    }
769
770    #[test]
771    fn render_placeholders_does_not_expand_placeholders_inside_values() {
772        // A user-controlled value must never be re-scanned: sequential
773        // replacement would expand the "{{reset_url}}" smuggled in via
774        // `username` and splice an href-only unescaped URL into the body.
775        let rendered = render_placeholders(
776            "Hello {{username}}, reset here: {{reset_url}}",
777            &[
778                ("username", "{{reset_url}}".to_string()),
779                ("reset_url", "https://evil.example.com/reset".to_string()),
780            ],
781        );
782
783        assert_eq!(
784            rendered,
785            "Hello {{reset_url}}, reset here: https://evil.example.com/reset"
786        );
787    }
788
789    #[test]
790    fn render_placeholders_keeps_unknown_and_unterminated_tokens_literal() {
791        let rendered = render_placeholders(
792            "Hi {{username}}, {{unknown}} and {{unterminated",
793            &[("username", "Aster".to_string())],
794        );
795
796        assert_eq!(rendered, "Hi Aster, {{unknown}} and {{unterminated");
797    }
798
799    #[test]
800    fn html_to_text_decodes_ampersand_entities_once() {
801        // "&amp;lt;" is the HTML encoding of the literal text "&lt;"; decoding
802        // "&amp;" before "&lt;" would double-decode it into a literal "<".
803        assert_eq!(
804            html_to_text("<p>&amp;lt; &amp;amp; &lt;</p>"),
805            "&lt; &amp; <"
806        );
807    }
808
809    #[test]
810    fn html_to_text_recovers_from_misnested_ignored_tags() {
811        // `<script><style></script>` mis-nesting must not wedge the ignore
812        // stack: the body text after it is still part of the message.
813        let html = "<script>bad()</script><p>Hello</p><script><style></script><p>Still here</p>";
814
815        assert_eq!(html_to_text(html), "Hello\nStill here");
816    }
817}