1#![forbid(unsafe_code)]
50
51mod theme;
52
53use std::fmt::Write as _;
54
55pub use theme::{
56 DATA_THEME, DATA_THEME_OVERRIDE, MailTheme, Palette, THEME_CONFIG_KEY, attach_theme,
57 theme_for_template,
58};
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Email {
63 pub subject: String,
65 pub preheader: String,
68 pub text: String,
70 pub html: String,
72}
73
74impl From<Email> for cratefield_core::Rendered {
75 fn from(email: Email) -> Self {
76 Self {
77 subject: email.subject,
78 html: email.html,
79 text: email.text,
80 }
81 }
82}
83
84pub fn escape(s: &str) -> String {
87 let mut out = String::with_capacity(s.len());
88 for c in s.chars() {
89 match c {
90 '&' => out.push_str("&"),
91 '<' => out.push_str("<"),
92 '>' => out.push_str(">"),
93 '"' => out.push_str("""),
94 '\'' => out.push_str("'"),
95 c if c.is_control() => out.push(' '),
96 c => out.push(c),
97 }
98 }
99 out
100}
101
102pub fn plain(s: &str) -> String {
105 s.chars()
106 .map(|c| if c.is_control() { ' ' } else { c })
107 .collect()
108}
109
110pub fn is_linkable(url: &str) -> bool {
114 let lower = url.trim_start().to_ascii_lowercase();
115 lower.starts_with("https://") || lower.starts_with("http://") || lower.starts_with("mailto:")
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum Dir {
121 #[default]
123 Ltr,
124 Rtl,
126}
127
128impl Dir {
129 fn as_str(self) -> &'static str {
130 match self {
131 Self::Ltr => "ltr",
132 Self::Rtl => "rtl",
133 }
134 }
135}
136
137#[derive(Debug, Clone)]
138struct Fact {
139 label: String,
140 value: String,
141 code: bool,
142}
143
144#[derive(Debug, Clone)]
146#[must_use]
147pub struct Message {
148 subject: String,
149 heading: String,
150 preheader: String,
151 lang: String,
152 dir: Dir,
153 paragraphs: Vec<String>,
154 facts: Vec<Fact>,
155 button: Option<(String, String)>,
156 fallback_link: bool,
157 link_intro: String,
158 code: Option<(String, String)>,
159 notes: Vec<String>,
160 recipient: Option<String>,
161 why: Option<String>,
162 footer_links: Vec<(String, String)>,
163}
164
165impl Message {
166 pub fn new(subject: impl Into<String>, heading: impl Into<String>) -> Self {
168 Self {
169 subject: subject.into(),
170 heading: heading.into(),
171 preheader: String::new(),
172 lang: "en".to_owned(),
173 dir: Dir::Ltr,
174 paragraphs: Vec::new(),
175 facts: Vec::new(),
176 button: None,
177 fallback_link: false,
178 link_intro: "Or paste this link into your browser:".to_owned(),
179 code: None,
180 notes: Vec::new(),
181 recipient: None,
182 why: None,
183 footer_links: Vec::new(),
184 }
185 }
186
187 pub fn preheader(mut self, text: impl Into<String>) -> Self {
189 self.preheader = text.into();
190 self
191 }
192
193 pub fn lang(mut self, tag: impl Into<String>) -> Self {
195 self.lang = tag.into();
196 self
197 }
198
199 pub fn dir(mut self, dir: Dir) -> Self {
201 self.dir = dir;
202 self
203 }
204
205 pub fn paragraph(mut self, text: impl Into<String>) -> Self {
207 self.paragraphs.push(text.into());
208 self
209 }
210
211 pub fn fact(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
213 self.facts.push(Fact {
214 label: label.into(),
215 value: value.into(),
216 code: false,
217 });
218 self
219 }
220
221 pub fn code_fact(mut self, label: impl Into<String>, value: impl Into<String>) -> Self {
224 self.facts.push(Fact {
225 label: label.into(),
226 value: value.into(),
227 code: true,
228 });
229 self
230 }
231
232 pub fn button(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
234 self.button = Some((label.into(), url.into()));
235 self
236 }
237
238 pub fn fallback_link(mut self) -> Self {
241 self.fallback_link = true;
242 self
243 }
244
245 pub fn link_intro(mut self, text: impl Into<String>) -> Self {
248 self.link_intro = text.into();
249 self
250 }
251
252 pub fn code(mut self, intro: impl Into<String>, value: impl Into<String>) -> Self {
254 self.code = Some((intro.into(), value.into()));
255 self
256 }
257
258 pub fn note(mut self, text: impl Into<String>) -> Self {
261 self.notes.push(text.into());
262 self
263 }
264
265 pub fn recipient(mut self, address: impl Into<String>) -> Self {
267 self.recipient = Some(address.into());
268 self
269 }
270
271 pub fn why(mut self, reason: impl Into<String>) -> Self {
273 self.why = Some(reason.into());
274 self
275 }
276
277 pub fn footer_link(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
279 self.footer_links.push((label.into(), url.into()));
280 self
281 }
282
283 pub fn render(&self, theme: &MailTheme) -> Email {
285 let theme = theme.sanitized();
286 Email {
287 subject: plain(&self.subject),
288 preheader: plain(&self.preheader),
289 text: self.render_text(&theme),
290 html: self.render_html(&theme),
291 }
292 }
293
294 fn footer_sentence(&self) -> Option<String> {
295 match (&self.recipient, &self.why) {
296 (Some(to), Some(why)) => Some(format!("Sent to {to} because {why}.")),
297 (Some(to), None) => Some(format!("This mail was sent to {to}.")),
298 (None, Some(why)) => Some(format!("You are receiving this because {why}.")),
299 (None, None) => None,
300 }
301 }
302
303 fn render_text(&self, theme: &MailTheme) -> String {
304 let mut t = String::new();
305 let _ = writeln!(t, "{}\n", plain(&self.heading));
306 for p in &self.paragraphs {
307 let _ = writeln!(t, "{}\n", plain(p));
308 }
309 if !self.facts.is_empty() {
310 for f in &self.facts {
311 let _ = writeln!(t, "{}: {}", plain(&f.label), plain(&f.value));
312 }
313 t.push('\n');
314 }
315 if let Some((label, url)) = &self.button {
316 let _ = writeln!(t, "{}:\n{}\n", plain(label), plain(url));
317 }
318 if let Some((intro, value)) = &self.code {
319 let _ = writeln!(t, "{}\n{}\n", plain(intro), plain(value));
320 }
321 for n in &self.notes {
322 let _ = writeln!(t, "{}\n", plain(n));
323 }
324 t.push_str("--\n");
325 if let Some(sentence) = self.footer_sentence() {
326 let _ = writeln!(t, "{}", plain(&sentence));
327 }
328 for (label, url) in &self.footer_links {
329 let _ = writeln!(t, "{}:\n{}", plain(label), plain(url));
330 }
331 for line in &theme.footer {
332 let _ = writeln!(t, "{}", plain(line));
333 }
334 let mut sign_off = Vec::new();
335 if !theme.brand_name.is_empty() {
336 sign_off.push(plain(&theme.brand_name));
337 }
338 if !theme.site_url.is_empty() {
339 sign_off.push(plain(&theme.site_url));
340 }
341 if let Some(contact) = &theme.contact {
342 sign_off.push(plain(contact));
343 }
344 if !sign_off.is_empty() {
345 let _ = writeln!(t, "{}", sign_off.join(" · "));
346 }
347 t
348 }
349
350 #[allow(clippy::too_many_lines, reason = "one email layout, top to bottom")]
351 fn render_html(&self, theme: &MailTheme) -> String {
352 let e = escape;
353 let l = &theme.light;
354 let font = &theme.font;
355 let mono = &theme.mono;
356 let mut h = String::with_capacity(8 * 1024);
357 let _ = write!(
358 h,
359 "<!doctype html>\
360<html lang=\"{lang}\" dir=\"{dir}\" xmlns=\"http://www.w3.org/1999/xhtml\">\
361<head>\
362<meta charset=\"utf-8\">\
363<meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
364<meta name=\"x-apple-disable-message-reformatting\">\
365<meta name=\"format-detection\" content=\"telephone=no,address=no,email=no,date=no,url=no\">\
366<meta name=\"color-scheme\" content=\"light dark\">\
367<meta name=\"supported-color-schemes\" content=\"light dark\">\
368<title>{title}</title>\
369<style>{style}</style>\
370</head>\
371<body class=\"cf-bg\" style=\"margin:0;padding:0;background:{bg};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%\">",
372 lang = e(&self.lang),
373 dir = self.dir.as_str(),
374 title = e(&self.subject),
375 style = head_style(theme),
376 bg = l.bg,
377 );
378 if !self.preheader.is_empty() {
379 let _ = write!(
381 h,
382 "<div style=\"display:none;max-height:0;max-width:0;overflow:hidden;opacity:0;mso-hide:all;\
383font-size:1px;line-height:1px;color:{bg}\">{}{}</div>",
384 e(&self.preheader),
385 "͏‌ ".repeat(60),
386 bg = l.bg,
387 );
388 }
389 let _ = write!(
390 h,
391 "<table role=\"presentation\" class=\"cf-bg\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" \
392style=\"background:{bg};border-collapse:collapse\"><tr>\
393<td class=\"cf-outer\" align=\"center\" style=\"padding:32px 16px\">\
394<!--[if mso]><table role=\"presentation\" width=\"600\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr><td><![endif]-->\
395<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" \
396style=\"max-width:600px;border-collapse:separate\">",
397 bg = l.bg,
398 );
399 let banded = Self::write_brand_row(&mut h, theme);
400 let card_edges = if banded {
403 format!(
404 "border:1px solid {border};border-top:0;border-radius:0 0 {r}px {r}px",
405 border = l.border,
406 r = theme.radius,
407 )
408 } else {
409 format!(
410 "border:1px solid {border};border-radius:{r}px",
411 border = l.border,
412 r = theme.radius,
413 )
414 };
415 let _ = write!(
416 h,
417 "<tr><td class=\"cf-card\" bgcolor=\"{card}\" \
418style=\"background:{card};{card_edges};padding:36px 40px;font-family:{font}\">\
419<h1 class=\"cf-h1 cf-ink\" style=\"margin:0 0 16px;font-family:{display};font-size:26px;line-height:32px;font-weight:700;letter-spacing:-0.4px;color:{ink}\">{heading}</h1>",
420 card = l.card,
421 display = theme.display_font,
422 ink = l.ink,
423 heading = e(&self.heading),
424 );
425 for p in &self.paragraphs {
426 let _ = write!(
427 h,
428 "<p class=\"cf-text\" style=\"margin:0 0 20px;font-family:{font};font-size:16px;line-height:24px;color:{text}\">{}</p>",
429 e(p),
430 text = l.text,
431 );
432 }
433 if !self.facts.is_empty() {
434 h.push_str(
435 "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"margin:0 0 24px;border-collapse:collapse\">",
436 );
437 for f in &self.facts {
438 let value = if f.code {
439 code_span(theme, &f.value)
440 } else {
441 e(&f.value)
442 };
443 let _ = write!(
444 h,
445 "<tr>\
446<td class=\"cf-fact-l cf-mut\" width=\"132\" valign=\"top\" style=\"width:132px;padding:6px 12px 6px 0;font-family:{font};font-size:14px;line-height:22px;color:{muted}\">{label}</td>\
447<td class=\"cf-fact-v cf-ink\" valign=\"top\" style=\"padding:6px 0;font-family:{font};font-size:15px;line-height:22px;color:{ink};word-break:break-word\">{value}</td>\
448</tr>",
449 label = e(&f.label),
450 muted = l.muted,
451 ink = l.ink,
452 );
453 }
454 h.push_str("</table>");
455 }
456 if let Some((label, url)) = &self.button {
457 if is_linkable(url) {
458 let _ = write!(
459 h,
460 "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\" style=\"margin:4px 0 24px\"><tr>\
461<td class=\"cf-btn\" bgcolor=\"{button}\" style=\"border-radius:{r}px;background:{button}\">\
462<a class=\"cf-btn-a\" href=\"{url}\" target=\"_blank\" rel=\"noopener\" style=\"display:inline-block;padding:14px 24px;font-family:{font};font-size:16px;line-height:20px;font-weight:700;color:{button_text};text-decoration:none;border-radius:{r}px\">{label}</a>\
463</td></tr></table>",
464 button = l.button,
465 button_text = l.button_text,
466 r = theme.button_radius,
467 url = e(url),
468 label = e(label),
469 );
470 }
471 if self.fallback_link || !is_linkable(url) {
472 let shown = if is_linkable(url) {
473 format!(
474 "<a class=\"cf-link\" href=\"{u}\" style=\"color:{fg};background:{bg};padding:2px 6px;border-radius:4px;\
477-webkit-box-decoration-break:clone;box-decoration-break:clone;word-break:break-all;line-height:26px;text-decoration:underline\">{u}</a>",
478 u = e(url),
479 fg = marked_link_ink(&l.accent, &l.button, &l.button_text),
480 bg = l.button,
481 )
482 } else {
483 format!(
484 "<span class=\"cf-ink\" style=\"color:{}\">{}</span>",
485 l.ink,
486 e(url)
487 )
488 };
489 let _ = write!(
490 h,
491 "<p class=\"cf-mut\" style=\"margin:0 0 6px;font-family:{font};font-size:14px;line-height:20px;color:{muted}\">{intro}</p>\
492<p style=\"margin:0 0 24px;font-family:{mono};font-size:13px;line-height:20px;word-break:break-all;overflow-wrap:anywhere\">{shown}</p>",
493 intro = e(if is_linkable(url) {
494 &self.link_intro
495 } else {
496 label
497 }),
498 muted = l.muted,
499 );
500 }
501 }
502 if let Some((intro, value)) = &self.code {
503 let _ = write!(
504 h,
505 "<p class=\"cf-mut\" style=\"margin:0 0 6px;font-family:{font};font-size:14px;line-height:20px;color:{muted}\">{intro}</p>\
506<p style=\"margin:0 0 24px\">{code}</p>",
507 intro = e(intro),
508 code = code_span(theme, value),
509 muted = l.muted,
510 );
511 }
512 if !self.notes.is_empty() {
513 let _ = write!(
514 h,
515 "<table role=\"presentation\" width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr>\
516<td class=\"cf-rule\" style=\"border-top:1px solid {border};padding-top:20px\">",
517 border = l.border,
518 );
519 for n in &self.notes {
520 let _ = write!(
521 h,
522 "<p class=\"cf-mut\" style=\"margin:0 0 8px;font-family:{font};font-size:14px;line-height:21px;color:{muted}\">{}</p>",
523 e(n),
524 muted = l.muted,
525 );
526 }
527 h.push_str("</td></tr></table>");
528 }
529 h.push_str("</td></tr>");
530 self.write_footer(&mut h, theme);
531 h.push_str(
532 "</table><!--[if mso]></td></tr></table><![endif]--></td></tr></table></body></html>\n",
533 );
534 [
538 "</head>", "</div>", "<tr>", "</tr>", "</h1>", "</p>", "</style>",
539 ]
540 .iter()
541 .fold(h, |h, tag| h.replace(tag, &format!("{tag}\n")))
542 }
543
544 fn write_brand_row(h: &mut String, theme: &MailTheme) -> bool {
546 let e = escape;
547 let l = &theme.light;
548 let font = &theme.font;
549 let label = if theme.wordmark.is_empty() && theme.logo_url.is_none() {
550 theme.brand_name.as_str()
551 } else {
552 theme.wordmark.as_str()
553 };
554 if label.is_empty() && theme.logo_url.is_none() {
555 return false;
556 }
557 let band = theme.header_bg.as_deref();
558 let label_ink = theme.header_text.as_deref().unwrap_or(&l.ink);
559 let mut inner = String::from(
560 "<table role=\"presentation\" cellpadding=\"0\" cellspacing=\"0\" border=\"0\"><tr>",
561 );
562 if let Some(logo) = &theme.logo_url {
563 let (w, hgt) = (theme.logo_width, theme.logo_height);
564 let alt = if theme.logo_alt.is_empty() {
565 &theme.brand_name
566 } else {
567 &theme.logo_alt
568 };
569 let tile = theme
570 .logo_bg
571 .as_deref()
572 .map_or_else(String::new, |bg| format!(" bgcolor=\"{bg}\""));
573 let tile_style = theme.logo_bg.as_deref().map_or_else(String::new, |bg| {
574 format!("background:{bg};border-radius:{}px;", (hgt / 4).min(12))
575 });
576 let _ = write!(
577 inner,
578 "<td width=\"{w}\" height=\"{hgt}\"{tile} style=\"width:{w}px;height:{hgt}px;{tile_style}\">\
579<img src=\"{src}\" width=\"{w}\" height=\"{hgt}\" alt=\"{alt}\" \
580style=\"display:block;width:{w}px;height:{hgt}px;border:0;outline:none;text-decoration:none;color:{ink};font-family:{font};font-size:12px;line-height:{hgt}px;font-weight:700\">\
581</td>",
582 src = e(logo),
583 alt = e(alt),
584 ink = l.ink,
585 );
586 }
587 if !label.is_empty() {
588 let pad = if theme.logo_url.is_some() {
589 "padding-left:10px;"
590 } else {
591 ""
592 };
593 let _ = write!(
596 inner,
597 "<td{class} style=\"{pad}font-family:{display};font-size:20px;line-height:32px;font-weight:700;letter-spacing:-0.5px;color:{ink}\">{label}</td>",
598 class = if band.is_some() {
599 ""
600 } else {
601 " class=\"cf-ink\""
602 },
603 display = theme.display_font,
604 ink = if band.is_some() { label_ink } else { &l.ink },
605 label = e(label),
606 );
607 }
608 inner.push_str("</tr></table>");
609 let row = if theme.site_url.is_empty() {
610 inner
611 } else {
612 format!(
613 "<a href=\"{site}/\" style=\"text-decoration:none;color:{ink}\">{inner}</a>",
614 site = e(&theme.site_url),
615 ink = l.ink,
616 )
617 };
618 if let Some(bg) = band {
619 let r = theme.radius;
620 let _ = write!(
621 h,
622 "<tr><td class=\"cf-band\" bgcolor=\"{bg}\" style=\"background:{bg};padding:20px 40px;border-radius:{r}px {r}px 0 0\">{row}</td></tr>"
623 );
624 true
625 } else {
626 let _ = write!(h, "<tr><td style=\"padding:0 4px 20px\">{row}</td></tr>");
627 false
628 }
629 }
630
631 fn write_footer(&self, h: &mut String, theme: &MailTheme) {
632 let e = escape;
633 let l = &theme.light;
634 let muted = &l.muted;
635 let link = |label: &str, url: &str| {
636 if is_linkable(url) {
637 format!(
638 "<a class=\"cf-mut\" href=\"{}\" style=\"color:{muted};text-decoration:underline\">{}</a>",
639 e(url),
640 e(label)
641 )
642 } else {
643 e(label)
644 }
645 };
646 let mut lines = Vec::new();
647 if let Some(sentence) = self.footer_sentence() {
648 lines.push(e(&sentence));
649 }
650 if !self.footer_links.is_empty() {
651 lines.push(
652 self.footer_links
653 .iter()
654 .map(|(label, url)| link(label, url))
655 .collect::<Vec<_>>()
656 .join(" · "),
657 );
658 }
659 for line in &theme.footer {
660 lines.push(e(line));
661 }
662 let mut sign_off = Vec::new();
663 if !theme.brand_name.is_empty() {
664 sign_off.push(e(&theme.brand_name));
665 }
666 if !theme.site_url.is_empty() {
667 let host = theme
668 .site_url
669 .trim_start_matches("https://")
670 .trim_start_matches("http://");
671 sign_off.push(link(host, &format!("{}/", theme.site_url)));
672 }
673 if let Some(contact) = &theme.contact {
674 sign_off.push(link(contact, &format!("mailto:{contact}")));
675 }
676 if !sign_off.is_empty() {
677 lines.push(sign_off.join(" · "));
678 }
679 if lines.is_empty() {
680 return;
681 }
682 let _ = write!(
683 h,
684 "<tr><td class=\"cf-mut\" style=\"padding:24px 8px 0;font-family:{font};font-size:13px;line-height:20px;color:{muted}\">",
685 font = theme.font,
686 );
687 let last = lines.len() - 1;
688 for (i, line) in lines.iter().enumerate() {
689 let margin = if i == last { "0" } else { "0 0 6px" };
690 let _ = write!(h, "<p style=\"margin:{margin}\">{line}</p>");
691 }
692 h.push_str("</td></tr>");
693 }
694}
695
696fn marked_link_ink<'a>(accent: &'a str, button: &'a str, button_text: &'a str) -> &'a str {
700 match (luminance(accent), luminance(button)) {
701 (Some(a), Some(b)) if contrast(a, b) >= 4.5 => accent,
702 _ => button_text,
703 }
704}
705
706fn luminance(hex: &str) -> Option<f64> {
707 let h = hex.strip_prefix('#')?;
708 let h: String = match h.len() {
709 3 => h.chars().flat_map(|c| [c, c]).collect(),
710 6 => h.to_owned(),
711 _ => return None,
712 };
713 let channel = |i: usize| -> Option<f64> {
714 let v = f64::from(u8::from_str_radix(&h[i..i + 2], 16).ok()?) / 255.0;
715 Some(if v <= 0.039_28 {
716 v / 12.92
717 } else {
718 ((v + 0.055) / 1.055).powf(2.4)
719 })
720 };
721 Some(0.2126 * channel(0)? + 0.7152 * channel(2)? + 0.0722 * channel(4)?)
722}
723
724fn contrast(a: f64, b: f64) -> f64 {
725 let (hi, lo) = if a > b { (a, b) } else { (b, a) };
726 (hi + 0.05) / (lo + 0.05)
727}
728
729fn code_span(theme: &MailTheme, value: &str) -> String {
730 let l = &theme.light;
731 format!(
732 "<span class=\"cf-code\" style=\"display:inline-block;max-width:100%;padding:2px 8px;\
733border:1px solid {border};border-radius:{r}px;background:{code_bg};font-family:{mono};font-size:14px;line-height:22px;\
734color:{ink};word-break:break-all;overflow-wrap:anywhere\">{v}</span>",
735 border = l.border,
736 code_bg = l.code_bg,
737 mono = theme.mono,
738 ink = l.ink,
739 r = theme.button_radius.min(6),
740 v = escape(value),
741 )
742}
743
744fn head_style(theme: &MailTheme) -> String {
747 let d = &theme.dark;
748 format!(
749 "body{{margin:0!important;padding:0!important;width:100%!important}}\
750a[x-apple-data-detectors]{{color:inherit!important;text-decoration:none!important}}\
751@media (max-width:620px){{\
752.cf-outer{{padding:20px 12px!important}}\
753.cf-card{{padding:28px 20px!important}}\
754.cf-h1{{font-size:22px!important;line-height:28px!important}}\
755}}\
756@media (max-width:480px){{\
757.cf-fact-l{{display:block!important;width:auto!important;padding:0 0 2px!important}}\
758.cf-fact-v{{display:block!important;padding:0 0 12px!important}}\
759}}\
760@media (prefers-color-scheme:dark){{\
761.cf-bg{{background:{bg}!important}}\
762.cf-card{{background:{card}!important;border-color:{border}!important}}\
763.cf-ink{{color:{ink}!important}}\
764.cf-text{{color:{text}!important}}\
765.cf-mut{{color:{muted}!important}}\
766.cf-code{{background:{code_bg}!important;color:{ink}!important;border-color:{border}!important}}\
767.cf-rule{{border-color:{border}!important}}\
768.cf-btn{{background:{button}!important}}\
769.cf-btn-a{{color:{button_text}!important}}\
770.cf-link{{color:{link_fg}!important;background:{button}!important}}\
771}}",
772 bg = d.bg,
773 card = d.card,
774 border = d.border,
775 ink = d.ink,
776 text = d.text,
777 muted = d.muted,
778 code_bg = d.code_bg,
779 button = d.button,
780 button_text = d.button_text,
781 link_fg = marked_link_ink(&d.accent, &d.button, &d.button_text),
782 ) + if theme.header_bg.is_some() {
783 "@media (max-width:620px){.cf-band{padding:16px 20px!important}}"
785 } else {
786 ""
787 }
788}
789
790pub fn samples() -> Vec<(&'static str, Message)> {
793 let link =
794 "https://api.acme.test/v1/auth-magic-link/consume?token=Jx3mQ9vT0cWb8nL2kPz7rY5sHd1fGa4e";
795 vec![
796 (
797 "sign-in",
798 Message::new("Sign in to Acme", "Sign in to Acme")
799 .preheader(
800 "This link works once, for 15 minutes. If you didn't ask for it, ignore this email.",
801 )
802 .paragraph("Use the button below to sign in to Acme.")
803 .button("Sign in", link)
804 .fallback_link()
805 .note("This link works once and expires in 15 minutes.")
806 .note(
807 "If you didn't ask to sign in, ignore this email: nobody can use the link \
808 without opening it, and it expires on its own.",
809 )
810 .recipient("ada@example.com")
811 .why("someone asked to sign in to Acme with this address"),
812 ),
813 (
814 "invitation",
815 Message::new("You have been invited to join Analytical Engines", "You're invited")
816 .preheader("Join Analytical Engines on Acme as admin.")
817 .paragraph("You have been invited to join an organization on Acme.")
818 .fact("Organization", "Analytical Engines")
819 .fact("Role", "admin")
820 .button(
821 "Accept the invitation",
822 "https://api.acme.test/v1/orgs/invitations/accept?token=01J9ZQ4M8K2D7X3B",
823 )
824 .code(
825 "Or paste this token into the accept page:",
826 "01J9ZQ4M8K2D7X3B6N5R0T1V2W",
827 )
828 .note("This invitation expires in 7 days.")
829 .recipient("ada@example.com")
830 .why("someone invited this address to an organization on Acme"),
831 ),
832 (
833 "notification",
834 Message::new("Your export is ready", "Your export is ready")
835 .preheader("The CSV of your March orders is ready to download.")
836 .paragraph("The CSV of your March orders is ready to download.")
837 .button("Open", "https://acme.test/exports/42")
838 .footer_link(
839 "Stop receiving these",
840 "https://api.acme.test/v1/notifications/unsubscribe?t=abc",
841 )
842 .footer_link(
843 "stop all notification email",
844 "https://api.acme.test/v1/notifications/unsubscribe?t=all",
845 ),
846 ),
847 ]
848}
849
850#[cfg(test)]
851mod tests {
852 use super::*;
853
854 #[test]
855 fn escape_covers_markup_quotes_and_control_characters() {
856 assert_eq!(
857 escape("<a href=\"x\" onclick='y'>&</a>"),
858 "<a href="x" onclick='y'>&</a>"
859 );
860 assert_eq!(escape("a\r\nb"), "a b");
861 assert_eq!(plain("a\r\nBcc: x"), "a Bcc: x");
862 }
863
864 #[test]
865 fn the_fallback_link_is_marked_and_readable() {
866 assert_eq!(marked_link_ink("#C6F432", "#18181B", "#FFFFFF"), "#C6F432");
868 assert_eq!(marked_link_ink("#0E1526", "#18181B", "#FFFFFF"), "#FFFFFF");
870 assert_eq!(marked_link_ink("lime", "#18181B", "#FFFFFF"), "#FFFFFF");
872 }
873
874 #[test]
875 fn only_web_and_mailto_urls_are_links() {
876 assert!(is_linkable("https://a.test/x"));
877 assert!(is_linkable("mailto:a@b.test"));
878 assert!(!is_linkable("javascript:alert(1)"));
879 assert!(!is_linkable(" JavaScript:alert(1)"));
880 assert!(!is_linkable("/relative"));
881 }
882}