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