1use std::collections::{BTreeMap, HashMap};
13use std::sync::{Arc, RwLock};
14use std::time::Duration;
15
16use minijinja::value::{Kwargs, Value};
17use minijinja::{Environment, State, UndefinedBehavior};
18use sea_query::{Expr, Iden, OnConflict, Order, Query};
19use serde::{Deserialize, Serialize};
20
21use crate::cache::Cache;
22use crate::error::{CoreError, CoreResult};
23use crate::query::{bind_values, build, AnyRowExt};
24use crate::Db;
25
26#[derive(Debug, Clone, Copy)]
28pub struct MailTemplateReg {
29 pub code: &'static str,
30 pub file: &'static str,
31}
32
33#[derive(Debug, Clone, Copy)]
35pub struct MailLayoutReg {
36 pub code: &'static str,
37 pub file: &'static str,
38}
39
40#[derive(Debug, Clone, Copy)]
42pub struct MailPartialReg {
43 pub code: &'static str,
44 pub file: &'static str,
45}
46
47pub const NAMESPACE: &str = "laterite.mail::templates";
50
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
53pub struct Sections {
54 pub settings: BTreeMap<String, String>,
55 pub text: String,
56 pub html: String,
57 pub css: String,
58}
59
60pub fn parse_file(file: &str) -> Sections {
64 let parts: Vec<&str> = file.split("\n==\n").collect();
65 let parts: Vec<String> = parts
66 .into_iter()
67 .map(|p| p.trim_matches('\n').to_string())
68 .collect();
69 let (header, text, html, css) = match parts.len() {
70 0 => (String::new(), String::new(), String::new(), String::new()),
71 1 => (
72 String::new(),
73 String::new(),
74 parts[0].clone(),
75 String::new(),
76 ),
77 2 => (
78 parts[0].clone(),
79 String::new(),
80 parts[1].clone(),
81 String::new(),
82 ),
83 3 => (
84 parts[0].clone(),
85 parts[1].clone(),
86 parts[2].clone(),
87 String::new(),
88 ),
89 _ => (
90 parts[0].clone(),
91 parts[1].clone(),
92 parts[2].clone(),
93 parts[3].clone(),
94 ),
95 };
96 let mut settings = BTreeMap::new();
97 for line in header.lines() {
98 let line = line.trim();
99 if line.is_empty() || line.starts_with('#') {
100 continue;
101 }
102 if let Some((key, value)) = line.split_once('=') {
103 let value = value
104 .trim()
105 .trim_matches('"')
106 .trim_matches('\'')
107 .to_string();
108 settings.insert(key.trim().to_string(), value);
109 }
110 }
111 Sections {
112 settings,
113 text,
114 html,
115 css,
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Template {
122 pub code: String,
123 pub subject: String,
124 pub description: String,
125 pub layout: Option<String>,
127 pub content_text: String,
128 pub content_html: String,
129 pub is_custom: bool,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct Layout {
136 pub code: String,
137 pub name: String,
138 pub content_text: String,
139 pub content_html: String,
140 pub content_css: String,
141 pub is_custom: bool,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct Partial {
147 pub code: String,
148 pub name: String,
149 pub content_text: String,
150 pub content_html: String,
151 pub is_custom: bool,
152}
153
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156pub struct Rendered {
157 pub subject: String,
158 pub text: String,
159 pub html: Option<String>,
160}
161
162#[derive(Iden)]
163pub(crate) enum MailTemplates {
164 Table,
165 Code,
166 Subject,
167 Description,
168 Layout,
169 ContentText,
170 ContentHtml,
171 IsCustom,
172}
173
174#[derive(Iden)]
175pub(crate) enum MailLayouts {
176 Table,
177 Code,
178 Name,
179 ContentText,
180 ContentHtml,
181 ContentCss,
182 IsCustom,
183}
184
185#[derive(Iden)]
186pub(crate) enum MailPartials {
187 Table,
188 Code,
189 Name,
190 ContentText,
191 ContentHtml,
192 IsCustom,
193}
194
195#[derive(Default)]
197struct Files {
198 templates: HashMap<&'static str, &'static str>,
199 layouts: HashMap<&'static str, &'static str>,
200 partials: HashMap<&'static str, &'static str>,
201}
202
203struct Inner {
204 db: Db,
205 cache: Cache,
206 files: RwLock<Files>,
207 brand_css: RwLock<String>,
208 app_name: RwLock<String>,
209}
210
211#[derive(Clone)]
213pub struct Templates {
214 inner: Arc<Inner>,
215}
216
217const TTL: Duration = Duration::from_secs(3600);
218
219impl Templates {
220 pub fn new(db: Db, cache: Cache) -> Self {
221 cache.allow(NAMESPACE);
222 Self {
223 inner: Arc::new(Inner {
224 db,
225 cache,
226 files: RwLock::new(Files::default()),
227 brand_css: RwLock::new(String::new()),
228 app_name: RwLock::new("Laterite".to_string()),
229 }),
230 }
231 }
232
233 pub fn set_app_name(&self, name: &str) {
235 *self
236 .inner
237 .app_name
238 .write()
239 .unwrap_or_else(|e| e.into_inner()) = name.to_string();
240 }
241
242 pub async fn set_brand_css(&self, css: &str) {
244 *self
245 .inner
246 .brand_css
247 .write()
248 .unwrap_or_else(|e| e.into_inner()) = css.to_string();
249 let _ = self.inner.cache.bump(NAMESPACE).await;
250 }
251
252 pub fn brand_css(&self) -> String {
253 self.inner
254 .brand_css
255 .read()
256 .unwrap_or_else(|e| e.into_inner())
257 .clone()
258 }
259
260 pub async fn seed(
264 &self,
265 templates: &[MailTemplateReg],
266 layouts: &[MailLayoutReg],
267 partials: &[MailPartialReg],
268 ) -> CoreResult<()> {
269 {
270 let mut files = self.inner.files.write().unwrap_or_else(|e| e.into_inner());
271 files.templates = templates.iter().map(|r| (r.code, r.file)).collect();
272 files.layouts = layouts.iter().map(|r| (r.code, r.file)).collect();
273 files.partials = partials.iter().map(|r| (r.code, r.file)).collect();
274 }
275 let db = &self.inner.db;
276 for reg in templates {
277 let row = template_from_file(reg.code, reg.file);
278 if find_template(db, reg.code)
279 .await?
280 .is_none_or(|t| !t.is_custom)
281 {
282 upsert_template(db, &row).await?;
283 }
284 }
285 for reg in layouts {
286 let row = layout_from_file(reg.code, reg.file);
287 if find_layout(db, reg.code)
288 .await?
289 .is_none_or(|l| !l.is_custom)
290 {
291 upsert_layout(db, &row).await?;
292 }
293 }
294 for reg in partials {
295 let row = partial_from_file(reg.code, reg.file);
296 if find_partial(db, reg.code)
297 .await?
298 .is_none_or(|p| !p.is_custom)
299 {
300 upsert_partial(db, &row).await?;
301 }
302 }
303 for t in list_templates(db).await? {
304 if !t.is_custom && !templates.iter().any(|r| r.code == t.code) {
305 delete_template(db, &t.code).await?;
306 }
307 }
308 for l in list_layouts(db).await? {
309 if !l.is_custom && !layouts.iter().any(|r| r.code == l.code) {
310 delete_layout(db, &l.code).await?;
311 }
312 }
313 for p in list_partials(db).await? {
314 if !p.is_custom && !partials.iter().any(|r| r.code == p.code) {
315 delete_partial(db, &p.code).await?;
316 }
317 }
318 let _ = self.inner.cache.bump(NAMESPACE).await;
319 Ok(())
320 }
321
322 pub async fn templates(&self) -> CoreResult<Vec<Template>> {
323 list_templates(&self.inner.db).await
324 }
325
326 pub async fn layouts(&self) -> CoreResult<Vec<Layout>> {
327 list_layouts(&self.inner.db).await
328 }
329
330 pub async fn partials(&self) -> CoreResult<Vec<Partial>> {
331 list_partials(&self.inner.db).await
332 }
333
334 pub async fn template(&self, code: &str) -> CoreResult<Option<Template>> {
335 find_template(&self.inner.db, code).await
336 }
337
338 pub async fn layout(&self, code: &str) -> CoreResult<Option<Layout>> {
339 find_layout(&self.inner.db, code).await
340 }
341
342 pub async fn partial(&self, code: &str) -> CoreResult<Option<Partial>> {
343 find_partial(&self.inner.db, code).await
344 }
345
346 pub async fn save_template(&self, template: &Template) -> CoreResult<()> {
349 let mut row = template.clone();
350 row.is_custom = true;
351 upsert_template(&self.inner.db, &row).await?;
352 self.bump().await;
353 Ok(())
354 }
355
356 pub async fn save_layout(&self, layout: &Layout) -> CoreResult<()> {
357 let mut row = layout.clone();
358 row.is_custom = true;
359 upsert_layout(&self.inner.db, &row).await?;
360 self.bump().await;
361 Ok(())
362 }
363
364 pub async fn save_partial(&self, partial: &Partial) -> CoreResult<()> {
365 let mut row = partial.clone();
366 row.is_custom = true;
367 upsert_partial(&self.inner.db, &row).await?;
368 self.bump().await;
369 Ok(())
370 }
371
372 pub async fn reset_template(&self, code: &str) -> CoreResult<bool> {
375 let file = self.file_of(|f| f.templates.get(code).copied());
376 let Some(file) = file else {
377 return Ok(false);
378 };
379 upsert_template(&self.inner.db, &template_from_file(code, file)).await?;
380 self.bump().await;
381 Ok(true)
382 }
383
384 pub async fn reset_layout(&self, code: &str) -> CoreResult<bool> {
385 let file = self.file_of(|f| f.layouts.get(code).copied());
386 let Some(file) = file else {
387 return Ok(false);
388 };
389 upsert_layout(&self.inner.db, &layout_from_file(code, file)).await?;
390 self.bump().await;
391 Ok(true)
392 }
393
394 pub async fn reset_partial(&self, code: &str) -> CoreResult<bool> {
395 let file = self.file_of(|f| f.partials.get(code).copied());
396 let Some(file) = file else {
397 return Ok(false);
398 };
399 upsert_partial(&self.inner.db, &partial_from_file(code, file)).await?;
400 self.bump().await;
401 Ok(true)
402 }
403
404 pub async fn delete_template(&self, code: &str) -> CoreResult<bool> {
407 if self.file_of(|f| f.templates.get(code).copied()).is_some() {
408 return self.reset_template(code).await;
409 }
410 delete_template(&self.inner.db, code).await?;
411 self.bump().await;
412 Ok(true)
413 }
414
415 pub async fn delete_layout(&self, code: &str) -> CoreResult<bool> {
416 if self.file_of(|f| f.layouts.get(code).copied()).is_some() {
417 return self.reset_layout(code).await;
418 }
419 delete_layout(&self.inner.db, code).await?;
420 self.bump().await;
421 Ok(true)
422 }
423
424 pub async fn delete_partial(&self, code: &str) -> CoreResult<bool> {
425 if self.file_of(|f| f.partials.get(code).copied()).is_some() {
426 return self.reset_partial(code).await;
427 }
428 delete_partial(&self.inner.db, code).await?;
429 self.bump().await;
430 Ok(true)
431 }
432
433 pub fn has_template_file(&self, code: &str) -> bool {
435 self.file_of(|f| f.templates.get(code).copied()).is_some()
436 }
437
438 pub fn has_layout_file(&self, code: &str) -> bool {
439 self.file_of(|f| f.layouts.get(code).copied()).is_some()
440 }
441
442 pub fn has_partial_file(&self, code: &str) -> bool {
443 self.file_of(|f| f.partials.get(code).copied()).is_some()
444 }
445
446 fn file_of(&self, pick: impl FnOnce(&Files) -> Option<&'static str>) -> Option<&'static str> {
447 let files = self.inner.files.read().unwrap_or_else(|e| e.into_inner());
448 pick(&files)
449 }
450
451 async fn bump(&self) {
452 let _ = self.inner.cache.bump(NAMESPACE).await;
453 }
454
455 pub async fn render(&self, code: &str, vars: &serde_json::Value) -> CoreResult<Rendered> {
460 let bundle = self.bundle(code).await?;
461 self.render_bundle(&bundle, vars)
462 }
463
464 pub async fn render_draft(
466 &self,
467 template: &Template,
468 vars: &serde_json::Value,
469 ) -> CoreResult<Rendered> {
470 let mut bundle = self.pieces().await?;
471 bundle.template = template.clone();
472 self.render_bundle(&bundle, vars)
473 }
474
475 async fn pieces(&self) -> CoreResult<Bundle> {
476 let key = "laterite.mail::pieces";
477 let pieces: Pieces = self
478 .inner
479 .cache
480 .remember(key, TTL, &[NAMESPACE], || async {
481 Ok(Pieces {
482 layouts: list_layouts(&self.inner.db).await?,
483 partials: list_partials(&self.inner.db).await?,
484 })
485 })
486 .await?;
487 Ok(Bundle {
488 template: Template {
489 code: String::new(),
490 subject: String::new(),
491 description: String::new(),
492 layout: None,
493 content_text: String::new(),
494 content_html: String::new(),
495 is_custom: false,
496 },
497 layouts: pieces.layouts,
498 partials: pieces.partials,
499 })
500 }
501
502 async fn bundle(&self, code: &str) -> CoreResult<Bundle> {
503 let key = format!("laterite.mail::template:{code}");
504 let template: Option<Template> = self
505 .inner
506 .cache
507 .remember(&key, TTL, &[NAMESPACE], || {
508 find_template(&self.inner.db, code)
509 })
510 .await?;
511 let template =
512 template.ok_or_else(|| CoreError::NotFound(format!("mail template `{code}`")))?;
513 let mut bundle = self.pieces().await?;
514 bundle.template = template;
515 Ok(bundle)
516 }
517
518 fn render_bundle(&self, bundle: &Bundle, vars: &serde_json::Value) -> CoreResult<Rendered> {
519 let app_name = self
520 .inner
521 .app_name
522 .read()
523 .unwrap_or_else(|e| e.into_inner())
524 .clone();
525 let brand_css = self.brand_css();
526 let layout = bundle
527 .template
528 .layout
529 .as_deref()
530 .and_then(|code| bundle.layouts.iter().find(|l| l.code == code));
531 let mut base = minijinja::context! { app_name => app_name.clone() };
532 if let serde_json::Value::Object(_) = vars {
533 base = minijinja::context! { ..Value::from(minijinja::value::Serde(vars)), ..base };
534 }
535
536 let mut html_env = environment(&bundle.partials, false);
538 html_env.add_template_owned("subject".to_string(), bundle.template.subject.clone())?;
539 html_env.add_template_owned("body".to_string(), bundle.template.content_html.clone())?;
540 if let Some(layout) = layout {
541 html_env.add_template_owned("layout".to_string(), layout.content_html.clone())?;
542 html_env.add_template_owned("css".to_string(), layout.content_css.clone())?;
543 }
544 let subject = html_env.get_template("subject")?.render(&base)?;
545 let body_source = html_env.get_template("body")?.render(&base)?;
546 let body_html = crate::markdown::render(&body_source);
547 let html = match layout {
548 Some(_) => {
549 let brand = brand_values(&brand_css);
550 let css = html_env
551 .get_template("css")?
552 .render(minijinja::context! { brand => brand, ..base.clone() })?;
553 let page = html_env
554 .get_template("layout")?
555 .render(minijinja::context! {
556 content => Value::from_safe_string(body_html),
557 css => Value::from_safe_string(css.clone()),
558 brand_css => Value::from_safe_string(brand_css.clone()),
559 subject => subject.clone(),
560 ..base.clone()
561 })?;
562 let inliner = css_inline::CSSInliner::options()
563 .load_remote_stylesheets(false)
564 .extra_css(Some(std::borrow::Cow::Owned(format!("{brand_css}\n{css}"))))
565 .build();
566 inliner.inline(&page).map_err(|e| {
567 CoreError::Internal(format!("mail css could not be inlined: {e}"))
568 })?
569 }
570 None => body_html,
571 };
572
573 let mut text_env = environment(&bundle.partials, true);
575 text_env.add_template_owned("body".to_string(), bundle.template.content_text.clone())?;
576 if let Some(layout) = layout {
577 text_env.add_template_owned("layout".to_string(), layout.content_text.clone())?;
578 }
579 let body_text = if bundle.template.content_text.trim().is_empty() {
580 text_of(&body_source)
581 } else {
582 text_env.get_template("body")?.render(&base)?
583 };
584 let text = match layout.filter(|l| !l.content_text.trim().is_empty()) {
585 Some(_) => text_env
586 .get_template("layout")?
587 .render(minijinja::context! {
588 content => body_text,
589 subject => subject.clone(),
590 ..base
591 })?,
592 None => body_text,
593 };
594 Ok(Rendered {
595 subject: subject.trim().to_string(),
596 text: tidy_text(&text),
597 html: Some(html),
598 })
599 }
600}
601
602#[derive(Clone, Serialize, Deserialize)]
603struct Pieces {
604 layouts: Vec<Layout>,
605 partials: Vec<Partial>,
606}
607
608struct Bundle {
609 template: Template,
610 layouts: Vec<Layout>,
611 partials: Vec<Partial>,
612}
613
614impl From<minijinja::Error> for CoreError {
615 fn from(e: minijinja::Error) -> Self {
616 CoreError::Internal(format!("mail template: {e}"))
617 }
618}
619
620fn environment(partials: &[Partial], text: bool) -> Environment<'static> {
623 let mut env = Environment::new();
624 env.set_undefined_behavior(UndefinedBehavior::Lenient);
625 for partial in partials {
626 let source = if text {
627 partial.content_text.clone()
628 } else {
629 partial.content_html.clone()
630 };
631 let _ = env.add_template_owned(format!("partial:{}", partial.code), source);
632 }
633 env.add_filter("markdown", |value: String| -> Value {
634 Value::from_safe_string(crate::markdown::render(&value))
635 });
636 env.add_function(
637 "partial",
638 |state: &State, name: String, kwargs: Kwargs| -> Result<Value, minijinja::Error> {
639 let template = state.env().get_template(&format!("partial:{name}"))?;
640 let mut ctx: BTreeMap<String, Value> = BTreeMap::new();
641 for key in kwargs.args() {
642 ctx.insert(key.to_string(), kwargs.get(key)?);
643 }
644 let parent = state.lookup("app_name");
646 if let Some(app_name) = parent {
647 ctx.entry("app_name".to_string()).or_insert(app_name);
648 }
649 kwargs.assert_all_used()?;
650 let out = template.render(Value::from_object(ctx))?;
651 Ok(Value::from_safe_string(out))
652 },
653 );
654 env
655}
656
657fn brand_values(css: &str) -> BTreeMap<String, String> {
660 let mut out = BTreeMap::new();
661 for part in css.split(';') {
662 let Some(at) = part.find("--mail-") else {
663 continue;
664 };
665 let rest = &part[at + "--mail-".len()..];
666 {
667 if let Some((key, value)) = rest.split_once(':') {
668 out.insert(
669 key.trim().replace('-', "_"),
670 value.trim().trim_end_matches('}').trim().to_string(),
671 );
672 }
673 }
674 }
675 out
676}
677
678fn text_of(source: &str) -> String {
681 let mut out = String::with_capacity(source.len());
682 let mut in_tag = false;
683 for ch in source.chars() {
684 match ch {
685 '<' => in_tag = true,
686 '>' => in_tag = false,
687 _ if !in_tag => out.push(ch),
688 _ => {}
689 }
690 }
691 out.replace("&", "&")
692 .replace("<", "<")
693 .replace(">", ">")
694 .replace(""", "\"")
695 .replace("'", "'")
696}
697
698fn tidy_text(text: &str) -> String {
699 let mut out = String::with_capacity(text.len());
700 let mut blank = 0;
701 for line in text.lines() {
702 let line = line.trim_end();
703 if line.trim().is_empty() {
704 blank += 1;
705 if blank > 1 {
706 continue;
707 }
708 } else {
709 blank = 0;
710 }
711 out.push_str(line);
712 out.push('\n');
713 }
714 out.trim().to_string()
715}
716
717fn template_from_file(code: &str, file: &str) -> Template {
718 let s = parse_file(file);
719 Template {
720 code: code.to_string(),
721 subject: s.settings.get("subject").cloned().unwrap_or_default(),
722 description: s.settings.get("description").cloned().unwrap_or_default(),
723 layout: s
724 .settings
725 .get("layout")
726 .cloned()
727 .filter(|l| !l.is_empty())
728 .or_else(|| Some("default".to_string())),
729 content_text: s.text,
730 content_html: s.html,
731 is_custom: false,
732 }
733}
734
735fn layout_from_file(code: &str, file: &str) -> Layout {
736 let s = parse_file(file);
737 Layout {
738 code: code.to_string(),
739 name: s
740 .settings
741 .get("name")
742 .cloned()
743 .unwrap_or_else(|| code.to_string()),
744 content_text: s.text,
745 content_html: s.html,
746 content_css: s.css,
747 is_custom: false,
748 }
749}
750
751fn partial_from_file(code: &str, file: &str) -> Partial {
752 let s = parse_file(file);
753 Partial {
754 code: code.to_string(),
755 name: s
756 .settings
757 .get("name")
758 .cloned()
759 .unwrap_or_else(|| code.to_string()),
760 content_text: s.text,
761 content_html: s.html,
762 is_custom: false,
763 }
764}
765
766fn template_of(r: &sqlx::any::AnyRow) -> CoreResult<Template> {
769 Ok(Template {
770 code: r.get_text("code")?,
771 subject: r.get_text("subject")?,
772 description: r.get_text("description")?,
773 layout: r.get_text_opt("layout")?.filter(|l| !l.is_empty()),
774 content_text: r.get_text("content_text")?,
775 content_html: r.get_text("content_html")?,
776 is_custom: r.get_bool("is_custom")?,
777 })
778}
779
780async fn list_templates(db: &Db) -> CoreResult<Vec<Template>> {
781 let (sql, values) = build(
782 db.backend,
783 Query::select()
784 .columns([
785 MailTemplates::Code,
786 MailTemplates::Subject,
787 MailTemplates::Description,
788 MailTemplates::Layout,
789 MailTemplates::ContentText,
790 MailTemplates::ContentHtml,
791 MailTemplates::IsCustom,
792 ])
793 .from(MailTemplates::Table)
794 .order_by(MailTemplates::Code, Order::Asc)
795 .to_owned(),
796 );
797 let rows = bind_values(sqlx::query(&sql), values)
798 .fetch_all(&db.pool)
799 .await?;
800 rows.iter().map(template_of).collect()
801}
802
803async fn find_template(db: &Db, code: &str) -> CoreResult<Option<Template>> {
804 let (sql, values) = build(
805 db.backend,
806 Query::select()
807 .columns([
808 MailTemplates::Code,
809 MailTemplates::Subject,
810 MailTemplates::Description,
811 MailTemplates::Layout,
812 MailTemplates::ContentText,
813 MailTemplates::ContentHtml,
814 MailTemplates::IsCustom,
815 ])
816 .from(MailTemplates::Table)
817 .and_where(Expr::col(MailTemplates::Code).eq(code))
818 .to_owned(),
819 );
820 let row = bind_values(sqlx::query(&sql), values)
821 .fetch_optional(&db.pool)
822 .await?;
823 row.as_ref().map(template_of).transpose()
824}
825
826async fn upsert_template(db: &Db, t: &Template) -> CoreResult<()> {
827 let (sql, values) = build(
828 db.backend,
829 Query::insert()
830 .into_table(MailTemplates::Table)
831 .columns([
832 MailTemplates::Code,
833 MailTemplates::Subject,
834 MailTemplates::Description,
835 MailTemplates::Layout,
836 MailTemplates::ContentText,
837 MailTemplates::ContentHtml,
838 MailTemplates::IsCustom,
839 ])
840 .values_panic([
841 t.code.as_str().into(),
842 t.subject.as_str().into(),
843 t.description.as_str().into(),
844 t.layout.clone().into(),
845 t.content_text.as_str().into(),
846 t.content_html.as_str().into(),
847 t.is_custom.into(),
848 ])
849 .on_conflict(
850 OnConflict::column(MailTemplates::Code)
851 .update_columns([
852 MailTemplates::Subject,
853 MailTemplates::Description,
854 MailTemplates::Layout,
855 MailTemplates::ContentText,
856 MailTemplates::ContentHtml,
857 MailTemplates::IsCustom,
858 ])
859 .to_owned(),
860 )
861 .to_owned(),
862 );
863 bind_values(sqlx::query(&sql), values)
864 .execute(&db.pool)
865 .await?;
866 Ok(())
867}
868
869async fn delete_template(db: &Db, code: &str) -> CoreResult<()> {
870 let (sql, values) = build(
871 db.backend,
872 Query::delete()
873 .from_table(MailTemplates::Table)
874 .and_where(Expr::col(MailTemplates::Code).eq(code))
875 .to_owned(),
876 );
877 bind_values(sqlx::query(&sql), values)
878 .execute(&db.pool)
879 .await?;
880 Ok(())
881}
882
883fn layout_of(r: &sqlx::any::AnyRow) -> CoreResult<Layout> {
884 Ok(Layout {
885 code: r.get_text("code")?,
886 name: r.get_text("name")?,
887 content_text: r.get_text("content_text")?,
888 content_html: r.get_text("content_html")?,
889 content_css: r.get_text("content_css")?,
890 is_custom: r.get_bool("is_custom")?,
891 })
892}
893
894async fn list_layouts(db: &Db) -> CoreResult<Vec<Layout>> {
895 let (sql, values) = build(
896 db.backend,
897 Query::select()
898 .columns([
899 MailLayouts::Code,
900 MailLayouts::Name,
901 MailLayouts::ContentText,
902 MailLayouts::ContentHtml,
903 MailLayouts::ContentCss,
904 MailLayouts::IsCustom,
905 ])
906 .from(MailLayouts::Table)
907 .order_by(MailLayouts::Code, Order::Asc)
908 .to_owned(),
909 );
910 let rows = bind_values(sqlx::query(&sql), values)
911 .fetch_all(&db.pool)
912 .await?;
913 rows.iter().map(layout_of).collect()
914}
915
916async fn find_layout(db: &Db, code: &str) -> CoreResult<Option<Layout>> {
917 Ok(list_layouts(db).await?.into_iter().find(|l| l.code == code))
918}
919
920async fn upsert_layout(db: &Db, l: &Layout) -> CoreResult<()> {
921 let (sql, values) = build(
922 db.backend,
923 Query::insert()
924 .into_table(MailLayouts::Table)
925 .columns([
926 MailLayouts::Code,
927 MailLayouts::Name,
928 MailLayouts::ContentText,
929 MailLayouts::ContentHtml,
930 MailLayouts::ContentCss,
931 MailLayouts::IsCustom,
932 ])
933 .values_panic([
934 l.code.as_str().into(),
935 l.name.as_str().into(),
936 l.content_text.as_str().into(),
937 l.content_html.as_str().into(),
938 l.content_css.as_str().into(),
939 l.is_custom.into(),
940 ])
941 .on_conflict(
942 OnConflict::column(MailLayouts::Code)
943 .update_columns([
944 MailLayouts::Name,
945 MailLayouts::ContentText,
946 MailLayouts::ContentHtml,
947 MailLayouts::ContentCss,
948 MailLayouts::IsCustom,
949 ])
950 .to_owned(),
951 )
952 .to_owned(),
953 );
954 bind_values(sqlx::query(&sql), values)
955 .execute(&db.pool)
956 .await?;
957 Ok(())
958}
959
960async fn delete_layout(db: &Db, code: &str) -> CoreResult<()> {
961 let (sql, values) = build(
962 db.backend,
963 Query::delete()
964 .from_table(MailLayouts::Table)
965 .and_where(Expr::col(MailLayouts::Code).eq(code))
966 .to_owned(),
967 );
968 bind_values(sqlx::query(&sql), values)
969 .execute(&db.pool)
970 .await?;
971 Ok(())
972}
973
974fn partial_of(r: &sqlx::any::AnyRow) -> CoreResult<Partial> {
975 Ok(Partial {
976 code: r.get_text("code")?,
977 name: r.get_text("name")?,
978 content_text: r.get_text("content_text")?,
979 content_html: r.get_text("content_html")?,
980 is_custom: r.get_bool("is_custom")?,
981 })
982}
983
984async fn list_partials(db: &Db) -> CoreResult<Vec<Partial>> {
985 let (sql, values) = build(
986 db.backend,
987 Query::select()
988 .columns([
989 MailPartials::Code,
990 MailPartials::Name,
991 MailPartials::ContentText,
992 MailPartials::ContentHtml,
993 MailPartials::IsCustom,
994 ])
995 .from(MailPartials::Table)
996 .order_by(MailPartials::Code, Order::Asc)
997 .to_owned(),
998 );
999 let rows = bind_values(sqlx::query(&sql), values)
1000 .fetch_all(&db.pool)
1001 .await?;
1002 rows.iter().map(partial_of).collect()
1003}
1004
1005async fn find_partial(db: &Db, code: &str) -> CoreResult<Option<Partial>> {
1006 Ok(list_partials(db)
1007 .await?
1008 .into_iter()
1009 .find(|p| p.code == code))
1010}
1011
1012async fn upsert_partial(db: &Db, p: &Partial) -> CoreResult<()> {
1013 let (sql, values) = build(
1014 db.backend,
1015 Query::insert()
1016 .into_table(MailPartials::Table)
1017 .columns([
1018 MailPartials::Code,
1019 MailPartials::Name,
1020 MailPartials::ContentText,
1021 MailPartials::ContentHtml,
1022 MailPartials::IsCustom,
1023 ])
1024 .values_panic([
1025 p.code.as_str().into(),
1026 p.name.as_str().into(),
1027 p.content_text.as_str().into(),
1028 p.content_html.as_str().into(),
1029 p.is_custom.into(),
1030 ])
1031 .on_conflict(
1032 OnConflict::column(MailPartials::Code)
1033 .update_columns([
1034 MailPartials::Name,
1035 MailPartials::ContentText,
1036 MailPartials::ContentHtml,
1037 MailPartials::IsCustom,
1038 ])
1039 .to_owned(),
1040 )
1041 .to_owned(),
1042 );
1043 bind_values(sqlx::query(&sql), values)
1044 .execute(&db.pool)
1045 .await?;
1046 Ok(())
1047}
1048
1049async fn delete_partial(db: &Db, code: &str) -> CoreResult<()> {
1050 let (sql, values) = build(
1051 db.backend,
1052 Query::delete()
1053 .from_table(MailPartials::Table)
1054 .and_where(Expr::col(MailPartials::Code).eq(code))
1055 .to_owned(),
1056 );
1057 bind_values(sqlx::query(&sql), values)
1058 .execute(&db.pool)
1059 .await?;
1060 Ok(())
1061}
1062
1063#[cfg(test)]
1064mod tests {
1065 use super::*;
1066
1067 #[test]
1068 fn a_file_splits_into_header_and_sections() {
1069 let s = parse_file("subject = \"Hi {{ name }}\"\nlayout = plain\n==\nplain {{ name }}\n==\n# Hi {{ name }}\n");
1070 assert_eq!(s.settings["subject"], "Hi {{ name }}");
1071 assert_eq!(s.settings["layout"], "plain");
1072 assert_eq!(s.text, "plain {{ name }}");
1073 assert_eq!(s.html, "# Hi {{ name }}");
1074 let two = parse_file("name = \"Button\"\n==\n<a>{{ body }}</a>");
1075 assert_eq!(two.settings["name"], "Button");
1076 assert_eq!(two.html, "<a>{{ body }}</a>");
1077 assert!(two.text.is_empty());
1078 let four = parse_file(
1079 "name = L\n==\n{{ content }}\n==\n<html>{{ content }}</html>\n==\nbody { color: red }",
1080 );
1081 assert_eq!(four.css, "body { color: red }");
1082 }
1083
1084 #[test]
1085 fn brand_properties_become_a_map_and_text_loses_its_tags() {
1086 let map = brand_values(":root { --mail-button-bg: #c0392b; --mail-text-color: #222; }");
1087 assert_eq!(map["button_bg"], "#c0392b");
1088 assert_eq!(map["text_color"], "#222");
1089 assert_eq!(text_of("<p>Hi & bye</p>"), "Hi & bye");
1090 assert_eq!(tidy_text("a\n\n\n\nb \n"), "a\n\nb");
1091 }
1092}