Skip to main content

karbon_framework/mail/
mailer.rs

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/// Email sender using SMTP
13#[derive(Clone)]
14pub struct Mailer {
15    transport: AsyncSmtpTransport<Tokio1Executor>,
16    from: String,
17}
18
19impl Mailer {
20    /// Create a new mailer from app config
21    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    /// Start building an email with this mailer's transport and from address
40    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    /// Send a plain text email (shortcut, backward compatible)
57    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    /// Send an HTML email (shortcut, backward compatible)
67    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/// Email priority
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Priority {
80    High,
81    Normal,
82    Low,
83}
84
85/// An email attachment
86#[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    /// Create an attachment from raw bytes
95    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    /// Create an attachment by reading a file from disk
104    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
156/// Email builder with fluent API
157pub 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    /// Override the sender (FROM) address
178    pub fn from(mut self, address: &str) -> Self {
179        self.from = address.to_string();
180        self
181    }
182
183    /// Add a recipient (TO)
184    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    /// Add multiple recipients (TO)
191    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    /// Add a CC recipient
200    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    /// Add multiple CC recipients
207    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    /// Add a BCC recipient
216    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    /// Add multiple BCC recipients
223    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    /// Set a Reply-To address
232    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    /// Set the subject
239    pub fn subject(mut self, subject: &str) -> Self {
240        self.subject = subject.to_string();
241        self
242    }
243
244    /// Set the plain text body
245    pub fn text(mut self, body: &str) -> Self {
246        self.text_body = Some(body.to_string());
247        self
248    }
249
250    /// Set the HTML body
251    pub fn html(mut self, body: &str) -> Self {
252        self.html_body = Some(body.to_string());
253        self
254    }
255
256    /// Set both plain text and HTML body (multipart/alternative)
257    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    /// Add an attachment from bytes
264    pub fn attach(mut self, attachment: MailAttachment) -> Self {
265        self.attachments.push(attachment);
266        self
267    }
268
269    /// Add an attachment from a file path
270    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    /// Set email priority
276    pub fn priority(mut self, priority: Priority) -> Self {
277        self.priority = priority;
278        self
279    }
280
281    /// Build and send the email
282    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        // Recipients
311        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        // Reply-To
322        if let Some(reply_to) = &self.reply_to {
323            builder = builder.reply_to(parse_mailbox(reply_to)?);
324        }
325
326        // Subject
327        builder = builder.subject(&self.subject);
328
329        // Build body
330        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            // Mixed multipart: body + attachments
336            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}