Skip to main content

cratefield_mail_templates/
lib.rs

1//! Branded transactional mail for Cratefield ventures: one email-client-safe
2//! layout, themed with the venture's own website colours, logo and fonts,
3//! and a plain-text twin for every message.
4//!
5//! Two parts:
6//!
7//! - [`MailTheme`] — brand name, wordmark, hosted logo and alt text,
8//!   light and dark [`Palette`]s, font stacks, footer lines. Modules get it
9//!   from the venture's composition (`themed_templates(&theme)` on each
10//!   mail-sending module), the deployment's `MAIL_THEME` config (merged
11//!   on top), or — with neither — [`MailTheme::for_venture`], a neutral
12//!   theme built from the venture's name, public URL and core `Brand`.
13//! - [`Message`] — a builder for one mail: heading, paragraphs, facts, a
14//!   primary button with its fallback link, a code value, notes and the
15//!   footer. [`Message::render`] returns an [`Email`] with the HTML and the
16//!   text part.
17//!
18//! ```
19//! use cratefield_mail_templates::{MailTheme, Message};
20//!
21//! let theme = MailTheme::new("Acme", "https://acme.test");
22//! let email = Message::new("Sign in to Acme", "Sign in to Acme")
23//!     .preheader("This link works once, for 15 minutes.")
24//!     .paragraph("Use the button below to sign in.")
25//!     .button("Sign in", "https://api.acme.test/v1/auth-magic-link/consume?token=abc")
26//!     .fallback_link()
27//!     .note("If you didn't ask for this, ignore this email.")
28//!     .recipient("ada@example.com")
29//!     .why("someone asked to sign in to Acme with this address")
30//!     .render(&theme);
31//! assert!(email.html.contains("consume?token=abc"));
32//! assert!(email.text.contains("consume?token=abc"));
33//! ```
34//!
35//! **Email-client safety.** Table layout, every style inline (the single
36//! `<style>` block only adds the narrow-screen and `prefers-color-scheme:
37//! dark` overrides, which clients without `<style>` support skip), no web
38//! fonts, no tracking pixel, no click tracking, `lang` and `dir` on the
39//! document. The only image is the theme's hosted logo, with alt text; the
40//! layout reads the same without it.
41//!
42//! **Escaping.** Every value — the builder's text, the theme's strings and
43//! anything a person typed — reaches the HTML through [`escape`], and the
44//! text part with control characters removed, so no value can add markup
45//! or forge a line. Theme colours and fonts are checked before they reach a
46//! `style` attribute, and only `https:`, `http:` and `mailto:` URLs become
47//! links.
48
49#![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/// One rendered mail.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct Email {
63    /// The `Subject:` header.
64    pub subject: String,
65    /// The preview line most inboxes show beside the subject; also the
66    /// hidden first line of the HTML.
67    pub preheader: String,
68    /// The `text/plain` part.
69    pub text: String,
70    /// The `text/html` part.
71    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
84/// Escapes text for HTML element content and quoted attribute values.
85/// Control characters become spaces.
86pub 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("&amp;"),
91            '<' => out.push_str("&lt;"),
92            '>' => out.push_str("&gt;"),
93            '"' => out.push_str("&quot;"),
94            '\'' => out.push_str("&#39;"),
95            c if c.is_control() => out.push(' '),
96            c => out.push(c),
97        }
98    }
99    out
100}
101
102/// A value for the text part or a header: control characters (a newline
103/// in a name, say) become spaces, so no value can forge a line.
104pub fn plain(s: &str) -> String {
105    s.chars()
106        .map(|c| if c.is_control() { ' ' } else { c })
107        .collect()
108}
109
110/// Whether `url` may be a link: `https:`, `http:` or `mailto:`. Anything
111/// else (`javascript:`, `data:`, a relative path that means nothing in an
112/// inbox) is shown as text instead.
113pub 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/// Text direction of the mail.
119#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
120pub enum Dir {
121    /// Left to right.
122    #[default]
123    Ltr,
124    /// Right to left.
125    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/// One mail, built up and then [rendered](Message::render) with a theme.
145#[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    /// A mail with this subject and heading, in English, left to right.
167    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    /// The preview line inboxes show beside the subject.
188    pub fn preheader(mut self, text: impl Into<String>) -> Self {
189        self.preheader = text.into();
190        self
191    }
192
193    /// The document language, a BCP 47 tag (default `en`).
194    pub fn lang(mut self, tag: impl Into<String>) -> Self {
195        self.lang = tag.into();
196        self
197    }
198
199    /// The text direction (default left to right).
200    pub fn dir(mut self, dir: Dir) -> Self {
201        self.dir = dir;
202        self
203    }
204
205    /// Appends a paragraph of body text.
206    pub fn paragraph(mut self, text: impl Into<String>) -> Self {
207        self.paragraphs.push(text.into());
208        self
209    }
210
211    /// Appends a labelled value (`Organization: Acme`).
212    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    /// Appends a labelled value shown as code (a key prefix, a URL that
222    /// should be read, not clicked).
223    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    /// The primary button. One per mail.
233    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    /// Also prints the button's URL as text, for clients that strip links
239    /// and people who would rather paste.
240    pub fn fallback_link(mut self) -> Self {
241        self.fallback_link = true;
242        self
243    }
244
245    /// The sentence before the fallback link (default "Or paste this link
246    /// into your browser:").
247    pub fn link_intro(mut self, text: impl Into<String>) -> Self {
248        self.link_intro = text.into();
249        self
250    }
251
252    /// A code to copy by hand (an invitation token), after `intro`.
253    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    /// Appends a muted note under a rule: link expiry, "ignore this if you
259    /// didn't ask".
260    pub fn note(mut self, text: impl Into<String>) -> Self {
261        self.notes.push(text.into());
262        self
263    }
264
265    /// The address the mail goes to, named in the footer.
266    pub fn recipient(mut self, address: impl Into<String>) -> Self {
267        self.recipient = Some(address.into());
268        self
269    }
270
271    /// Why this person got this mail, completing "Sent to … because …".
272    pub fn why(mut self, reason: impl Into<String>) -> Self {
273        self.why = Some(reason.into());
274        self
275    }
276
277    /// Appends a footer link (unsubscribe, preferences).
278    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    /// Renders the HTML and text parts with `theme`.
284    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            // Padded so clients don't pull body text into the preview.
380            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                "&#847;&zwnj;&nbsp;".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        // The card. Under a header band it joins the band: no top border,
401        // no top corners.
402        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                        // Marked on the button colour so a light brand accent (lime,
475                        // yellow) stays readable on a white card.
476                        "<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        // A line break after each block, so the source (and its snapshot)
535        // reads line by line. Values are escaped, so none of these tags can
536        // appear inside them.
537        [
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    /// The logo row; `true` when it was drawn as a header band.
545    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            // On a band the wordmark keeps the band's colour in both
594            // schemes, so it loses the class the dark scheme repaints.
595            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(" &middot; "),
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(" &middot; "));
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
696/// The text colour for the fallback link, marked on `button`: the brand
697/// accent when it reads on the button (contrast 4.5:1 or more), else the
698/// button's own text colour.
699fn 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
744/// The narrow-screen and dark-mode overrides. Only classes; every base
745/// style is inline. Colours are sanitized hex values.
746fn 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        // The band narrows with the card on a phone.
784        "@media (max-width:620px){.cf-band{padding:16px 20px!important}}"
785    } else {
786        ""
787    }
788}
789
790/// Representative messages with sample values: what `render-emails`
791/// writes for a theme and what the snapshots cover.
792pub 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            "&lt;a href=&quot;x&quot; onclick=&#39;y&#39;&gt;&amp;&lt;/a&gt;"
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        // A lime accent reads on a near-black button: the link keeps it.
867        assert_eq!(marked_link_ink("#C6F432", "#18181B", "#FFFFFF"), "#C6F432");
868        // A dark accent would vanish on the dark mark: the button text is used.
869        assert_eq!(marked_link_ink("#0E1526", "#18181B", "#FFFFFF"), "#FFFFFF");
870        // Not a colour at all: fall back to the button text.
871        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}