1use std::collections::HashSet;
9use std::error::Error;
10use std::fmt;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RenderedMail {
15 pub subject: String,
17 pub text_body: String,
19 pub html_body: String,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
25#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
26pub struct TemplateVariableItem {
27 pub token: String,
29 pub label_i18n_key: String,
31 pub description_i18n_key: String,
33}
34
35#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
37#[cfg_attr(all(debug_assertions, feature = "openapi"), derive(utoipa::ToSchema))]
38pub struct TemplateVariableGroup {
39 pub category: String,
41 pub template_code: String,
43 pub label_i18n_key: String,
45 pub variables: Vec<TemplateVariableItem>,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub struct TemplateVariableSpec {
52 pub key: &'static str,
54 pub label_i18n_key: &'static str,
56 pub description_i18n_key: &'static str,
58}
59
60impl TemplateVariableSpec {
61 #[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#[derive(Debug, Clone, PartialEq, Eq)]
78pub struct MailTemplateDefinition {
79 pub code: &'static str,
81 pub category: &'static str,
83 pub label_i18n_key: &'static str,
85 pub variables: &'static [TemplateVariableSpec],
87}
88
89impl MailTemplateDefinition {
90 #[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 #[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#[derive(Debug, Clone, Copy)]
128pub struct MailTemplateRegistry {
129 definitions: &'static [MailTemplateDefinition],
130}
131
132impl MailTemplateRegistry {
133 #[must_use]
135 pub const fn new(definitions: &'static [MailTemplateDefinition]) -> Self {
136 Self { definitions }
137 }
138
139 #[must_use]
141 pub const fn definitions(&self) -> &'static [MailTemplateDefinition] {
142 self.definitions
143 }
144
145 pub fn variable_groups(&self) -> Vec<TemplateVariableGroup> {
147 self.definitions
148 .iter()
149 .map(MailTemplateDefinition::variable_group)
150 .collect()
151 }
152
153 #[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 pub fn validate(&self) -> Result<(), MailTemplateRegistryError> {
167 validate_definitions(self.definitions.iter())
168 }
169}
170
171#[derive(Debug, Clone, PartialEq, Eq)]
173pub struct MailTemplateCatalog {
174 definitions: Vec<&'static MailTemplateDefinition>,
175}
176
177pub type MailTemplateRegistrar = fn(&mut MailTemplateCatalogBuilder);
179
180impl MailTemplateCatalog {
181 #[must_use]
183 pub fn builder() -> MailTemplateCatalogBuilder {
184 MailTemplateCatalogBuilder::new()
185 }
186
187 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 #[must_use]
208 pub fn definitions(&self) -> &[&'static MailTemplateDefinition] {
209 &self.definitions
210 }
211
212 #[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 #[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
233pub struct MailTemplateCatalogBuilder {
234 definitions: Vec<&'static MailTemplateDefinition>,
235}
236
237impl MailTemplateCatalogBuilder {
238 #[must_use]
240 pub fn new() -> Self {
241 Self::default()
242 }
243
244 pub fn register(&mut self, definition: &'static MailTemplateDefinition) -> &mut Self {
246 self.definitions.push(definition);
247 self
248 }
249
250 pub fn register_all(&mut self, definitions: &'static [MailTemplateDefinition]) -> &mut Self {
252 self.definitions.extend(definitions.iter());
253 self
254 }
255
256 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#[derive(Debug, Clone, PartialEq, Eq)]
271pub enum MailTemplateRegistryError {
272 EmptyTemplateCode,
274 DuplicateTemplateCode {
276 code: &'static str,
278 },
279 EmptyVariableKey {
281 template_code: &'static str,
283 },
284 DuplicateVariableKey {
286 template_code: &'static str,
288 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#[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 #[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 #[must_use]
374 pub fn text_values(&self) -> &[(&'static str, String)] {
375 &self.text_values
376 }
377
378 #[must_use]
380 pub fn html_values(&self) -> &[(&'static str, String)] {
381 &self.html_values
382 }
383}
384
385#[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#[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 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#[must_use]
436pub fn escape_html(value: &str) -> String {
437 aster_forge_utils::html::escape_html(value)
438}
439
440#[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 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 value
565 .replace(" ", " ")
566 .replace("<", "<")
567 .replace(">", ">")
568 .replace(""", "\"")
569 .replace("'", "'")
570 .replace("&", "&")
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&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 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 assert_eq!(
804 html_to_text("<p>&lt; &amp; <</p>"),
805 "< & <"
806 );
807 }
808
809 #[test]
810 fn html_to_text_recovers_from_misnested_ignored_tags() {
811 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}