Skip to main content

modo/email/
button.rs

1/// Button type variants for email buttons.
2///
3/// Controls the background colour of the rendered HTML button.
4/// In template Markdown, use `[button:TYPE|Label](url)`.
5#[derive(Debug, Clone, Copy, PartialEq)]
6pub enum ButtonType {
7    /// Blue (or `brand_color` variable when supplied). Template syntax: `[button|Label](url)` or `[button:primary|Label](url)`.
8    Primary,
9    /// Red. Template syntax: `[button:danger|Label](url)`.
10    Danger,
11    /// Amber. Template syntax: `[button:warning|Label](url)`.
12    Warning,
13    /// Cyan. Template syntax: `[button:info|Label](url)`.
14    Info,
15    /// Green. Template syntax: `[button:success|Label](url)`.
16    Success,
17}
18
19impl ButtonType {
20    /// Returns `(background_color, text_color)` CSS hex values for this button type.
21    ///
22    /// For `Primary`, `brand_color` overrides the default blue when provided.
23    pub fn colors<'a>(&self, brand_color: Option<&'a str>) -> (&'a str, &'a str) {
24        match self {
25            Self::Primary => (brand_color.unwrap_or("#2563eb"), "#ffffff"),
26            Self::Danger => ("#dc2626", "#ffffff"),
27            Self::Warning => ("#d97706", "#ffffff"),
28            Self::Info => ("#0891b2", "#ffffff"),
29            Self::Success => ("#16a34a", "#ffffff"),
30        }
31    }
32}
33
34/// Parse button text like "button|Label" or "button:type|Label".
35/// Returns `Some((ButtonType, label))` if it matches, `None` otherwise.
36pub fn parse_button(text: &str) -> Option<(ButtonType, &str)> {
37    let rest = text.strip_prefix("button")?;
38
39    if let Some(rest) = rest.strip_prefix('|') {
40        // "button|Label" -> Primary
41        if rest.is_empty() {
42            return None;
43        }
44        return Some((ButtonType::Primary, rest));
45    }
46
47    if let Some(rest) = rest.strip_prefix(':') {
48        // "button:type|Label"
49        let (type_str, label) = rest.split_once('|')?;
50        if label.is_empty() {
51            return None;
52        }
53        let btn_type = match type_str {
54            "primary" => ButtonType::Primary,
55            "danger" => ButtonType::Danger,
56            "warning" => ButtonType::Warning,
57            "info" => ButtonType::Info,
58            "success" => ButtonType::Success,
59            _ => return None,
60        };
61        return Some((btn_type, label));
62    }
63
64    None
65}
66
67/// Render a table-based HTML button (Outlook-compatible).
68pub fn render_button_html(
69    label: &str,
70    url: &str,
71    btn_type: ButtonType,
72    brand_color: Option<&str>,
73) -> String {
74    use crate::email::render;
75    let (bg, fg) = btn_type.colors(brand_color);
76    let label = render::escape_html(label);
77    let url = render::escape_html(url);
78    format!(
79        r#"<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 16px 0;"><tr><td style="background-color: {bg}; border-radius: 6px; padding: 12px 24px;"><a href="{url}" style="color: {fg}; text-decoration: none; font-weight: 600; display: inline-block;">{label}</a></td></tr></table>"#
80    )
81}
82
83/// Render a plain text button as `"Label: url"`.
84pub fn render_button_text(label: &str, url: &str) -> String {
85    format!("{label}: {url}")
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn parse_button_primary_default() {
94        let (btn_type, label) = parse_button("button|Get Started").unwrap();
95        assert_eq!(btn_type, ButtonType::Primary);
96        assert_eq!(label, "Get Started");
97    }
98
99    #[test]
100    fn parse_button_with_type() {
101        let (btn_type, label) = parse_button("button:danger|Delete Account").unwrap();
102        assert_eq!(btn_type, ButtonType::Danger);
103        assert_eq!(label, "Delete Account");
104    }
105
106    #[test]
107    fn parse_button_all_types() {
108        assert_eq!(
109            parse_button("button:primary|X").unwrap().0,
110            ButtonType::Primary
111        );
112        assert_eq!(
113            parse_button("button:danger|X").unwrap().0,
114            ButtonType::Danger
115        );
116        assert_eq!(
117            parse_button("button:warning|X").unwrap().0,
118            ButtonType::Warning
119        );
120        assert_eq!(parse_button("button:info|X").unwrap().0, ButtonType::Info);
121        assert_eq!(
122            parse_button("button:success|X").unwrap().0,
123            ButtonType::Success
124        );
125    }
126
127    #[test]
128    fn parse_button_not_a_button() {
129        assert!(parse_button("Click here").is_none());
130        assert!(parse_button("").is_none());
131        assert!(parse_button("button").is_none());
132        assert!(parse_button("button|").is_none());
133        assert!(parse_button("button:unknown|Label").is_none());
134        assert!(parse_button("button:danger|").is_none());
135    }
136
137    #[test]
138    fn render_html_contains_expected_parts() {
139        let html = render_button_html("Go", "https://x.com", ButtonType::Primary, None);
140        assert!(html.contains("background-color: #2563eb"));
141        assert!(html.contains("href=\"https://x.com\""));
142        assert!(html.contains(">Go</a>"));
143        assert!(html.contains("role=\"presentation\""));
144    }
145
146    #[test]
147    fn render_html_brand_color_overrides_primary() {
148        let html = render_button_html("Go", "https://x.com", ButtonType::Primary, Some("#ff0000"));
149        assert!(html.contains("background-color: #ff0000"));
150    }
151
152    #[test]
153    fn render_html_brand_color_does_not_affect_other_types() {
154        let html = render_button_html("Go", "https://x.com", ButtonType::Danger, Some("#ff0000"));
155        assert!(html.contains("background-color: #dc2626"));
156    }
157
158    #[test]
159    fn render_text_format() {
160        let text = render_button_text("Get Started", "https://example.com");
161        assert_eq!(text, "Get Started: https://example.com");
162    }
163
164    #[test]
165    fn render_html_escapes_special_chars() {
166        let html = render_button_html(
167            "<b>Bold</b>",
168            r#"https://x.com/?a=1&b="2""#,
169            ButtonType::Primary,
170            None,
171        );
172        assert!(html.contains(">&lt;b&gt;Bold&lt;/b&gt;</a>"));
173        assert!(html.contains("href=\"https://x.com/?a=1&amp;b=&quot;2&quot;\""));
174    }
175}