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 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#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct MailTemplateDefinition {
78 pub code: &'static str,
80 pub category: &'static str,
82 pub label_i18n_key: &'static str,
84 pub variables: &'static [TemplateVariableSpec],
86}
87
88impl MailTemplateDefinition {
89 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 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#[derive(Debug, Clone, Copy)]
125pub struct MailTemplateRegistry {
126 definitions: &'static [MailTemplateDefinition],
127}
128
129impl MailTemplateRegistry {
130 pub const fn new(definitions: &'static [MailTemplateDefinition]) -> Self {
132 Self { definitions }
133 }
134
135 pub const fn definitions(&self) -> &'static [MailTemplateDefinition] {
137 self.definitions
138 }
139
140 pub fn variable_groups(&self) -> Vec<TemplateVariableGroup> {
142 self.definitions
143 .iter()
144 .map(MailTemplateDefinition::variable_group)
145 .collect()
146 }
147
148 pub fn get(&self, code: &str) -> Option<&'static MailTemplateDefinition> {
150 self.definitions
151 .iter()
152 .find(|definition| definition.code == code)
153 }
154
155 pub fn validate(&self) -> Result<(), MailTemplateRegistryError> {
157 validate_definitions(self.definitions.iter())
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
163pub struct MailTemplateCatalog {
164 definitions: Vec<&'static MailTemplateDefinition>,
165}
166
167pub type MailTemplateRegistrar = fn(&mut MailTemplateCatalogBuilder);
169
170impl MailTemplateCatalog {
171 pub fn builder() -> MailTemplateCatalogBuilder {
173 MailTemplateCatalogBuilder::new()
174 }
175
176 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 pub fn definitions(&self) -> &[&'static MailTemplateDefinition] {
193 &self.definitions
194 }
195
196 pub fn variable_groups(&self) -> Vec<TemplateVariableGroup> {
198 self.definitions
199 .iter()
200 .map(|definition| definition.variable_group())
201 .collect()
202 }
203
204 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
215pub struct MailTemplateCatalogBuilder {
216 definitions: Vec<&'static MailTemplateDefinition>,
217}
218
219impl MailTemplateCatalogBuilder {
220 pub fn new() -> Self {
222 Self::default()
223 }
224
225 pub fn register(&mut self, definition: &'static MailTemplateDefinition) -> &mut Self {
227 self.definitions.push(definition);
228 self
229 }
230
231 pub fn register_all(&mut self, definitions: &'static [MailTemplateDefinition]) -> &mut Self {
233 self.definitions.extend(definitions.iter());
234 self
235 }
236
237 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#[derive(Debug, Clone, PartialEq, Eq)]
248pub enum MailTemplateRegistryError {
249 EmptyTemplateCode,
251 DuplicateTemplateCode {
253 code: &'static str,
255 },
256 EmptyVariableKey {
258 template_code: &'static str,
260 },
261 DuplicateVariableKey {
263 template_code: &'static str,
265 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#[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 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 pub fn text_values(&self) -> &[(&'static str, String)] {
350 &self.text_values
351 }
352
353 pub fn html_values(&self) -> &[(&'static str, String)] {
355 &self.html_values
356 }
357}
358
359pub 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
376pub 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 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
406pub fn escape_html(value: &str) -> String {
408 aster_forge_utils::html::escape_html(value)
409}
410
411pub 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 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 value
535 .replace(" ", " ")
536 .replace("<", "<")
537 .replace(">", ">")
538 .replace(""", "\"")
539 .replace("'", "'")
540 .replace("&", "&")
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&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 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 assert_eq!(
774 html_to_text("<p>&lt; &amp; <</p>"),
775 "< & <"
776 );
777 }
778
779 #[test]
780 fn html_to_text_recovers_from_misnested_ignored_tags() {
781 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}