acton_htmx/email/
builder.rs

1//! Email builder with fluent API
2//!
3//! Provides a convenient builder pattern for constructing emails.
4
5use serde::{Deserialize, Serialize};
6
7use super::{EmailError, EmailTemplate};
8
9/// An email message
10///
11/// Use the builder pattern to construct emails:
12///
13/// ```rust
14/// use acton_htmx::email::Email;
15///
16/// let email = Email::new()
17///     .to("user@example.com")
18///     .from("noreply@myapp.com")
19///     .subject("Welcome!")
20///     .text("Welcome to our app!")
21///     .html("<h1>Welcome to our app!</h1>");
22/// ```
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct Email {
25    /// Email recipients (To)
26    pub to: Vec<String>,
27
28    /// Email sender (From)
29    pub from: Option<String>,
30
31    /// Reply-To address
32    pub reply_to: Option<String>,
33
34    /// CC recipients
35    pub cc: Vec<String>,
36
37    /// BCC recipients
38    pub bcc: Vec<String>,
39
40    /// Email subject
41    pub subject: Option<String>,
42
43    /// Plain text body
44    pub text: Option<String>,
45
46    /// HTML body
47    pub html: Option<String>,
48
49    /// Custom headers
50    pub headers: Vec<(String, String)>,
51}
52
53impl Email {
54    /// Create a new empty email
55    ///
56    /// # Examples
57    ///
58    /// ```rust
59    /// use acton_htmx::email::Email;
60    ///
61    /// let email = Email::new();
62    /// ```
63    #[must_use]
64    pub fn new() -> Self {
65        Self::default()
66    }
67
68    /// Create an email from a template
69    ///
70    /// # Errors
71    ///
72    /// Returns `EmailError::TemplateError` if the template fails to render
73    ///
74    /// # Examples
75    ///
76    /// ```rust,no_run
77    /// use acton_htmx::email::{Email, EmailTemplate};
78    /// use askama::Template;
79    ///
80    /// #[derive(Template)]
81    /// #[template(path = "emails/welcome.html")]
82    /// struct WelcomeEmail {
83    ///     name: String,
84    /// }
85    ///
86    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
87    /// let template = WelcomeEmail {
88    ///     name: "Alice".to_string(),
89    /// };
90    ///
91    /// let email = Email::from_template(&template)?;
92    /// # Ok(())
93    /// # }
94    /// ```
95    pub fn from_template<T: EmailTemplate>(template: &T) -> Result<Self, EmailError> {
96        let (html, text) = template.render_email()?;
97
98        let mut email = Self::new();
99        if let Some(html_content) = html {
100            email = email.html(&html_content);
101        }
102        if let Some(text_content) = text {
103            email = email.text(&text_content);
104        }
105
106        Ok(email)
107    }
108
109    /// Add a recipient (To)
110    ///
111    /// # Examples
112    ///
113    /// ```rust
114    /// use acton_htmx::email::Email;
115    ///
116    /// let email = Email::new()
117    ///     .to("user@example.com");
118    /// ```
119    #[must_use]
120    pub fn to(mut self, address: &str) -> Self {
121        self.to.push(address.to_string());
122        self
123    }
124
125    /// Add multiple recipients (To)
126    ///
127    /// # Examples
128    ///
129    /// ```rust
130    /// use acton_htmx::email::Email;
131    ///
132    /// let email = Email::new()
133    ///     .to_multiple(&["user1@example.com", "user2@example.com"]);
134    /// ```
135    #[must_use]
136    pub fn to_multiple(mut self, addresses: &[&str]) -> Self {
137        for address in addresses {
138            self.to.push((*address).to_string());
139        }
140        self
141    }
142
143    /// Set the sender (From)
144    ///
145    /// # Examples
146    ///
147    /// ```rust
148    /// use acton_htmx::email::Email;
149    ///
150    /// let email = Email::new()
151    ///     .from("noreply@myapp.com");
152    /// ```
153    #[must_use]
154    pub fn from(mut self, address: &str) -> Self {
155        self.from = Some(address.to_string());
156        self
157    }
158
159    /// Set the reply-to address
160    ///
161    /// # Examples
162    ///
163    /// ```rust
164    /// use acton_htmx::email::Email;
165    ///
166    /// let email = Email::new()
167    ///     .reply_to("support@myapp.com");
168    /// ```
169    #[must_use]
170    pub fn reply_to(mut self, address: &str) -> Self {
171        self.reply_to = Some(address.to_string());
172        self
173    }
174
175    /// Add a CC recipient
176    ///
177    /// # Examples
178    ///
179    /// ```rust
180    /// use acton_htmx::email::Email;
181    ///
182    /// let email = Email::new()
183    ///     .cc("manager@example.com");
184    /// ```
185    #[must_use]
186    pub fn cc(mut self, address: &str) -> Self {
187        self.cc.push(address.to_string());
188        self
189    }
190
191    /// Add a BCC recipient
192    ///
193    /// # Examples
194    ///
195    /// ```rust
196    /// use acton_htmx::email::Email;
197    ///
198    /// let email = Email::new()
199    ///     .bcc("admin@example.com");
200    /// ```
201    #[must_use]
202    pub fn bcc(mut self, address: &str) -> Self {
203        self.bcc.push(address.to_string());
204        self
205    }
206
207    /// Set the email subject
208    ///
209    /// # Examples
210    ///
211    /// ```rust
212    /// use acton_htmx::email::Email;
213    ///
214    /// let email = Email::new()
215    ///     .subject("Welcome to Our App!");
216    /// ```
217    #[must_use]
218    pub fn subject(mut self, subject: &str) -> Self {
219        self.subject = Some(subject.to_string());
220        self
221    }
222
223    /// Set the plain text body
224    ///
225    /// # Examples
226    ///
227    /// ```rust
228    /// use acton_htmx::email::Email;
229    ///
230    /// let email = Email::new()
231    ///     .text("Welcome to our app!");
232    /// ```
233    #[must_use]
234    pub fn text(mut self, body: &str) -> Self {
235        self.text = Some(body.to_string());
236        self
237    }
238
239    /// Set the HTML body
240    ///
241    /// # Examples
242    ///
243    /// ```rust
244    /// use acton_htmx::email::Email;
245    ///
246    /// let email = Email::new()
247    ///     .html("<h1>Welcome to our app!</h1>");
248    /// ```
249    #[must_use]
250    pub fn html(mut self, body: &str) -> Self {
251        self.html = Some(body.to_string());
252        self
253    }
254
255    /// Add a custom header
256    ///
257    /// # Examples
258    ///
259    /// ```rust
260    /// use acton_htmx::email::Email;
261    ///
262    /// let email = Email::new()
263    ///     .header("X-Priority", "1");
264    /// ```
265    #[must_use]
266    pub fn header(mut self, name: &str, value: &str) -> Self {
267        self.headers.push((name.to_string(), value.to_string()));
268        self
269    }
270
271    /// Validate the email
272    ///
273    /// Checks that all required fields are present
274    ///
275    /// # Errors
276    ///
277    /// Returns errors if:
278    /// - No recipients
279    /// - No sender
280    /// - No subject
281    /// - No content (text or HTML)
282    pub fn validate(&self) -> Result<(), EmailError> {
283        if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
284            return Err(EmailError::NoRecipients);
285        }
286
287        if self.from.is_none() {
288            return Err(EmailError::NoSender);
289        }
290
291        if self.subject.is_none() {
292            return Err(EmailError::NoSubject);
293        }
294
295        if self.text.is_none() && self.html.is_none() {
296            return Err(EmailError::NoContent);
297        }
298
299        Ok(())
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn test_email_builder() {
309        let email = Email::new()
310            .to("user@example.com")
311            .from("noreply@myapp.com")
312            .subject("Test")
313            .text("Hello, World!");
314
315        assert_eq!(email.to, vec!["user@example.com"]);
316        assert_eq!(email.from, Some("noreply@myapp.com".to_string()));
317        assert_eq!(email.subject, Some("Test".to_string()));
318        assert_eq!(email.text, Some("Hello, World!".to_string()));
319    }
320
321    #[test]
322    fn test_email_validation_no_recipients() {
323        let email = Email::new()
324            .from("noreply@myapp.com")
325            .subject("Test")
326            .text("Hello");
327
328        assert!(matches!(email.validate(), Err(EmailError::NoRecipients)));
329    }
330
331    #[test]
332    fn test_email_validation_no_sender() {
333        let email = Email::new()
334            .to("user@example.com")
335            .subject("Test")
336            .text("Hello");
337
338        assert!(matches!(email.validate(), Err(EmailError::NoSender)));
339    }
340
341    #[test]
342    fn test_email_validation_no_subject() {
343        let email = Email::new()
344            .to("user@example.com")
345            .from("noreply@myapp.com")
346            .text("Hello");
347
348        assert!(matches!(email.validate(), Err(EmailError::NoSubject)));
349    }
350
351    #[test]
352    fn test_email_validation_no_content() {
353        let email = Email::new()
354            .to("user@example.com")
355            .from("noreply@myapp.com")
356            .subject("Test");
357
358        assert!(matches!(email.validate(), Err(EmailError::NoContent)));
359    }
360
361    #[test]
362    fn test_email_validation_success() {
363        let email = Email::new()
364            .to("user@example.com")
365            .from("noreply@myapp.com")
366            .subject("Test")
367            .text("Hello, World!");
368
369        assert!(email.validate().is_ok());
370    }
371
372    #[test]
373    fn test_multiple_recipients() {
374        let email = Email::new()
375            .to_multiple(&["user1@example.com", "user2@example.com"])
376            .from("noreply@myapp.com")
377            .subject("Test")
378            .text("Hello");
379
380        assert_eq!(email.to.len(), 2);
381        assert!(email.to.contains(&"user1@example.com".to_string()));
382        assert!(email.to.contains(&"user2@example.com".to_string()));
383    }
384
385    #[test]
386    fn test_cc_and_bcc() {
387        let email = Email::new()
388            .to("user@example.com")
389            .cc("manager@example.com")
390            .bcc("admin@example.com")
391            .from("noreply@myapp.com")
392            .subject("Test")
393            .text("Hello");
394
395        assert_eq!(email.cc, vec!["manager@example.com"]);
396        assert_eq!(email.bcc, vec!["admin@example.com"]);
397    }
398
399    #[test]
400    fn test_custom_headers() {
401        let email = Email::new()
402            .to("user@example.com")
403            .from("noreply@myapp.com")
404            .subject("Test")
405            .text("Hello")
406            .header("X-Priority", "1")
407            .header("X-Custom", "value");
408
409        assert_eq!(email.headers.len(), 2);
410        assert!(email.headers.contains(&("X-Priority".to_string(), "1".to_string())));
411        assert!(email.headers.contains(&("X-Custom".to_string(), "value".to_string())));
412    }
413
414    #[test]
415    fn test_html_and_text() {
416        let email = Email::new()
417            .to("user@example.com")
418            .from("noreply@myapp.com")
419            .subject("Test")
420            .text("Plain text content")
421            .html("<h1>HTML content</h1>");
422
423        assert_eq!(email.text, Some("Plain text content".to_string()));
424        assert_eq!(email.html, Some("<h1>HTML content</h1>".to_string()));
425    }
426}