Skip to main content

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