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 reply_to: Option<String>,
35 pub cc: Vec<String>,
37 pub bcc: Vec<String>,
39 pub headers: Vec<(String, String)>,
41 #[serde(default)]
43 pub attachments: Vec<MailAttachment>,
44}
45
46impl MailMessage {
47 pub fn new() -> Self {
49 Self::default()
50 }
51
52 pub fn subject(mut self, subject: impl Into<String>) -> Self {
54 self.subject = subject.into();
55 self
56 }
57
58 pub fn body(mut self, body: impl Into<String>) -> Self {
60 self.body = body.into();
61 self
62 }
63
64 pub fn html(mut self, html: impl Into<String>) -> Self {
66 self.html = Some(html.into());
67 self
68 }
69
70 pub fn from(mut self, from: impl Into<String>) -> Self {
72 self.from = Some(from.into());
73 self
74 }
75
76 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 pub fn cc(mut self, email: impl Into<String>) -> Self {
84 self.cc.push(email.into());
85 self
86 }
87
88 pub fn bcc(mut self, email: impl Into<String>) -> Self {
90 self.bcc.push(email.into());
91 self
92 }
93
94 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 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}