1use lettre::{
2 message::{
3 header::ContentType, Attachment, Mailbox, MultiPart, SinglePart,
4 },
5 transport::smtp::authentication::Credentials,
6 AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
7};
8
9use crate::config::Config;
10use crate::error::{AppError, AppResult};
11
12#[derive(Clone)]
14pub struct Mailer {
15 transport: AsyncSmtpTransport<Tokio1Executor>,
16 from: String,
17}
18
19impl Mailer {
20 pub fn new(config: &Config) -> AppResult<Self> {
22 let credentials = Credentials::new(
23 config.smtp_user.clone(),
24 config.smtp_password.clone(),
25 );
26
27 let transport = AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)
28 .map_err(|e| AppError::Internal(format!("SMTP config error: {}", e)))?
29 .credentials(credentials)
30 .port(config.smtp_port)
31 .build();
32
33 Ok(Self {
34 transport,
35 from: config.mail_from.clone(),
36 })
37 }
38
39 pub fn compose(&self) -> MailBuilder {
41 MailBuilder {
42 transport: self.transport.clone(),
43 from: self.from.clone(),
44 reply_to: None,
45 to: Vec::new(),
46 cc: Vec::new(),
47 bcc: Vec::new(),
48 subject: String::new(),
49 text_body: None,
50 html_body: None,
51 attachments: Vec::new(),
52 priority: Priority::Normal,
53 }
54 }
55
56 pub async fn send_text(&self, to: &str, subject: &str, body: &str) -> AppResult<()> {
58 self.compose()
59 .to(to)?
60 .subject(subject)
61 .text(body)
62 .send()
63 .await
64 }
65
66 pub async fn send_html(&self, to: &str, subject: &str, html: &str) -> AppResult<()> {
68 self.compose()
69 .to(to)?
70 .subject(subject)
71 .html(html)
72 .send()
73 .await
74 }
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Priority {
80 High,
81 Normal,
82 Low,
83}
84
85#[derive(Debug, Clone)]
87pub struct MailAttachment {
88 pub filename: String,
89 pub content_type: String,
90 pub data: Vec<u8>,
91}
92
93impl MailAttachment {
94 pub fn new(filename: impl Into<String>, content_type: impl Into<String>, data: Vec<u8>) -> Self {
96 Self {
97 filename: filename.into(),
98 content_type: content_type.into(),
99 data,
100 }
101 }
102
103 pub fn from_file(path: &std::path::Path) -> AppResult<Self> {
105 let filename = path
106 .file_name()
107 .and_then(|n| n.to_str())
108 .unwrap_or("attachment")
109 .to_string();
110
111 let data = std::fs::read(path)
112 .map_err(|e| AppError::Internal(format!("Failed to read attachment '{}': {}", filename, e)))?;
113
114 let content_type = Self::guess_content_type(&filename);
115
116 Ok(Self {
117 filename,
118 content_type,
119 data,
120 })
121 }
122
123 fn guess_content_type(filename: &str) -> String {
124 let ext = filename
125 .rsplit('.')
126 .next()
127 .unwrap_or("")
128 .to_lowercase();
129
130 match ext.as_str() {
131 "pdf" => "application/pdf",
132 "zip" => "application/zip",
133 "gz" | "gzip" => "application/gzip",
134 "doc" => "application/msword",
135 "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
136 "xls" => "application/vnd.ms-excel",
137 "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
138 "csv" => "text/csv",
139 "txt" => "text/plain",
140 "html" | "htm" => "text/html",
141 "json" => "application/json",
142 "xml" => "application/xml",
143 "jpg" | "jpeg" => "image/jpeg",
144 "png" => "image/png",
145 "gif" => "image/gif",
146 "webp" => "image/webp",
147 "svg" => "image/svg+xml",
148 "mp4" => "video/mp4",
149 "mp3" => "audio/mpeg",
150 _ => "application/octet-stream",
151 }
152 .to_string()
153 }
154}
155
156pub struct MailBuilder {
158 transport: AsyncSmtpTransport<Tokio1Executor>,
159 from: String,
160 reply_to: Option<String>,
161 to: Vec<String>,
162 cc: Vec<String>,
163 bcc: Vec<String>,
164 subject: String,
165 text_body: Option<String>,
166 html_body: Option<String>,
167 attachments: Vec<MailAttachment>,
168 priority: Priority,
169}
170
171fn parse_mailbox(addr: &str) -> AppResult<Mailbox> {
172 addr.parse::<Mailbox>()
173 .map_err(|e| AppError::BadRequest(format!("Invalid email address '{}': {}", addr, e)))
174}
175
176impl MailBuilder {
177 pub fn to(mut self, address: &str) -> AppResult<Self> {
179 parse_mailbox(address)?;
180 self.to.push(address.to_string());
181 Ok(self)
182 }
183
184 pub fn to_many(mut self, addresses: &[&str]) -> AppResult<Self> {
186 for addr in addresses {
187 parse_mailbox(addr)?;
188 self.to.push(addr.to_string());
189 }
190 Ok(self)
191 }
192
193 pub fn cc(mut self, address: &str) -> AppResult<Self> {
195 parse_mailbox(address)?;
196 self.cc.push(address.to_string());
197 Ok(self)
198 }
199
200 pub fn cc_many(mut self, addresses: &[&str]) -> AppResult<Self> {
202 for addr in addresses {
203 parse_mailbox(addr)?;
204 self.cc.push(addr.to_string());
205 }
206 Ok(self)
207 }
208
209 pub fn bcc(mut self, address: &str) -> AppResult<Self> {
211 parse_mailbox(address)?;
212 self.bcc.push(address.to_string());
213 Ok(self)
214 }
215
216 pub fn bcc_many(mut self, addresses: &[&str]) -> AppResult<Self> {
218 for addr in addresses {
219 parse_mailbox(addr)?;
220 self.bcc.push(addr.to_string());
221 }
222 Ok(self)
223 }
224
225 pub fn reply_to(mut self, address: &str) -> AppResult<Self> {
227 parse_mailbox(address)?;
228 self.reply_to = Some(address.to_string());
229 Ok(self)
230 }
231
232 pub fn subject(mut self, subject: &str) -> Self {
234 self.subject = subject.to_string();
235 self
236 }
237
238 pub fn text(mut self, body: &str) -> Self {
240 self.text_body = Some(body.to_string());
241 self
242 }
243
244 pub fn html(mut self, body: &str) -> Self {
246 self.html_body = Some(body.to_string());
247 self
248 }
249
250 pub fn text_and_html(mut self, text: &str, html: &str) -> Self {
252 self.text_body = Some(text.to_string());
253 self.html_body = Some(html.to_string());
254 self
255 }
256
257 pub fn attach(mut self, attachment: MailAttachment) -> Self {
259 self.attachments.push(attachment);
260 self
261 }
262
263 pub fn attach_file(mut self, path: &std::path::Path) -> AppResult<Self> {
265 self.attachments.push(MailAttachment::from_file(path)?);
266 Ok(self)
267 }
268
269 pub fn priority(mut self, priority: Priority) -> Self {
271 self.priority = priority;
272 self
273 }
274
275 pub async fn send(self) -> AppResult<()> {
277 if self.to.is_empty() {
278 return Err(AppError::BadRequest("No recipients specified".to_string()));
279 }
280
281 if self.subject.is_empty() {
282 return Err(AppError::BadRequest("No subject specified".to_string()));
283 }
284
285 if self.text_body.is_none() && self.html_body.is_none() {
286 return Err(AppError::BadRequest("No email body specified".to_string()));
287 }
288
289 let transport = self.transport.clone();
290 let message = self.build_message()?;
291
292 transport
293 .send(message)
294 .await
295 .map_err(|e| AppError::Internal(format!("Failed to send email: {}", e)))?;
296
297 Ok(())
298 }
299
300 fn build_message(self) -> AppResult<Message> {
301 let mut builder = Message::builder()
302 .from(parse_mailbox(&self.from)?);
303
304 for addr in &self.to {
306 builder = builder.to(parse_mailbox(addr)?);
307 }
308 for addr in &self.cc {
309 builder = builder.cc(parse_mailbox(addr)?);
310 }
311 for addr in &self.bcc {
312 builder = builder.bcc(parse_mailbox(addr)?);
313 }
314
315 if let Some(reply_to) = &self.reply_to {
317 builder = builder.reply_to(parse_mailbox(reply_to)?);
318 }
319
320 builder = builder.subject(&self.subject);
322
323 let has_attachments = !self.attachments.is_empty();
325 let has_text = self.text_body.is_some();
326 let has_html = self.html_body.is_some();
327
328 if has_attachments {
329 let body_part = Self::make_body_part(
331 &self.text_body,
332 &self.html_body,
333 has_text,
334 has_html,
335 );
336
337 let mut mixed = MultiPart::mixed().multipart(body_part);
338
339 for att in self.attachments {
340 let content_type = ContentType::parse(&att.content_type)
341 .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
342 let attachment_part = Attachment::new(att.filename)
343 .body(att.data, content_type);
344 mixed = mixed.singlepart(attachment_part);
345 }
346
347 builder.multipart(mixed)
348 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
349 } else if has_text && has_html {
350 let alternative = MultiPart::alternative_plain_html(
351 self.text_body.unwrap(),
352 self.html_body.unwrap(),
353 );
354 builder.multipart(alternative)
355 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
356 } else if has_html {
357 builder
358 .header(ContentType::TEXT_HTML)
359 .body(self.html_body.unwrap())
360 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
361 } else {
362 builder
363 .header(ContentType::TEXT_PLAIN)
364 .body(self.text_body.unwrap())
365 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
366 }
367 }
368
369 fn make_body_part(
370 text_body: &Option<String>,
371 html_body: &Option<String>,
372 has_text: bool,
373 has_html: bool,
374 ) -> MultiPart {
375 if has_text && has_html {
376 MultiPart::alternative_plain_html(
377 text_body.clone().unwrap(),
378 html_body.clone().unwrap(),
379 )
380 } else if has_html {
381 MultiPart::alternative()
382 .singlepart(SinglePart::html(html_body.clone().unwrap()))
383 } else {
384 MultiPart::alternative()
385 .singlepart(SinglePart::plain(text_body.clone().unwrap()))
386 }
387 }
388}