Skip to main content

avila_cell/
mime.rs

1//! MIME (Multipurpose Internet Mail Extensions) support
2
3use crate::encoding::{base64_encode, quoted_printable_encode, generate_boundary};
4
5/// MIME types
6pub mod types {
7    /// Text MIME types
8    pub const TEXT_PLAIN: &str = "text/plain";
9    /// HTML MIME type
10    pub const TEXT_HTML: &str = "text/html";
11    /// Calendar MIME type
12    pub const TEXT_CALENDAR: &str = "text/calendar";
13    /// Binary application type
14    pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
15    /// PDF type
16    pub const APPLICATION_PDF: &str = "application/pdf";
17    /// JPEG image
18    pub const IMAGE_JPEG: &str = "image/jpeg";
19    /// PNG image
20    pub const IMAGE_PNG: &str = "image/png";
21    /// ZIP archive
22    pub const APPLICATION_ZIP: &str = "application/zip";
23    /// JSON
24    pub const APPLICATION_JSON: &str = "application/json";
25}
26
27/// Content transfer encoding
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TransferEncoding {
30    /// 7-bit ASCII
31    SevenBit,
32    /// 8-bit
33    EightBit,
34    /// Base64 encoding
35    Base64,
36    /// Quoted-Printable
37    QuotedPrintable,
38}
39
40impl TransferEncoding {
41    /// Returns the string representation
42    pub fn as_str(&self) -> &'static str {
43        match self {
44            Self::SevenBit => "7bit",
45            Self::EightBit => "8bit",
46            Self::Base64 => "base64",
47            Self::QuotedPrintable => "quoted-printable",
48        }
49    }
50}
51
52/// MIME part
53#[derive(Debug, Clone)]
54pub struct MimePart {
55    /// Content type
56    pub content_type: String,
57    /// Transfer encoding
58    pub encoding: TransferEncoding,
59    /// Content disposition (inline or attachment)
60    pub disposition: Option<String>,
61    /// Filename (for attachments)
62    pub filename: Option<String>,
63    /// Content ID (for embedded images)
64    pub content_id: Option<String>,
65    /// Body content
66    pub content: Vec<u8>,
67}
68
69impl MimePart {
70    /// Creates a text part
71    pub fn text(content: impl Into<String>) -> Self {
72        Self {
73            content_type: format!("{}; charset=utf-8", types::TEXT_PLAIN),
74            encoding: TransferEncoding::QuotedPrintable,
75            disposition: None,
76            filename: None,
77            content_id: None,
78            content: content.into().into_bytes(),
79        }
80    }
81
82    /// Creates an HTML part
83    pub fn html(content: impl Into<String>) -> Self {
84        Self {
85            content_type: format!("{}; charset=utf-8", types::TEXT_HTML),
86            encoding: TransferEncoding::QuotedPrintable,
87            disposition: None,
88            filename: None,
89            content_id: None,
90            content: content.into().into_bytes(),
91        }
92    }
93
94    /// Creates an attachment part
95    pub fn attachment(filename: impl Into<String>, content_type: impl Into<String>, content: Vec<u8>) -> Self {
96        let fname = filename.into();
97        Self {
98            content_type: content_type.into(),
99            encoding: TransferEncoding::Base64,
100            disposition: Some(format!("attachment; filename=\"{}\"", fname)),
101            filename: Some(fname),
102            content_id: None,
103            content,
104        }
105    }
106
107    /// Creates an inline image part
108    pub fn inline_image(cid: impl Into<String>, content_type: impl Into<String>, content: Vec<u8>) -> Self {
109        Self {
110            content_type: content_type.into(),
111            encoding: TransferEncoding::Base64,
112            disposition: Some("inline".to_string()),
113            filename: None,
114            content_id: Some(cid.into()),
115            content,
116        }
117    }
118
119    /// Encodes the content based on transfer encoding
120    pub fn encode_content(&self) -> String {
121        match self.encoding {
122            TransferEncoding::Base64 => {
123                let encoded = base64_encode(&self.content);
124                // Break into 76-character lines
125                let mut result = String::new();
126                for (i, chunk) in encoded.as_bytes().chunks(76).enumerate() {
127                    if i > 0 {
128                        result.push_str("\r\n");
129                    }
130                    result.push_str(&String::from_utf8_lossy(chunk));
131                }
132                result
133            }
134            TransferEncoding::QuotedPrintable => {
135                quoted_printable_encode(&String::from_utf8_lossy(&self.content))
136            }
137            _ => String::from_utf8_lossy(&self.content).to_string(),
138        }
139    }
140
141    /// Converts to MIME format
142    pub fn to_mime(&self) -> String {
143        let mut mime = String::new();
144
145        mime.push_str(&format!("Content-Type: {}\r\n", self.content_type));
146        mime.push_str(&format!("Content-Transfer-Encoding: {}\r\n", self.encoding.as_str()));
147
148        if let Some(ref disp) = self.disposition {
149            mime.push_str(&format!("Content-Disposition: {}\r\n", disp));
150        }
151
152        if let Some(ref cid) = self.content_id {
153            mime.push_str(&format!("Content-ID: <{}>\r\n", cid));
154        }
155
156        mime.push_str("\r\n");
157        mime.push_str(&self.encode_content());
158
159        mime
160    }
161}
162
163/// Multipart message builder
164#[derive(Debug)]
165pub struct MultipartBuilder {
166    /// Multipart type (mixed, alternative, related)
167    multipart_type: String,
168    /// MIME parts
169    parts: Vec<MimePart>,
170    /// Boundary
171    boundary: String,
172}
173
174impl MultipartBuilder {
175    /// Creates a new multipart/mixed builder
176    pub fn mixed() -> Self {
177        Self {
178            multipart_type: "mixed".to_string(),
179            parts: Vec::new(),
180            boundary: generate_boundary(),
181        }
182    }
183
184    /// Creates a new multipart/alternative builder
185    pub fn alternative() -> Self {
186        Self {
187            multipart_type: "alternative".to_string(),
188            parts: Vec::new(),
189            boundary: generate_boundary(),
190        }
191    }
192
193    /// Creates a new multipart/related builder (for HTML with inline images)
194    pub fn related() -> Self {
195        Self {
196            multipart_type: "related".to_string(),
197            parts: Vec::new(),
198            boundary: generate_boundary(),
199        }
200    }
201
202    /// Adds a MIME part
203    pub fn add_part(mut self, part: MimePart) -> Self {
204        self.parts.push(part);
205        self
206    }
207
208    /// Gets the Content-Type header
209    pub fn content_type(&self) -> String {
210        format!("multipart/{}; boundary=\"{}\"", self.multipart_type, self.boundary)
211    }
212
213    /// Builds the multipart body
214    pub fn build(&self) -> String {
215        let mut body = String::new();
216
217        for part in &self.parts {
218            body.push_str(&format!("--{}\r\n", self.boundary));
219            body.push_str(&part.to_mime());
220            body.push_str("\r\n");
221        }
222
223        body.push_str(&format!("--{}--\r\n", self.boundary));
224
225        body
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn test_text_part() {
235        let part = MimePart::text("Hello, World!");
236        assert_eq!(part.encoding, TransferEncoding::QuotedPrintable);
237        assert!(part.content_type.contains("text/plain"));
238    }
239
240    #[test]
241    fn test_html_part() {
242        let part = MimePart::html("<h1>Hello</h1>");
243        assert_eq!(part.encoding, TransferEncoding::QuotedPrintable);
244        assert!(part.content_type.contains("text/html"));
245    }
246
247    #[test]
248    fn test_attachment() {
249        let part = MimePart::attachment("test.pdf", types::APPLICATION_PDF, vec![1, 2, 3]);
250        assert_eq!(part.encoding, TransferEncoding::Base64);
251        assert!(part.disposition.is_some());
252    }
253
254    #[test]
255    fn test_multipart_builder() {
256        let multipart = MultipartBuilder::alternative()
257            .add_part(MimePart::text("Plain text"))
258            .add_part(MimePart::html("<p>HTML</p>"));
259
260        let body = multipart.build();
261        let content_type = multipart.content_type();
262
263        assert!(content_type.contains("multipart/alternative"));
264        assert!(body.contains("text/plain"));
265        assert!(body.contains("text/html"));
266    }
267}