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    /// Add a recipient (TO)
178    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    /// Add multiple recipients (TO)
185    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    /// Add a CC recipient
194    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    /// Add multiple CC recipients
201    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    /// Add a BCC recipient
210    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    /// Add multiple BCC recipients
217    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    /// Set a Reply-To address
226    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    /// Set the subject
233    pub fn subject(mut self, subject: &str) -> Self {
234        self.subject = subject.to_string();
235        self
236    }
237
238    /// Set the plain text body
239    pub fn text(mut self, body: &str) -> Self {
240        self.text_body = Some(body.to_string());
241        self
242    }
243
244    /// Set the HTML body
245    pub fn html(mut self, body: &str) -> Self {
246        self.html_body = Some(body.to_string());
247        self
248    }
249
250    /// Set both plain text and HTML body (multipart/alternative)
251    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    /// Add an attachment from bytes
258    pub fn attach(mut self, attachment: MailAttachment) -> Self {
259        self.attachments.push(attachment);
260        self
261    }
262
263    /// Add an attachment from a file path
264    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    /// Set email priority
270    pub fn priority(mut self, priority: Priority) -> Self {
271        self.priority = priority;
272        self
273    }
274
275    /// Build and send the email
276    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        // Recipients
305        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        // Reply-To
316        if let Some(reply_to) = &self.reply_to {
317            builder = builder.reply_to(parse_mailbox(reply_to)?);
318        }
319
320        // Subject
321        builder = builder.subject(&self.subject);
322
323        // Build body
324        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            // Mixed multipart: body + attachments
330            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}