1use crate::encoding::{base64_encode, quoted_printable_encode, generate_boundary};
4
5pub mod types {
7 pub const TEXT_PLAIN: &str = "text/plain";
9 pub const TEXT_HTML: &str = "text/html";
11 pub const TEXT_CALENDAR: &str = "text/calendar";
13 pub const APPLICATION_OCTET_STREAM: &str = "application/octet-stream";
15 pub const APPLICATION_PDF: &str = "application/pdf";
17 pub const IMAGE_JPEG: &str = "image/jpeg";
19 pub const IMAGE_PNG: &str = "image/png";
21 pub const APPLICATION_ZIP: &str = "application/zip";
23 pub const APPLICATION_JSON: &str = "application/json";
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum TransferEncoding {
30 SevenBit,
32 EightBit,
34 Base64,
36 QuotedPrintable,
38}
39
40impl TransferEncoding {
41 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#[derive(Debug, Clone)]
54pub struct MimePart {
55 pub content_type: String,
57 pub encoding: TransferEncoding,
59 pub disposition: Option<String>,
61 pub filename: Option<String>,
63 pub content_id: Option<String>,
65 pub content: Vec<u8>,
67}
68
69impl MimePart {
70 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 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 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 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 pub fn encode_content(&self) -> String {
121 match self.encoding {
122 TransferEncoding::Base64 => {
123 let encoded = base64_encode(&self.content);
124 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 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#[derive(Debug)]
165pub struct MultipartBuilder {
166 multipart_type: String,
168 parts: Vec<MimePart>,
170 boundary: String,
172}
173
174impl MultipartBuilder {
175 pub fn mixed() -> Self {
177 Self {
178 multipart_type: "mixed".to_string(),
179 parts: Vec::new(),
180 boundary: generate_boundary(),
181 }
182 }
183
184 pub fn alternative() -> Self {
186 Self {
187 multipart_type: "alternative".to_string(),
188 parts: Vec::new(),
189 boundary: generate_boundary(),
190 }
191 }
192
193 pub fn related() -> Self {
195 Self {
196 multipart_type: "related".to_string(),
197 parts: Vec::new(),
198 boundary: generate_boundary(),
199 }
200 }
201
202 pub fn add_part(mut self, part: MimePart) -> Self {
204 self.parts.push(part);
205 self
206 }
207
208 pub fn content_type(&self) -> String {
210 format!("multipart/{}; boundary=\"{}\"", self.multipart_type, self.boundary)
211 }
212
213 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}