lettre/message/
mimebody.rs

1use std::{io::Write, iter::repeat_with};
2
3use mime::Mime;
4
5use crate::message::{
6    header::{self, ContentTransferEncoding, ContentType, Header, Headers},
7    EmailFormat, IntoBody,
8};
9
10/// MIME part variants
11#[derive(Debug, Clone)]
12pub(super) enum Part {
13    /// Single part with content
14    Single(SinglePart),
15
16    /// Multiple parts of content
17    Multi(MultiPart),
18}
19
20impl Part {
21    #[cfg(feature = "dkim")]
22    pub(super) fn format_body(&self, out: &mut Vec<u8>) {
23        match self {
24            Part::Single(part) => part.format_body(out),
25            Part::Multi(part) => part.format_body(out),
26        }
27    }
28}
29
30impl EmailFormat for Part {
31    fn format(&self, out: &mut Vec<u8>) {
32        match self {
33            Part::Single(part) => part.format(out),
34            Part::Multi(part) => part.format(out),
35        }
36    }
37}
38
39/// Creates builder for single part
40#[derive(Debug, Clone)]
41pub struct SinglePartBuilder {
42    headers: Headers,
43}
44
45impl SinglePartBuilder {
46    /// Creates a default singlepart builder
47    pub fn new() -> Self {
48        Self {
49            headers: Headers::new(),
50        }
51    }
52
53    /// Set the header to singlepart
54    pub fn header<H: Header>(mut self, header: H) -> Self {
55        self.headers.set(header);
56        self
57    }
58
59    /// Set the Content-Type header of the singlepart
60    pub fn content_type(mut self, content_type: ContentType) -> Self {
61        self.headers.set(content_type);
62        self
63    }
64
65    /// Build singlepart using body
66    pub fn body<T: IntoBody>(mut self, body: T) -> SinglePart {
67        let maybe_encoding = self.headers.get::<ContentTransferEncoding>();
68        let body = body.into_body(maybe_encoding);
69
70        self.headers.set(body.encoding());
71
72        SinglePart {
73            headers: self.headers,
74            body: body.into_vec(),
75        }
76    }
77}
78
79impl Default for SinglePartBuilder {
80    fn default() -> Self {
81        Self::new()
82    }
83}
84
85/// Single part
86///
87/// # Example
88///
89/// ```
90/// use lettre::message::{header, SinglePart};
91///
92/// # use std::error::Error;
93/// # fn main() -> Result<(), Box<dyn Error>> {
94/// let part = SinglePart::builder()
95///     .header(header::ContentType::TEXT_PLAIN)
96///     .body(String::from("Текст письма в уникоде"));
97/// # Ok(())
98/// # }
99/// ```
100#[derive(Debug, Clone)]
101pub struct SinglePart {
102    headers: Headers,
103    body: Vec<u8>,
104}
105
106impl SinglePart {
107    /// Creates a builder for singlepart
108    #[inline]
109    pub fn builder() -> SinglePartBuilder {
110        SinglePartBuilder::new()
111    }
112
113    /// Directly create a `SinglePart` from a plain UTF-8 content
114    pub fn plain<T: IntoBody>(body: T) -> Self {
115        Self::builder()
116            .header(header::ContentType::TEXT_PLAIN)
117            .body(body)
118    }
119
120    /// Directly create a `SinglePart` from a UTF-8 HTML content
121    pub fn html<T: IntoBody>(body: T) -> Self {
122        Self::builder()
123            .header(header::ContentType::TEXT_HTML)
124            .body(body)
125    }
126
127    /// Get the headers from singlepart
128    #[inline]
129    pub fn headers(&self) -> &Headers {
130        &self.headers
131    }
132
133    /// Get the encoded body
134    #[inline]
135    pub fn raw_body(&self) -> &[u8] {
136        &self.body
137    }
138
139    /// Get message content formatted for sending
140    pub fn formatted(&self) -> Vec<u8> {
141        let mut out = Vec::new();
142        self.format(&mut out);
143        out
144    }
145
146    /// Format only the signlepart body
147    fn format_body(&self, out: &mut Vec<u8>) {
148        out.extend_from_slice(&self.body);
149        out.extend_from_slice(b"\r\n");
150    }
151}
152
153impl EmailFormat for SinglePart {
154    fn format(&self, out: &mut Vec<u8>) {
155        write!(out, "{}", self.headers)
156            .expect("A Write implementation panicked while formatting headers");
157        out.extend_from_slice(b"\r\n");
158        self.format_body(out);
159    }
160}
161
162/// The kind of multipart
163#[derive(Debug, Clone)]
164pub enum MultiPartKind {
165    /// Mixed kind to combine unrelated content parts
166    ///
167    /// For example, this kind can be used to mix an email message and attachments.
168    Mixed,
169
170    /// Alternative kind to join several variants of same email contents.
171    ///
172    /// That kind is recommended to use for joining plain (text) and rich (HTML) messages into a single email message.
173    Alternative,
174
175    /// Related kind to mix content and related resources.
176    ///
177    /// For example, you can include images in HTML content using that.
178    Related,
179
180    /// Encrypted kind for encrypted messages
181    Encrypted { protocol: String },
182
183    /// Signed kind for signed messages
184    Signed { protocol: String, micalg: String },
185}
186
187/// Create a random MIME boundary.
188/// (Not cryptographically random)
189fn make_boundary() -> String {
190    repeat_with(fastrand::alphanumeric).take(40).collect()
191}
192
193impl MultiPartKind {
194    pub(crate) fn to_mime<S: Into<String>>(&self, boundary: Option<S>) -> Mime {
195        let boundary = boundary.map_or_else(make_boundary, Into::into);
196
197        format!(
198            "multipart/{}; boundary=\"{}\"{}",
199            match self {
200                Self::Mixed => "mixed",
201                Self::Alternative => "alternative",
202                Self::Related => "related",
203                Self::Encrypted { .. } => "encrypted",
204                Self::Signed { .. } => "signed",
205            },
206            boundary,
207            match self {
208                Self::Encrypted { protocol } => format!("; protocol=\"{protocol}\""),
209                Self::Signed { protocol, micalg } =>
210                    format!("; protocol=\"{protocol}\"; micalg=\"{micalg}\""),
211                _ => String::new(),
212            }
213        )
214        .parse()
215        .unwrap()
216    }
217
218    fn from_mime(m: &Mime) -> Option<Self> {
219        match m.subtype().as_ref() {
220            "mixed" => Some(Self::Mixed),
221            "alternative" => Some(Self::Alternative),
222            "related" => Some(Self::Related),
223            "signed" => m.get_param("protocol").and_then(|p| {
224                m.get_param("micalg").map(|micalg| Self::Signed {
225                    protocol: p.as_str().to_owned(),
226                    micalg: micalg.as_str().to_owned(),
227                })
228            }),
229            "encrypted" => m.get_param("protocol").map(|p| Self::Encrypted {
230                protocol: p.as_str().to_owned(),
231            }),
232            _ => None,
233        }
234    }
235}
236
237/// Multipart builder
238#[derive(Debug, Clone)]
239pub struct MultiPartBuilder {
240    headers: Headers,
241}
242
243impl MultiPartBuilder {
244    /// Creates default multipart builder
245    pub fn new() -> Self {
246        Self {
247            headers: Headers::new(),
248        }
249    }
250
251    /// Set a header
252    pub fn header<H: Header>(mut self, header: H) -> Self {
253        self.headers.set(header);
254        self
255    }
256
257    /// Set `Content-Type` header using [`MultiPartKind`]
258    pub fn kind(self, kind: MultiPartKind) -> Self {
259        self.header(ContentType::from_mime(kind.to_mime::<String>(None)))
260    }
261
262    /// Set custom boundary
263    pub fn boundary<S: Into<String>>(self, boundary: S) -> Self {
264        let kind = {
265            let content_type = self.headers.get::<ContentType>().unwrap();
266            MultiPartKind::from_mime(content_type.as_ref()).unwrap()
267        };
268        let mime = kind.to_mime(Some(boundary));
269        self.header(ContentType::from_mime(mime))
270    }
271
272    /// Creates multipart without parts
273    pub fn build(self) -> MultiPart {
274        MultiPart {
275            headers: self.headers,
276            parts: Vec::new(),
277        }
278    }
279
280    /// Creates multipart using singlepart
281    pub fn singlepart(self, part: SinglePart) -> MultiPart {
282        self.build().singlepart(part)
283    }
284
285    /// Creates multipart using multipart
286    pub fn multipart(self, part: MultiPart) -> MultiPart {
287        self.build().multipart(part)
288    }
289}
290
291impl Default for MultiPartBuilder {
292    fn default() -> Self {
293        Self::new()
294    }
295}
296
297/// Multipart variant with parts
298#[derive(Debug, Clone)]
299pub struct MultiPart {
300    headers: Headers,
301    parts: Vec<Part>,
302}
303
304impl MultiPart {
305    /// Creates multipart builder
306    pub fn builder() -> MultiPartBuilder {
307        MultiPartBuilder::new()
308    }
309
310    /// Creates mixed multipart builder
311    ///
312    /// Shortcut for `MultiPart::builder().kind(MultiPartKind::Mixed)`
313    pub fn mixed() -> MultiPartBuilder {
314        MultiPart::builder().kind(MultiPartKind::Mixed)
315    }
316
317    /// Creates alternative multipart builder
318    ///
319    /// Shortcut for `MultiPart::builder().kind(MultiPartKind::Alternative)`
320    pub fn alternative() -> MultiPartBuilder {
321        MultiPart::builder().kind(MultiPartKind::Alternative)
322    }
323
324    /// Creates related multipart builder
325    ///
326    /// Shortcut for `MultiPart::builder().kind(MultiPartKind::Related)`
327    pub fn related() -> MultiPartBuilder {
328        MultiPart::builder().kind(MultiPartKind::Related)
329    }
330
331    /// Creates encrypted multipart builder
332    ///
333    /// Shortcut for `MultiPart::builder().kind(MultiPartKind::Encrypted{ protocol })`
334    pub fn encrypted(protocol: String) -> MultiPartBuilder {
335        MultiPart::builder().kind(MultiPartKind::Encrypted { protocol })
336    }
337
338    /// Creates signed multipart builder
339    ///
340    /// Shortcut for `MultiPart::builder().kind(MultiPartKind::Signed{ protocol, micalg })`
341    pub fn signed(protocol: String, micalg: String) -> MultiPartBuilder {
342        MultiPart::builder().kind(MultiPartKind::Signed { protocol, micalg })
343    }
344
345    /// Alias for HTML and plain text versions of an email
346    pub fn alternative_plain_html<T: IntoBody, V: IntoBody>(plain: T, html: V) -> Self {
347        Self::alternative()
348            .singlepart(SinglePart::plain(plain))
349            .singlepart(SinglePart::html(html))
350    }
351
352    /// Add single part to multipart
353    pub fn singlepart(mut self, part: SinglePart) -> Self {
354        self.parts.push(Part::Single(part));
355        self
356    }
357
358    /// Add multi part to multipart
359    pub fn multipart(mut self, part: MultiPart) -> Self {
360        self.parts.push(Part::Multi(part));
361        self
362    }
363
364    /// Get the boundary of multipart contents
365    pub fn boundary(&self) -> String {
366        let content_type = self.headers.get::<ContentType>().unwrap();
367        content_type
368            .as_ref()
369            .get_param("boundary")
370            .unwrap()
371            .as_str()
372            .into()
373    }
374
375    /// Get the headers from the multipart
376    pub fn headers(&self) -> &Headers {
377        &self.headers
378    }
379
380    /// Get a mutable reference to the headers
381    pub fn headers_mut(&mut self) -> &mut Headers {
382        &mut self.headers
383    }
384
385    /// Get message content formatted for SMTP
386    pub fn formatted(&self) -> Vec<u8> {
387        let mut out = Vec::new();
388        self.format(&mut out);
389        out
390    }
391
392    /// Format only the multipart body
393    fn format_body(&self, out: &mut Vec<u8>) {
394        let boundary = self.boundary();
395
396        for part in &self.parts {
397            out.extend_from_slice(b"--");
398            out.extend_from_slice(boundary.as_bytes());
399            out.extend_from_slice(b"\r\n");
400            part.format(out);
401        }
402
403        out.extend_from_slice(b"--");
404        out.extend_from_slice(boundary.as_bytes());
405        out.extend_from_slice(b"--\r\n");
406    }
407}
408
409impl EmailFormat for MultiPart {
410    fn format(&self, out: &mut Vec<u8>) {
411        write!(out, "{}", self.headers)
412            .expect("A Write implementation panicked while formatting headers");
413        out.extend_from_slice(b"\r\n");
414        self.format_body(out);
415    }
416}
417
418#[cfg(test)]
419mod test {
420    use pretty_assertions::assert_eq;
421
422    use super::*;
423
424    #[test]
425    fn single_part_binary() {
426        let part = SinglePart::builder()
427            .header(header::ContentType::TEXT_PLAIN)
428            .header(header::ContentTransferEncoding::Binary)
429            .body(String::from("Текст письма в уникоде"));
430
431        assert_eq!(
432            String::from_utf8(part.formatted()).unwrap(),
433            concat!(
434                "Content-Type: text/plain; charset=utf-8\r\n",
435                "Content-Transfer-Encoding: binary\r\n",
436                "\r\n",
437                "Текст письма в уникоде\r\n"
438            )
439        );
440    }
441
442    #[test]
443    fn single_part_quoted_printable() {
444        let part = SinglePart::builder()
445            .header(header::ContentType::TEXT_PLAIN)
446            .header(header::ContentTransferEncoding::QuotedPrintable)
447            .body(String::from("Текст письма в уникоде"));
448
449        assert_eq!(
450            String::from_utf8(part.formatted()).unwrap(),
451            concat!(
452                "Content-Type: text/plain; charset=utf-8\r\n",
453                "Content-Transfer-Encoding: quoted-printable\r\n",
454                "\r\n",
455                "=D0=A2=D0=B5=D0=BA=D1=81=D1=82 =D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=B0 =D0=B2 =\r\n",
456                "=D1=83=D0=BD=D0=B8=D0=BA=D0=BE=D0=B4=D0=B5\r\n"
457            )
458        );
459    }
460
461    #[test]
462    fn single_part_base64() {
463        let part = SinglePart::builder()
464            .header(header::ContentType::TEXT_PLAIN)
465            .header(header::ContentTransferEncoding::Base64)
466            .body(String::from("Текст письма в уникоде"));
467
468        assert_eq!(
469            String::from_utf8(part.formatted()).unwrap(),
470            concat!(
471                "Content-Type: text/plain; charset=utf-8\r\n",
472                "Content-Transfer-Encoding: base64\r\n",
473                "\r\n",
474                "0KLQtdC60YHRgiDQv9C40YHRjNC80LAg0LIg0YPQvdC40LrQvtC00LU=\r\n"
475            )
476        );
477    }
478
479    #[test]
480    fn multi_part_mixed() {
481        let part = MultiPart::mixed()
482            .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
483            .singlepart(
484                SinglePart::builder()
485                    .header(header::ContentType::TEXT_PLAIN)
486                    .header(header::ContentTransferEncoding::Binary)
487                    .body(String::from("Текст письма в уникоде")),
488            )
489            .singlepart(
490                SinglePart::builder()
491                    .header(header::ContentType::TEXT_PLAIN)
492                    .header(header::ContentDisposition::attachment("example.c"))
493                    .header(header::ContentTransferEncoding::Binary)
494                    .body(String::from("int main() { return 0; }")),
495            );
496
497        assert_eq!(
498            String::from_utf8(part.formatted()).unwrap(),
499            concat!(
500                "Content-Type: multipart/mixed;\r\n",
501                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
502                "\r\n",
503                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
504                "Content-Type: text/plain; charset=utf-8\r\n",
505                "Content-Transfer-Encoding: binary\r\n",
506                "\r\n",
507                "Текст письма в уникоде\r\n",
508                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
509                "Content-Type: text/plain; charset=utf-8\r\n",
510                "Content-Disposition: attachment; filename=\"example.c\"\r\n",
511                "Content-Transfer-Encoding: binary\r\n",
512                "\r\n",
513                "int main() { return 0; }\r\n",
514                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n"
515            )
516        );
517    }
518    #[test]
519    fn multi_part_encrypted() {
520        let part = MultiPart::encrypted("application/pgp-encrypted".to_owned())
521            .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
522            .singlepart(
523                SinglePart::builder()
524                    .header(header::ContentType::parse("application/pgp-encrypted").unwrap())
525                    .body(String::from("Version: 1")),
526            )
527            .singlepart(
528                SinglePart::builder()
529                    .header(
530                        ContentType::parse("application/octet-stream; name=\"encrypted.asc\"")
531                            .unwrap(),
532                    )
533                    .header(header::ContentDisposition::inline_with_name(
534                        "encrypted.asc",
535                    ))
536                    .body(String::from(concat!(
537                        "-----BEGIN PGP MESSAGE-----\r\n",
538                        "wV4D0dz5vDXklO8SAQdA5lGX1UU/eVQqDxNYdHa7tukoingHzqUB6wQssbMfHl8w\r\n",
539                        "...\r\n",
540                        "-----END PGP MESSAGE-----\r\n"
541                    ))),
542            );
543
544        assert_eq!(
545            String::from_utf8(part.formatted()).unwrap(),
546            concat!(
547                "Content-Type: multipart/encrypted;\r\n",
548                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\";\r\n",
549                " protocol=\"application/pgp-encrypted\"\r\n",
550                "\r\n",
551                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
552                "Content-Type: application/pgp-encrypted\r\n",
553                "Content-Transfer-Encoding: 7bit\r\n",
554                "\r\n",
555                "Version: 1\r\n",
556                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
557                "Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n",
558                "Content-Disposition: inline; filename=\"encrypted.asc\"\r\n",
559                "Content-Transfer-Encoding: 7bit\r\n",
560                "\r\n",
561                "-----BEGIN PGP MESSAGE-----\r\n",
562                "wV4D0dz5vDXklO8SAQdA5lGX1UU/eVQqDxNYdHa7tukoingHzqUB6wQssbMfHl8w\r\n",
563                "...\r\n",
564                "-----END PGP MESSAGE-----\r\n",
565                "\r\n",
566                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n"
567            )
568        );
569    }
570    #[test]
571    fn multi_part_signed() {
572        let part = MultiPart::signed(
573            "application/pgp-signature".to_owned(),
574            "pgp-sha256".to_owned(),
575        )
576        .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
577        .singlepart(
578            SinglePart::builder()
579                .header(header::ContentType::TEXT_PLAIN)
580                .body(String::from("Test email for signature")),
581        )
582        .singlepart(
583            SinglePart::builder()
584                .header(
585                    ContentType::parse("application/pgp-signature; name=\"signature.asc\"")
586                        .unwrap(),
587                )
588                .header(header::ContentDisposition::attachment("signature.asc"))
589                .body(String::from(concat!(
590                    "-----BEGIN PGP SIGNATURE-----\r\n",
591                    "\r\n",
592                    "iHUEARYIAB0WIQTNsp3S/GbdE0KoiQ+IGQOscREZuQUCXyOzDAAKCRCIGQOscREZ\r\n",
593                    "udgDAQCv3FJ3QWW5bRaGZAa0Ug6vASFdkvDMKoRwcoFnHPthjQEAiQ8skkIyE2GE\r\n",
594                    "PoLpAXiKpT+NU8S8+8dfvwutnb4dSwM=\r\n",
595                    "=3FYZ\r\n",
596                    "-----END PGP SIGNATURE-----\r\n",
597                ))),
598        );
599
600        assert_eq!(
601            String::from_utf8(part.formatted()).unwrap(),
602            concat!(
603                "Content-Type: multipart/signed;\r\n",
604                " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\";\r\n",
605                " protocol=\"application/pgp-signature\";",
606                " micalg=\"pgp-sha256\"\r\n",
607                "\r\n",
608                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
609                "Content-Type: text/plain; charset=utf-8\r\n",
610                "Content-Transfer-Encoding: 7bit\r\n",
611                "\r\n",
612                "Test email for signature\r\n",
613                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
614                "Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n",
615                "Content-Disposition: attachment; filename=\"signature.asc\"\r\n",
616                "Content-Transfer-Encoding: 7bit\r\n",
617                "\r\n",
618                "-----BEGIN PGP SIGNATURE-----\r\n",
619                "\r\n",
620                "iHUEARYIAB0WIQTNsp3S/GbdE0KoiQ+IGQOscREZuQUCXyOzDAAKCRCIGQOscREZ\r\n",
621                "udgDAQCv3FJ3QWW5bRaGZAa0Ug6vASFdkvDMKoRwcoFnHPthjQEAiQ8skkIyE2GE\r\n",
622                "PoLpAXiKpT+NU8S8+8dfvwutnb4dSwM=\r\n",
623                "=3FYZ\r\n",
624                "-----END PGP SIGNATURE-----\r\n",
625                "\r\n",
626                "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n"
627            )
628        );
629    }
630
631    #[test]
632    fn multi_part_alternative() {
633        let part = MultiPart::alternative()
634            .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
635            .singlepart(SinglePart::builder()
636                             .header(header::ContentType::TEXT_PLAIN)
637                             .header(header::ContentTransferEncoding::Binary)
638                             .body(String::from("Текст письма в уникоде")))
639            .singlepart(SinglePart::builder()
640                             .header(header::ContentType::TEXT_HTML)
641                             .header(header::ContentTransferEncoding::Binary)
642                             .body(String::from("<p>Текст <em>письма</em> в <a href=\"https://ru.wikipedia.org/wiki/Юникод\">уникоде</a><p>")));
643
644        assert_eq!(String::from_utf8(part.formatted()).unwrap(),
645                   concat!("Content-Type: multipart/alternative;\r\n",
646                           " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
647                           "\r\n",
648                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
649                           "Content-Type: text/plain; charset=utf-8\r\n",
650                           "Content-Transfer-Encoding: binary\r\n",
651                           "\r\n",
652                           "Текст письма в уникоде\r\n",
653                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
654                           "Content-Type: text/html; charset=utf-8\r\n",
655                           "Content-Transfer-Encoding: binary\r\n",
656                           "\r\n",
657                           "<p>Текст <em>письма</em> в <a href=\"https://ru.wikipedia.org/wiki/Юникод\">уникоде</a><p>\r\n",
658                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n"));
659    }
660
661    #[test]
662    fn multi_part_mixed_related() {
663        let part = MultiPart::mixed()
664            .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
665            .multipart(MultiPart::related()
666                            .boundary("0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1")
667                            .singlepart(SinglePart::builder()
668                                             .header(header::ContentType::TEXT_HTML)
669                                             .header(header::ContentTransferEncoding::Binary)
670                                             .body(String::from("<p>Текст <em>письма</em> в <a href=\"https://ru.wikipedia.org/wiki/Юникод\">уникоде</a><p>")))
671                            .singlepart(SinglePart::builder()
672                                             .header(header::ContentType::parse("image/png").unwrap())
673                                             .header(header::ContentLocation::from(String::from("/image.png")))
674                                             .header(header::ContentTransferEncoding::Base64)
675                                             .body(String::from("1234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890"))))
676            .singlepart(SinglePart::builder()
677                             .header(header::ContentType::TEXT_PLAIN)
678                             .header(header::ContentDisposition::attachment("example.c"))
679                             .header(header::ContentTransferEncoding::Binary)
680                             .body(String::from("int main() { return 0; }")));
681
682        assert_eq!(String::from_utf8(part.formatted()).unwrap(),
683                   concat!("Content-Type: multipart/mixed;\r\n",
684                           " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
685                           "\r\n",
686                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
687                           "Content-Type: multipart/related;\r\n",
688                           " boundary=\"0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\"\r\n",
689                           "\r\n",
690                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
691                           "Content-Type: text/html; charset=utf-8\r\n",
692                           "Content-Transfer-Encoding: binary\r\n",
693                           "\r\n",
694                           "<p>Текст <em>письма</em> в <a href=\"https://ru.wikipedia.org/wiki/Юникод\">уникоде</a><p>\r\n",
695                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
696                           "Content-Type: image/png\r\n",
697                           "Content-Location: /image.png\r\n",
698                           "Content-Transfer-Encoding: base64\r\n",
699                           "\r\n",
700                           "MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3\r\n",
701                           "ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0\r\n",
702                           "NTY3ODkwMTIzNDU2Nzg5MA==\r\n",
703                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n",
704                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1\r\n",
705                           "Content-Type: text/plain; charset=utf-8\r\n",
706                           "Content-Disposition: attachment; filename=\"example.c\"\r\n",
707                           "Content-Transfer-Encoding: binary\r\n",
708                           "\r\n",
709                           "int main() { return 0; }\r\n",
710                           "--0oVZ2r6AoLAhLlb0gPNSKy6BEqdS2IfwxrcbUuo1--\r\n"));
711    }
712
713    #[test]
714    fn test_make_boundary() {
715        let mut boundaries = std::collections::HashSet::with_capacity(10);
716        for _ in 0..1000 {
717            boundaries.insert(make_boundary());
718        }
719
720        // Ensure there are no duplicates
721        assert_eq!(1000, boundaries.len());
722
723        // Ensure correct length
724        for boundary in boundaries {
725            assert_eq!(40, boundary.len());
726        }
727    }
728}