ferro_notifications/channels/
mail.rs1use serde::{Deserialize, Serialize};
4
5pub const MAX_ATTACHMENT_BYTES: usize = 25 * 1024 * 1024;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct MailAttachment {
14 pub filename: String,
16 pub content_type: String,
18 pub content: Vec<u8>,
20}
21
22#[derive(Debug, Clone, Default, Serialize, Deserialize)]
24pub struct MailMessage {
25 pub subject: String,
27 pub body: String,
29 pub html: Option<String>,
31 pub from: Option<String>,
33 pub from_name: Option<String>,
35 pub reply_to: Option<String>,
37 pub cc: Vec<String>,
39 pub bcc: Vec<String>,
41 pub headers: Vec<(String, String)>,
43 #[serde(default)]
45 pub attachments: Vec<MailAttachment>,
46}
47
48impl MailMessage {
49 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn subject(mut self, subject: impl Into<String>) -> Self {
56 self.subject = subject.into();
57 self
58 }
59
60 pub fn body(mut self, body: impl Into<String>) -> Self {
62 self.body = body.into();
63 self
64 }
65
66 pub fn html(mut self, html: impl Into<String>) -> Self {
68 self.html = Some(html.into());
69 self
70 }
71
72 pub fn from(mut self, from: impl Into<String>) -> Self {
74 self.from = Some(from.into());
75 self
76 }
77
78 pub fn from_name(mut self, name: impl Into<String>) -> Self {
81 self.from_name = Some(name.into());
82 self
83 }
84
85 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 pub fn cc(mut self, email: impl Into<String>) -> Self {
93 self.cc.push(email.into());
94 self
95 }
96
97 pub fn bcc(mut self, email: impl Into<String>) -> Self {
99 self.bcc.push(email.into());
100 self
101 }
102
103 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 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}