Skip to main content

ferro_notifications/channels/
mail.rs

1//! Mail notification channel.
2
3use serde::{Deserialize, Serialize};
4
5/// Per-attachment size limit (25 MB), per CONTEXT.md D-11.
6///
7/// This is a framework-level cap — provider-specific caps (e.g. Resend's 40 MB
8/// total per email) are surfaced by the carrier and NOT duplicated here.
9pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
10
11/// A binary attachment for a mail message.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MailAttachment {
14    /// Filename as it should appear to the recipient (e.g. "invoice.pdf").
15    pub filename: String,
16    /// MIME content-type (e.g. "application/pdf").
17    pub content_type: String,
18    /// Raw bytes of the attachment.
19    pub content: Vec<u8>,
20}
21
22/// A mail message for email notifications.
23#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct MailMessage {
25    /// Email subject line.
26    pub subject: String,
27    /// Plain text body.
28    pub body: String,
29    /// Optional HTML body.
30    pub html: Option<String>,
31    /// From address (if different from default).
32    pub from: Option<String>,
33    /// From display name override (e.g. tenant business name). Overrides `MailConfig::from_name`.
34    pub from_name: Option<String>,
35    /// Reply-to address.
36    pub reply_to: Option<String>,
37    /// CC recipients.
38    pub cc: Vec<String>,
39    /// BCC recipients.
40    pub bcc: Vec<String>,
41    /// Custom headers.
42    pub headers: Vec<(String, String)>,
43    /// Inline attachments. Per CONTEXT.md D-09, all attachments are in-memory `Vec<u8>`.
44    #[serde(default)]
45    pub attachments: Vec<MailAttachment>,
46}
47
48impl MailMessage {
49    /// Create a new empty mail message.
50    pub fn new() -> Self {
51        Self::default()
52    }
53
54    /// Set the subject line.
55    pub fn subject(mut self, subject: impl Into<String>) -> Self {
56        self.subject = subject.into();
57        self
58    }
59
60    /// Set the plain text body.
61    pub fn body(mut self, body: impl Into<String>) -> Self {
62        self.body = body.into();
63        self
64    }
65
66    /// Set the HTML body.
67    pub fn html(mut self, html: impl Into<String>) -> Self {
68        self.html = Some(html.into());
69        self
70    }
71
72    /// Set the from address.
73    pub fn from(mut self, from: impl Into<String>) -> Self {
74        self.from = Some(from.into());
75        self
76    }
77
78    /// Override the sender display name for this message (e.g. tenant business name).
79    /// Takes precedence over `MailConfig::from_name`.
80    pub fn from_name(mut self, name: impl Into<String>) -> Self {
81        self.from_name = Some(name.into());
82        self
83    }
84
85    /// Set the reply-to address.
86    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
87        self.reply_to = Some(reply_to.into());
88        self
89    }
90
91    /// Add a CC recipient.
92    pub fn cc(mut self, email: impl Into<String>) -> Self {
93        self.cc.push(email.into());
94        self
95    }
96
97    /// Add a BCC recipient.
98    pub fn bcc(mut self, email: impl Into<String>) -> Self {
99        self.bcc.push(email.into());
100        self
101    }
102
103    /// Add a custom header.
104    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
105        self.headers.push((name.into(), value.into()));
106        self
107    }
108
109    /// Add an attachment to the mail message.
110    ///
111    /// Returns `Err(Error::AttachmentTooLarge { .. })` if `content.len()` exceeds
112    /// `MAX_ATTACHMENT_BYTES` (25 MB) per CONTEXT.md D-11. The cap is per-attachment;
113    /// no cumulative cap is enforced (Resend's 40 MB total is the carrier's responsibility).
114    ///
115    /// Multiple calls accumulate.
116    pub fn attachment(
117        mut self,
118        filename: impl Into<String>,
119        content_type: impl Into<String>,
120        content: Vec<u8>,
121    ) -> Result<Self, crate::Error> {
122        let filename = filename.into();
123        if content.len() > MAX_ATTACHMENT_BYTES {
124            return Err(crate::Error::AttachmentTooLarge {
125                filename,
126                size: content.len(),
127                limit: MAX_ATTACHMENT_BYTES,
128            });
129        }
130        self.attachments.push(MailAttachment {
131            filename,
132            content_type: content_type.into(),
133            content,
134        });
135        Ok(self)
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_mail_message_builder() {
145        let mail = MailMessage::new()
146            .subject("Welcome!")
147            .body("Hello, welcome to our service.")
148            .html("<h1>Hello!</h1>")
149            .from("noreply@example.com")
150            .cc("manager@example.com")
151            .bcc("archive@example.com");
152
153        assert_eq!(mail.subject, "Welcome!");
154        assert_eq!(mail.body, "Hello, welcome to our service.");
155        assert_eq!(mail.html, Some("<h1>Hello!</h1>".into()));
156        assert_eq!(mail.from, Some("noreply@example.com".into()));
157        assert_eq!(mail.cc, vec!["manager@example.com"]);
158        assert_eq!(mail.bcc, vec!["archive@example.com"]);
159        assert!(mail.attachments.is_empty());
160    }
161
162    #[test]
163    fn test_mail_attachment_under_limit_succeeds() {
164        let mail = MailMessage::new()
165            .attachment("a.pdf", "application/pdf", vec![0u8; 1024])
166            .expect("under limit should succeed");
167        assert_eq!(mail.attachments.len(), 1);
168        assert_eq!(mail.attachments[0].filename, "a.pdf");
169        assert_eq!(mail.attachments[0].content_type, "application/pdf");
170        assert_eq!(mail.attachments[0].content.len(), 1024);
171    }
172
173    #[test]
174    fn test_mail_attachment_at_exact_limit_succeeds() {
175        let mail = MailMessage::new()
176            .attachment(
177                "edge.bin",
178                "application/octet-stream",
179                vec![0u8; MAX_ATTACHMENT_BYTES],
180            )
181            .expect("exactly at limit must succeed (limit is inclusive)");
182        assert_eq!(mail.attachments.len(), 1);
183        assert_eq!(mail.attachments[0].content.len(), MAX_ATTACHMENT_BYTES);
184    }
185
186    #[test]
187    fn test_mail_attachment_over_limit_returns_typed_error() {
188        let oversize = vec![0u8; MAX_ATTACHMENT_BYTES + 1];
189        let result = MailMessage::new().attachment("big.pdf", "application/pdf", oversize);
190        match result {
191            Err(crate::Error::AttachmentTooLarge {
192                filename,
193                size,
194                limit,
195            }) => {
196                assert_eq!(filename, "big.pdf");
197                assert_eq!(size, MAX_ATTACHMENT_BYTES + 1);
198                assert_eq!(limit, MAX_ATTACHMENT_BYTES);
199            }
200            other => panic!("expected AttachmentTooLarge, got {other:?}"),
201        }
202    }
203
204    #[test]
205    fn test_mail_attachment_accumulates() {
206        let mail = MailMessage::new()
207            .attachment("a.pdf", "application/pdf", vec![1, 2, 3])
208            .unwrap()
209            .attachment("b.pdf", "application/pdf", vec![4, 5, 6])
210            .unwrap()
211            .attachment("c.txt", "text/plain", b"hello".to_vec())
212            .unwrap();
213        assert_eq!(mail.attachments.len(), 3);
214        assert_eq!(mail.attachments[0].filename, "a.pdf");
215        assert_eq!(mail.attachments[1].filename, "b.pdf");
216        assert_eq!(mail.attachments[2].filename, "c.txt");
217        assert_eq!(mail.attachments[2].content, b"hello".to_vec());
218    }
219
220    #[test]
221    fn test_mail_message_serde_round_trip_with_attachments() {
222        let mail = MailMessage::new()
223            .subject("with attachment")
224            .body("body")
225            .attachment("hi.txt", "text/plain", b"hello".to_vec())
226            .unwrap();
227        let json = serde_json::to_string(&mail).unwrap();
228        let back: MailMessage = serde_json::from_str(&json).unwrap();
229        assert_eq!(back.subject, "with attachment");
230        assert_eq!(back.attachments.len(), 1);
231        assert_eq!(back.attachments[0].filename, "hi.txt");
232        assert_eq!(back.attachments[0].content, b"hello".to_vec());
233    }
234
235    #[test]
236    fn test_mail_message_default_has_empty_attachments() {
237        let mail = MailMessage::default();
238        assert!(mail.attachments.is_empty());
239    }
240
241    #[test]
242    fn test_max_attachment_bytes_constant() {
243        assert_eq!(MAX_ATTACHMENT_BYTES, 26_214_400);
244    }
245}