1use std::fmt;
10
11use lettre::message::header::ContentType;
12use lettre::message::{Attachment, Mailbox, Message, MessageBuilder, MultiPart, SinglePart};
13
14use crate::mail::error::EmailError;
15#[cfg(feature = "views")]
16use crate::mail::error::MailViewError;
17
18#[derive(Clone)]
21pub struct Email {
22 builder: MessageBuilder,
23}
24
25impl Email {
26 #[must_use]
28 pub fn builder() -> Self {
29 Self {
30 builder: Message::builder(),
31 }
32 }
33
34 #[must_use]
36 pub fn from_builder(builder: MessageBuilder) -> Self {
37 Self { builder }
38 }
39
40 #[must_use]
42 pub fn from(mut self, mailbox: Mailbox) -> Self {
43 self.builder = self.builder.from(mailbox);
44 self
45 }
46
47 #[must_use]
49 pub fn reply_to(mut self, mailbox: Mailbox) -> Self {
50 self.builder = self.builder.reply_to(mailbox);
51 self
52 }
53
54 #[must_use]
56 pub fn to(mut self, mailbox: Mailbox) -> Self {
57 self.builder = self.builder.to(mailbox);
58 self
59 }
60
61 #[must_use]
63 pub fn cc(mut self, mailbox: Mailbox) -> Self {
64 self.builder = self.builder.cc(mailbox);
65 self
66 }
67
68 #[must_use]
70 pub fn bcc(mut self, mailbox: Mailbox) -> Self {
71 self.builder = self.builder.bcc(mailbox);
72 self
73 }
74
75 #[must_use]
77 pub fn subject(mut self, subject: impl Into<String>) -> Self {
78 self.builder = self.builder.subject(subject);
79 self
80 }
81
82 pub fn plain(self, body: impl Into<String>) -> Result<Message, EmailError> {
88 let body: String = body.into();
89 self.builder
90 .header(ContentType::TEXT_PLAIN)
91 .body(body)
92 .map_err(EmailError::build)
93 }
94
95 pub fn html(self, body: impl Into<String>) -> Result<Message, EmailError> {
101 let html_part = SinglePart::builder()
102 .header(ContentType::TEXT_HTML)
103 .body(body.into());
104 self.builder
105 .singlepart(html_part)
106 .map_err(EmailError::build)
107 }
108
109 pub fn alternative(
116 self,
117 plain: impl Into<String>,
118 html: impl Into<String>,
119 ) -> Result<Message, EmailError> {
120 let multipart = MultiPart::alternative_plain_html(plain.into(), html.into());
121 self.builder.multipart(multipart).map_err(EmailError::build)
122 }
123
124 pub fn mixed(
131 self,
132 body: MultiPart,
133 attachments: Vec<EmailAttachment>,
134 ) -> Result<Message, EmailError> {
135 let mut mixed = MultiPart::mixed().multipart(body);
136 for attachment in attachments {
137 mixed = mixed.singlepart(attachment.into_lettre());
138 }
139 self.builder.multipart(mixed).map_err(EmailError::build)
140 }
141
142 pub fn plain_with_attachments(
149 self,
150 body: impl Into<String>,
151 attachments: Vec<EmailAttachment>,
152 ) -> Result<Message, EmailError> {
153 let body = MultiPart::alternative_plain_html(body.into(), String::new());
154 self.mixed(body, attachments)
155 }
156
157 pub fn alternative_with_attachments(
164 self,
165 plain: impl Into<String>,
166 html: impl Into<String>,
167 attachments: Vec<EmailAttachment>,
168 ) -> Result<Message, EmailError> {
169 let body = MultiPart::alternative_plain_html(plain.into(), html.into());
170 self.mixed(body, attachments)
171 }
172
173 #[must_use]
176 pub fn into_builder(self) -> MessageBuilder {
177 self.builder
178 }
179}
180
181pub struct EmailAttachment {
183 filename: String,
184 body: Vec<u8>,
185 content_type: ContentType,
186}
187
188impl EmailAttachment {
189 pub fn new(
196 filename: impl Into<String>,
197 body: Vec<u8>,
198 content_type: &str,
199 ) -> Result<Self, EmailError> {
200 let content_type = ContentType::parse(content_type).map_err(EmailError::content_type)?;
201 Ok(Self {
202 filename: filename.into(),
203 body,
204 content_type,
205 })
206 }
207
208 pub(crate) fn into_lettre(self) -> SinglePart {
209 Attachment::new(self.filename).body(self.body, self.content_type)
210 }
211}
212
213impl fmt::Debug for EmailAttachment {
214 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
215 formatter
216 .debug_struct("EmailAttachment")
217 .field("filename", &self.filename)
218 .field("content_type", &self.content_type)
219 .field("body_len", &self.body.len())
220 .finish_non_exhaustive()
221 }
222}
223
224#[cfg(feature = "views")]
240impl Email {
241 pub fn templated<P, H>(self, plain: &P, html: &H) -> Result<Message, MailViewError>
289 where
290 P: crate::view::Template,
291 H: crate::view::Template,
292 {
293 let (plain, html) = render_pair(plain, html)?;
294 Ok(self.alternative(plain, html)?)
295 }
296
297 pub fn templated_with_attachments<P, H>(
305 self,
306 plain: &P,
307 html: &H,
308 attachments: Vec<EmailAttachment>,
309 ) -> Result<Message, MailViewError>
310 where
311 P: crate::view::Template,
312 H: crate::view::Template,
313 {
314 let (plain, html) = render_pair(plain, html)?;
315 Ok(self.alternative_with_attachments(plain, html, attachments)?)
316 }
317}
318
319#[cfg(feature = "views")]
321fn render_pair<P, H>(plain: &P, html: &H) -> Result<(String, String), crate::view::ViewError>
322where
323 P: crate::view::Template,
324 H: crate::view::Template,
325{
326 let plain = plain.render().map_err(crate::view::ViewError::from)?;
327 let html = html.render().map_err(crate::view::ViewError::from)?;
328 Ok((plain, html))
329}
330
331#[cfg(all(test, feature = "views"))]
332mod template_tests {
333 use super::*;
334 use crate::view::Template;
335
336 #[derive(Template)]
337 #[template(source = "Hello {{ name }}, 3 < 4.", ext = "txt")]
338 struct Text {
339 name: &'static str,
340 }
341
342 #[derive(Template)]
343 #[template(source = "<p>Hello {{ name }}.</p>", ext = "html")]
344 struct Html {
345 name: &'static str,
346 }
347
348 fn envelope() -> Email {
349 Email::builder()
350 .from("billing@example.com".parse().unwrap())
351 .to("ada@example.com".parse().unwrap())
352 .subject("Your invoice")
353 }
354
355 #[test]
359 fn one_template_pair_fills_both_halves() {
360 let message = envelope()
361 .templated(&Text { name: "Ada" }, &Html { name: "A<B" })
362 .unwrap();
363 let raw = String::from_utf8(message.formatted()).unwrap();
364
365 assert!(raw.contains("multipart/alternative"), "{raw}");
366 assert!(raw.contains("text/plain"), "{raw}");
367 assert!(raw.contains("text/html"), "{raw}");
368 assert!(
369 raw.contains("Hello Ada, 3 < 4."),
370 "text half missing: {raw}"
371 );
372 assert!(
373 raw.contains("<") || raw.contains("<"),
374 "the HTML half was not escaped: {raw}"
375 );
376 assert!(
377 !raw.contains("<p>Hello A<B"),
378 "the HTML half kept a raw angle bracket from data: {raw}"
379 );
380 }
381
382 #[test]
383 fn attachments_ride_along_with_a_templated_body() {
384 let attachment =
385 EmailAttachment::new("invoice.txt", b"total: 1".to_vec(), "text/plain").unwrap();
386 let message = envelope()
387 .templated_with_attachments(
388 &Text { name: "Ada" },
389 &Html { name: "Ada" },
390 vec![attachment],
391 )
392 .unwrap();
393 let raw = String::from_utf8(message.formatted()).unwrap();
394
395 assert!(raw.contains("multipart/mixed"), "{raw}");
396 assert!(raw.contains("invoice.txt"), "{raw}");
397 }
398
399 #[test]
402 fn a_failing_template_never_becomes_a_message() {
403 let failure = envelope()
404 .templated(
405 &crate::view::test_support::Unformattable::default(),
406 &Html { name: "Ada" },
407 )
408 .unwrap_err();
409
410 assert!(matches!(failure, MailViewError::Render { .. }));
411
412 let framework = crate::Error::from(failure);
413 assert_eq!(framework.status(), 500);
414 let rendered = framework.to_string();
415 assert!(
416 !rendered.contains("secret-template-text"),
417 "the template's text survived into the framework error: {rendered}"
418 );
419 }
420}