mailgen/
lib.rs

1//! # Overview
2//! This crate allows you to generate pretty emails without all the hassle.
3//! Take a look at the [README](https://github.com/atrox/mailgen) for screenshots.
4//!
5//! # Examples
6//!
7//! ```
8//! use mailgen::themes::DefaultTheme;
9//! use mailgen::{Action, Branding, EmailBuilder, Greeting, Mailgen};
10//!
11//! let theme = DefaultTheme::new()?;
12//! let branding = Branding::new("test product", "https://testproduct.com");
13//! let mailgen = Mailgen::new(theme, branding);
14//!
15//! let email = EmailBuilder::new()
16//!     .summary("this is a test email that contains stuff to test...")
17//!     .greeting(Greeting::Name("person name"))
18//!     .intro("test intro")
19//!     .intro("another intro")
20//!     .dictionary("test key", "test value")
21//!     .dictionary("test key 2", "test value 2")
22//!     .action(Action {
23//!         text: "Test Action",
24//!         link: "https://test.com/action",
25//!         color: Some(("black", "white")),
26//!         ..Default::default()
27//!     })
28//!     .action(Action {
29//!         text: "Test Action 2",
30//!         link: "https://test.com/action2",
31//!         instructions: Some("test instruction"),
32//!         ..Default::default()
33//!     })
34//!     .outro("test outro 1")
35//!     .outro("test outro 2")
36//!     .signature("test signature...")
37//!     .build();
38//!
39//! let rendered = mailgen.render_text(&email)?;
40//! std::fs::write("./email-doctest.txt", rendered)?;
41//!
42//! let rendered = mailgen.render_html(&email)?;
43//! std::fs::write("./email-doctest.html", rendered)?;
44//!
45//! # Ok::<(), Box<dyn std::error::Error>>(())
46//! ```
47
48mod builder;
49mod email;
50pub mod themes;
51
52pub use builder::EmailBuilder;
53pub use email::{Action, Email, GoToAction, Greeting, Table, TableColumns};
54use serde::{Deserialize, Serialize};
55use themes::{TemplateContext, Theme};
56
57pub struct Mailgen<T: Theme> {
58    theme: T,
59    branding: Branding,
60}
61
62impl<T: Theme> Mailgen<T> {
63    pub fn new(theme: T, branding: Branding) -> Self {
64        Self { theme, branding }
65    }
66
67    pub fn render_html(&self, email: &Email) -> Result<String, T::Error> {
68        let context = TemplateContext {
69            email,
70            branding: &self.branding,
71        };
72
73        self.theme.html(&context)
74    }
75
76    pub fn render_text(&self, email: &Email) -> Result<String, T::Error> {
77        let context = TemplateContext {
78            email,
79            branding: &self.branding,
80        };
81
82        self.theme.text(&context)
83    }
84}
85
86/// Product represents your company product (brand)
87/// Appears in header & footer of e-mails
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Branding {
90    pub name: String,
91    pub link: String,
92    pub logo: Option<String>,
93    pub copyright: Option<String>,
94    pub trouble_text: String,
95}
96
97impl Branding {
98    pub fn new<S: Into<String>>(name: S, link: S) -> Self {
99        let name = name.into();
100        let link = link.into();
101        let copyright = format!("Copyright © {name}. All rights reserved.");
102        let trouble_text = "If you're having trouble with the button '{ACTION}', copy and paste the URL below into your web browser."
103            .to_string();
104
105        Self {
106            name,
107            link,
108            trouble_text,
109            copyright: Some(copyright),
110            logo: None,
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use crate::builder::EmailBuilder;
118    use crate::{Action, Branding, Greeting, Mailgen, Table, TableColumns};
119
120    #[test]
121    #[cfg(feature = "default-theme")]
122    fn test_default_theme() -> Result<(), Box<dyn std::error::Error>> {
123        use crate::themes::DefaultTheme;
124
125        let theme = DefaultTheme::new()?;
126        let product = Branding::new("test product", "https://testproduct.com");
127        let mailgen = Mailgen::new(theme, product);
128
129        let email = EmailBuilder::new()
130            .summary("this is a test email that contains stuff to test...")
131            .greeting(Greeting::Name("person name"))
132            .intro("test intro")
133            .intro("another intro")
134            .dictionary("test key", "test value")
135            .dictionary("test key 2", "test value 2")
136            .action(Action {
137                text: "Test Action",
138                link: "https://test.com/action",
139                color: Some(("black", "white")),
140                ..Default::default()
141            })
142            .action(Action {
143                text: "Test Action 2",
144                link: "https://test.com/action2",
145                instructions: Some("test instruction"),
146                ..Default::default()
147            })
148            .outro("test outro 1")
149            .outro("test outro 2")
150            .signature("test signature...")
151            .build();
152
153        let rendered = mailgen.render_text(&email)?;
154        std::fs::write("./email.txt", rendered)?;
155
156        let rendered = mailgen.render_html(&email)?;
157        std::fs::write("./email.html", rendered)?;
158
159        Ok(())
160    }
161
162    #[test]
163    #[cfg(feature = "default-theme")]
164    fn test_tables() -> Result<(), Box<dyn std::error::Error>> {
165        use crate::themes::DefaultTheme;
166        use std::collections::HashMap;
167
168        let theme = DefaultTheme::new()?;
169        let branding = Branding::new("test product", "https://testproduct.com");
170        let mailgen = Mailgen::new(theme, branding);
171
172        // Create table data
173        let mut row1 = HashMap::new();
174        row1.insert("Item", "Product 1");
175        row1.insert("Price", "$10.99");
176        row1.insert("Quantity", "1");
177
178        let mut row2 = HashMap::new();
179        row2.insert("Item", "Product 2");
180        row2.insert("Price", "$24.99");
181        row2.insert("Quantity", "2");
182
183        let mut custom_alignment = HashMap::new();
184        custom_alignment.insert("Price", "right");
185        custom_alignment.insert("Quantity", "center");
186
187        let columns = TableColumns {
188            custom_width: None,
189            custom_alignment: Some(custom_alignment),
190        };
191
192        let table = Table {
193            title: "Order Summary",
194            data: vec![row1, row2],
195            columns: Some(columns),
196        };
197
198        let email = EmailBuilder::new()
199            .summary("Order Confirmation")
200            .greeting(Greeting::Name("Customer"))
201            .intro("Thank you for your order!")
202            .table(table)
203            .outro("Your order will be processed soon.")
204            .signature("The Sales Team")
205            .build();
206
207        let rendered = mailgen.render_html(&email)?;
208        std::fs::write("./email_with_table.html", rendered)?;
209
210        let rendered = mailgen.render_text(&email)?;
211        std::fs::write("./email_with_table.txt", rendered)?;
212
213        Ok(())
214    }
215
216    #[test]
217    #[cfg(feature = "default-theme")]
218    fn create_eml_file() -> Result<(), Box<dyn std::error::Error>> {
219        use crate::themes::DefaultTheme;
220        use std::collections::HashMap;
221        use std::fs::File;
222        use std::io::Write;
223
224        let theme = DefaultTheme::new()?;
225        let product = Branding::new("test product", "https://testproduct.com");
226        let mailgen = Mailgen::new(theme, product);
227
228        // Create table data
229        let mut row1 = HashMap::new();
230        row1.insert("Item", "Product 1");
231        row1.insert("Price", "$10.99");
232        row1.insert("Quantity", "1");
233
234        let mut row2 = HashMap::new();
235        row2.insert("Item", "Product 2");
236        row2.insert("Price", "$24.99");
237        row2.insert("Quantity", "2");
238
239        let mut custom_alignment = HashMap::new();
240        custom_alignment.insert("Price", "right");
241        custom_alignment.insert("Quantity", "center");
242
243        let columns = TableColumns {
244            custom_width: None,
245            custom_alignment: Some(custom_alignment),
246        };
247
248        let table = Table {
249            title: "Order Summary",
250            data: vec![row1, row2],
251            columns: Some(columns),
252        };
253
254        let email = EmailBuilder::new()
255            .summary("Email Test Subject")
256            .greeting(Greeting::Name("Test User"))
257            .intro("Welcome to our service!")
258            .intro("We're excited to have you on board.")
259            .dictionary("Account", "test@example.com")
260            .dictionary("Plan", "Premium")
261            .action(Action {
262                text: "Confirm Account",
263                link: "https://example.com/confirm",
264                color: Some(("#48cfad", "#ffffff")),
265                ..Default::default()
266            })
267            .table(table)
268            .outro("Need help, or have questions?")
269            .outro("Just reply to this email, we'd love to help.")
270            .signature("The Example Team")
271            .build();
272
273        let text_content = mailgen.render_text(&email)?;
274        let html_content = mailgen.render_html(&email)?;
275
276        // Create a boundary for the multipart email
277        let boundary = "------------MAILGEN_BOUNDARY";
278
279        // Create an .eml file with proper headers and MIME structure
280        let mut eml_file = File::create("./email_test.eml")?;
281
282        // Write email headers
283        writeln!(
284            eml_file,
285            "From: \"Test Product\" <no-reply@testproduct.com>"
286        )?;
287        writeln!(eml_file, "To: \"Test User\" <test@example.com>")?;
288        writeln!(eml_file, "Subject: Email Test Subject")?;
289        writeln!(eml_file, "MIME-Version: 1.0")?;
290        writeln!(
291            eml_file,
292            "Content-Type: multipart/alternative; boundary=\"{boundary}\""
293        )?;
294        writeln!(eml_file)?;
295
296        // Write the text part
297        writeln!(eml_file, "--{boundary}")?;
298        writeln!(eml_file, "Content-Type: text/plain; charset=UTF-8")?;
299        writeln!(eml_file, "Content-Transfer-Encoding: 8bit")?;
300        writeln!(eml_file)?;
301        writeln!(eml_file, "{}", text_content)?;
302        writeln!(eml_file)?;
303
304        // Write the HTML part
305        writeln!(eml_file, "--{boundary}")?;
306        writeln!(eml_file, "Content-Type: text/html; charset=UTF-8")?;
307        writeln!(eml_file, "Content-Transfer-Encoding: 8bit")?;
308        writeln!(eml_file)?;
309        writeln!(eml_file, "{}", html_content)?;
310        writeln!(eml_file)?;
311
312        // End the MIME message
313        writeln!(eml_file, "--{boundary}--")?;
314
315        Ok(())
316    }
317
318    #[test]
319    #[cfg(feature = "default-theme")]
320    fn test_go_to_action() -> Result<(), Box<dyn std::error::Error>> {
321        use crate::themes::DefaultTheme;
322
323        let theme = DefaultTheme::new()?;
324        let branding = Branding::new("Test Company", "https://example.com");
325        let mailgen = Mailgen::new(theme, branding);
326
327        let email = EmailBuilder::new()
328            .summary("Account Activation")
329            .greeting(Greeting::Name("John Doe"))
330            .intro("Welcome to our service! Please activate your account.")
331            .action(Action {
332                text: "Activate Account",
333                link: "https://example.com/activate",
334                instructions: Some("Click the button below to activate your account:"),
335                ..Default::default()
336            })
337            .go_to_action(
338                "Activate Now",
339                "https://example.com/activate",
340                "Activate your account with one click",
341            )
342            .outro("Need help? Just reply to this email.")
343            .build();
344
345        let rendered_html = mailgen.render_html(&email)?;
346
347        // Verify JSON-LD script tag is present
348        assert!(rendered_html.contains("application/ld+json"));
349        assert!(rendered_html.contains("Activate your account with one click"));
350
351        std::fs::write("./email_with_goto_action.html", rendered_html)?;
352
353        let rendered_text = mailgen.render_text(&email)?;
354        std::fs::write("./email_with_goto_action.txt", rendered_text)?;
355
356        Ok(())
357    }
358}