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 from(mut self, address: &str) -> Self {
179 self.from = address.to_string();
180 self
181 }
182
183 pub fn to(mut self, address: &str) -> AppResult<Self> {
185 parse_mailbox(address)?;
186 self.to.push(address.to_string());
187 Ok(self)
188 }
189
190 pub fn to_many(mut self, addresses: &[&str]) -> AppResult<Self> {
192 for addr in addresses {
193 parse_mailbox(addr)?;
194 self.to.push(addr.to_string());
195 }
196 Ok(self)
197 }
198
199 pub fn cc(mut self, address: &str) -> AppResult<Self> {
201 parse_mailbox(address)?;
202 self.cc.push(address.to_string());
203 Ok(self)
204 }
205
206 pub fn cc_many(mut self, addresses: &[&str]) -> AppResult<Self> {
208 for addr in addresses {
209 parse_mailbox(addr)?;
210 self.cc.push(addr.to_string());
211 }
212 Ok(self)
213 }
214
215 pub fn bcc(mut self, address: &str) -> AppResult<Self> {
217 parse_mailbox(address)?;
218 self.bcc.push(address.to_string());
219 Ok(self)
220 }
221
222 pub fn bcc_many(mut self, addresses: &[&str]) -> AppResult<Self> {
224 for addr in addresses {
225 parse_mailbox(addr)?;
226 self.bcc.push(addr.to_string());
227 }
228 Ok(self)
229 }
230
231 pub fn reply_to(mut self, address: &str) -> AppResult<Self> {
233 parse_mailbox(address)?;
234 self.reply_to = Some(address.to_string());
235 Ok(self)
236 }
237
238 pub fn subject(mut self, subject: &str) -> Self {
240 self.subject = subject.to_string();
241 self
242 }
243
244 pub fn text(mut self, body: &str) -> Self {
246 self.text_body = Some(body.to_string());
247 self
248 }
249
250 pub fn html(mut self, body: &str) -> Self {
252 self.html_body = Some(body.to_string());
253 self
254 }
255
256 pub fn text_and_html(mut self, text: &str, html: &str) -> Self {
258 self.text_body = Some(text.to_string());
259 self.html_body = Some(html.to_string());
260 self
261 }
262
263 pub fn attach(mut self, attachment: MailAttachment) -> Self {
265 self.attachments.push(attachment);
266 self
267 }
268
269 pub fn attach_file(mut self, path: &std::path::Path) -> AppResult<Self> {
271 self.attachments.push(MailAttachment::from_file(path)?);
272 Ok(self)
273 }
274
275 pub fn priority(mut self, priority: Priority) -> Self {
277 self.priority = priority;
278 self
279 }
280
281 pub async fn send(self) -> AppResult<()> {
283 if self.to.is_empty() {
284 return Err(AppError::BadRequest("No recipients specified".to_string()));
285 }
286
287 if self.subject.is_empty() {
288 return Err(AppError::BadRequest("No subject specified".to_string()));
289 }
290
291 if self.text_body.is_none() && self.html_body.is_none() {
292 return Err(AppError::BadRequest("No email body specified".to_string()));
293 }
294
295 let transport = self.transport.clone();
296 let message = self.build_message()?;
297
298 transport
299 .send(message)
300 .await
301 .map_err(|e| AppError::Internal(format!("Failed to send email: {}", e)))?;
302
303 Ok(())
304 }
305
306 fn build_message(self) -> AppResult<Message> {
307 let mut builder = Message::builder()
308 .from(parse_mailbox(&self.from)?);
309
310 for addr in &self.to {
312 builder = builder.to(parse_mailbox(addr)?);
313 }
314 for addr in &self.cc {
315 builder = builder.cc(parse_mailbox(addr)?);
316 }
317 for addr in &self.bcc {
318 builder = builder.bcc(parse_mailbox(addr)?);
319 }
320
321 if let Some(reply_to) = &self.reply_to {
323 builder = builder.reply_to(parse_mailbox(reply_to)?);
324 }
325
326 builder = builder.subject(&self.subject);
328
329 let has_attachments = !self.attachments.is_empty();
331 let has_text = self.text_body.is_some();
332 let has_html = self.html_body.is_some();
333
334 if has_attachments {
335 let body_part = Self::make_body_part(
337 &self.text_body,
338 &self.html_body,
339 has_text,
340 has_html,
341 );
342
343 let mut mixed = MultiPart::mixed().multipart(body_part);
344
345 for att in self.attachments {
346 let content_type = ContentType::parse(&att.content_type)
347 .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
348 let attachment_part = Attachment::new(att.filename)
349 .body(att.data, content_type);
350 mixed = mixed.singlepart(attachment_part);
351 }
352
353 builder.multipart(mixed)
354 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
355 } else if has_text && has_html {
356 let alternative = MultiPart::alternative_plain_html(
357 self.text_body.unwrap(),
358 self.html_body.unwrap(),
359 );
360 builder.multipart(alternative)
361 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
362 } else if has_html {
363 builder
364 .header(ContentType::TEXT_HTML)
365 .body(self.html_body.unwrap())
366 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
367 } else {
368 builder
369 .header(ContentType::TEXT_PLAIN)
370 .body(self.text_body.unwrap())
371 .map_err(|e| AppError::Internal(format!("Email build error: {}", e)))
372 }
373 }
374
375 fn make_body_part(
376 text_body: &Option<String>,
377 html_body: &Option<String>,
378 has_text: bool,
379 has_html: bool,
380 ) -> MultiPart {
381 if has_text && has_html {
382 MultiPart::alternative_plain_html(
383 text_body.clone().unwrap(),
384 html_body.clone().unwrap(),
385 )
386 } else if has_html {
387 MultiPart::alternative()
388 .singlepart(SinglePart::html(html_body.clone().unwrap()))
389 } else {
390 MultiPart::alternative()
391 .singlepart(SinglePart::plain(text_body.clone().unwrap()))
392 }
393 }
394}