1use crate::client::MailClient;
4use lettre::message::{header::ContentType, Attachment as LAttach, MultiPart, SinglePart};
5use lettre::Message;
6use sova_core::{Error, Result};
7use std::path::PathBuf;
8
9#[derive(Clone, Debug)]
11pub struct EmailSnapshot {
12 pub from: String,
13 pub to: Vec<String>,
14 pub cc: Vec<String>,
15 pub bcc: Vec<String>,
16 pub subject: String,
17 pub text: Option<String>,
18 pub html: Option<String>,
19 pub attachments: Vec<String>,
20}
21
22#[cfg(feature = "templates")]
23#[derive(Clone)]
24struct PendingView {
25 name: String,
26 ctx: serde_json::Value,
27}
28
29#[derive(Clone)]
31pub struct Email {
32 client: Option<MailClient>,
33 from: Option<String>,
34 to: Vec<String>,
35 cc: Vec<String>,
36 bcc: Vec<String>,
37 subject: String,
38 text: Option<String>,
39 html: Option<String>,
40 attachments: Vec<Attachment>,
41 #[cfg(feature = "templates")]
42 html_view: Option<PendingView>,
43 #[cfg(feature = "templates")]
44 text_view: Option<PendingView>,
45 #[cfg(feature = "templates")]
46 ambient: Option<sova_templates::FrozenAmbient>,
47 #[cfg(feature = "markdown")]
48 pending_markdown: Option<String>,
49 #[cfg(all(feature = "templates", feature = "markdown"))]
50 markdown_view: Option<PendingView>,
51}
52
53#[derive(Clone)]
54enum Attachment {
55 Path(PathBuf),
56 Bytes { filename: String, data: Vec<u8> },
57}
58
59impl Email {
60 pub fn new() -> Self {
61 Self {
62 client: None,
63 from: None,
64 to: Vec::new(),
65 cc: Vec::new(),
66 bcc: Vec::new(),
67 subject: String::new(),
68 text: None,
69 html: None,
70 attachments: Vec::new(),
71 #[cfg(feature = "templates")]
72 html_view: None,
73 #[cfg(feature = "templates")]
74 text_view: None,
75 #[cfg(feature = "templates")]
76 ambient: None,
77 #[cfg(feature = "markdown")]
78 pending_markdown: None,
79 #[cfg(all(feature = "templates", feature = "markdown"))]
80 markdown_view: None,
81 }
82 }
83
84 pub(crate) fn with_client(client: MailClient) -> Self {
85 let from = client.default_from.clone();
86 #[cfg(feature = "templates")]
87 let ambient = client.templates().map(|t| t.freeze_globals());
88 Self {
89 client: Some(client),
90 from,
91 #[cfg(feature = "templates")]
92 ambient,
93 ..Self::new()
94 }
95 }
96
97 #[cfg(feature = "templates")]
98 pub(crate) fn with_ambient(mut self, ambient: sova_templates::FrozenAmbient) -> Self {
99 self.ambient = Some(ambient);
100 self
101 }
102
103 pub fn from(mut self, addr: impl Into<String>) -> Self {
104 self.from = Some(addr.into());
105 self
106 }
107
108 pub fn to(mut self, addr: impl Into<String>) -> Self {
109 self.to.push(addr.into());
110 self
111 }
112
113 pub fn cc(mut self, addr: impl Into<String>) -> Self {
114 self.cc.push(addr.into());
115 self
116 }
117
118 pub fn bcc(mut self, addr: impl Into<String>) -> Self {
119 self.bcc.push(addr.into());
120 self
121 }
122
123 pub fn subject(mut self, subject: impl Into<String>) -> Self {
124 self.subject = subject.into();
125 self
126 }
127
128 pub fn text(mut self, body: impl Into<String>) -> Self {
129 self.text = Some(body.into());
130 #[cfg(feature = "templates")]
131 {
132 self.text_view = None;
133 }
134 self
135 }
136
137 pub fn html(mut self, body: impl Into<String>) -> Self {
138 self.html = Some(body.into());
139 #[cfg(feature = "templates")]
140 {
141 self.html_view = None;
142 }
143 #[cfg(feature = "markdown")]
144 {
145 self.pending_markdown = None;
146 }
147 #[cfg(all(feature = "templates", feature = "markdown"))]
148 {
149 self.markdown_view = None;
150 }
151 self
152 }
153
154 #[cfg(feature = "templates")]
160 pub fn view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
161 let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
162 self.html_view = Some(PendingView {
163 name: name.into(),
164 ctx,
165 });
166 self.html = None;
167 #[cfg(feature = "markdown")]
168 {
169 self.pending_markdown = None;
170 }
171 #[cfg(all(feature = "templates", feature = "markdown"))]
172 {
173 self.markdown_view = None;
174 }
175 self
176 }
177
178 #[cfg(feature = "templates")]
180 pub fn text_view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
181 let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
182 self.text_view = Some(PendingView {
183 name: name.into(),
184 ctx,
185 });
186 self.text = None;
187 self
188 }
189
190 #[cfg(feature = "markdown")]
195 pub fn markdown(mut self, md: impl Into<String>) -> Self {
196 let md = md.into();
197 self.text = Some(md.clone());
198 self.pending_markdown = Some(md);
199 self.html = None;
200 #[cfg(feature = "templates")]
201 {
202 self.html_view = None;
203 self.markdown_view = None;
204 }
205 self
206 }
207
208 #[cfg(all(feature = "templates", feature = "markdown"))]
210 pub fn markdown_view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
211 let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
212 self.markdown_view = Some(PendingView {
213 name: name.into(),
214 ctx,
215 });
216 self.html = None;
217 self.html_view = None;
218 self.pending_markdown = None;
219 self
220 }
221
222 pub fn attach(mut self, path: impl Into<PathBuf>) -> Self {
223 self.attachments.push(Attachment::Path(path.into()));
224 self
225 }
226
227 pub fn attach_bytes(mut self, filename: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
228 self.attachments.push(Attachment::Bytes {
229 filename: filename.into(),
230 data: data.into(),
231 });
232 self
233 }
234
235 pub async fn send(self) -> Result<()> {
237 let client = self
238 .client
239 .clone()
240 .ok_or_else(|| Error::Internal("mail: no client — use MailClient::send".into()))?;
241 client.send(self).await
242 }
243
244 #[cfg(any(feature = "templates", feature = "markdown"))]
245 pub(crate) fn resolve_body(&mut self, client: &MailClient) -> Result<()> {
246 #[cfg(feature = "templates")]
247 {
248 let needs_templates = self.html_view.is_some()
249 || self.text_view.is_some()
250 || {
251 #[cfg(feature = "markdown")]
252 {
253 self.markdown_view.is_some()
254 }
255 #[cfg(not(feature = "markdown"))]
256 {
257 false
258 }
259 };
260 if needs_templates {
261 let templates = client.templates().ok_or_else(|| {
262 Error::Internal(
263 "mail view requires Templates plugin (install Templates before Mail, or enable mail-templates)"
264 .into(),
265 )
266 })?;
267 let ambient = self
268 .ambient
269 .clone()
270 .unwrap_or_else(|| templates.freeze_globals());
271 if let Some(view) = self.html_view.take() {
272 self.html = Some(templates.render_owned(&ambient, &view.name, view.ctx)?);
273 }
274 if let Some(view) = self.text_view.take() {
275 self.text = Some(templates.render_owned(&ambient, &view.name, view.ctx)?);
276 }
277 #[cfg(feature = "markdown")]
278 if let Some(view) = self.markdown_view.take() {
279 let md = templates.render_owned(&ambient, &view.name, view.ctx)?;
280 if self.text.is_none() {
281 self.text = Some(md.clone());
282 }
283 self.html = Some(crate::markdown::to_html(&md));
284 }
285 let _ = self.ambient.take();
286 }
287 }
288
289 #[cfg(feature = "markdown")]
290 if let Some(md) = self.pending_markdown.take() {
291 self.html = Some(crate::markdown::to_html(&md));
292 }
293
294 Ok(())
295 }
296
297 pub(crate) fn snapshot(&self) -> EmailSnapshot {
298 EmailSnapshot {
299 from: self.from.clone().unwrap_or_default(),
300 to: self.to.clone(),
301 cc: self.cc.clone(),
302 bcc: self.bcc.clone(),
303 subject: self.subject.clone(),
304 text: self.text.clone(),
305 html: self.html.clone(),
306 attachments: self
307 .attachments
308 .iter()
309 .map(|a| match a {
310 Attachment::Path(p) => p.display().to_string(),
311 Attachment::Bytes { filename, .. } => filename.clone(),
312 })
313 .collect(),
314 }
315 }
316
317 pub(crate) fn into_message(self, default_from: Option<&str>) -> Result<(EmailSnapshot, Message)> {
318 if self.to.is_empty() {
319 return Err(Error::BadRequest("mail: at least one `to` required".into()));
320 }
321 let from = self
322 .from
323 .as_deref()
324 .or(default_from)
325 .ok_or_else(|| Error::BadRequest("mail: `from` required".into()))?
326 .to_string();
327
328 let mut snap = self.snapshot();
329 snap.from = from.clone();
330
331 let mut builder = Message::builder().from(
332 from.parse()
333 .map_err(|e| Error::BadRequest(format!("mail from: {e}")))?,
334 );
335 for addr in &self.to {
336 builder = builder.to(addr
337 .parse()
338 .map_err(|e| Error::BadRequest(format!("mail to: {e}")))?);
339 }
340 for addr in &self.cc {
341 builder = builder.cc(addr
342 .parse()
343 .map_err(|e| Error::BadRequest(format!("mail cc: {e}")))?);
344 }
345 for addr in &self.bcc {
346 builder = builder.bcc(addr
347 .parse()
348 .map_err(|e| Error::BadRequest(format!("mail bcc: {e}")))?);
349 }
350 builder = builder.subject(&self.subject);
351
352 let content = match (&self.text, &self.html) {
353 (Some(text), Some(html)) => MultiPart::alternative()
354 .singlepart(plain(text))
355 .singlepart(html_part(html)),
356 (Some(text), None) => MultiPart::mixed().singlepart(plain(text)),
357 (None, Some(html)) => MultiPart::mixed().singlepart(html_part(html)),
358 (None, None) => MultiPart::mixed().singlepart(plain("")),
359 };
360
361 let message = if self.attachments.is_empty() {
362 builder
363 .multipart(content)
364 .map_err(|e| Error::BadRequest(format!("mail build: {e}")))?
365 } else {
366 let mut mixed = MultiPart::mixed().multipart(content);
367 for att in &self.attachments {
368 let (filename, data) = match att {
369 Attachment::Path(path) => {
370 let filename = path
371 .file_name()
372 .and_then(|s| s.to_str())
373 .unwrap_or("attachment")
374 .to_string();
375 let data = std::fs::read(path).map_err(|e| {
376 Error::Internal(format!("mail attach {}: {e}", path.display()))
377 })?;
378 (filename, data)
379 }
380 Attachment::Bytes { filename, data } => (filename.clone(), data.clone()),
381 };
382 let ct = content_type_for(&filename);
383 mixed = mixed.singlepart(LAttach::new(filename).body(data, ct));
384 }
385 builder
386 .multipart(mixed)
387 .map_err(|e| Error::BadRequest(format!("mail build: {e}")))?
388 };
389
390 Ok((snap, message))
391 }
392}
393
394impl Default for Email {
395 fn default() -> Self {
396 Self::new()
397 }
398}
399
400fn plain(body: &str) -> SinglePart {
401 SinglePart::builder()
402 .header(ContentType::TEXT_PLAIN)
403 .body(body.to_string())
404}
405
406fn html_part(body: &str) -> SinglePart {
407 SinglePart::builder()
408 .header(ContentType::TEXT_HTML)
409 .body(body.to_string())
410}
411
412fn content_type_for(filename: &str) -> ContentType {
413 let mime = mime_guess_lite(filename);
414 mime.parse::<ContentType>().unwrap_or(ContentType::TEXT_PLAIN)
415}
416
417fn mime_guess_lite(filename: &str) -> &'static str {
418 match filename
419 .rsplit('.')
420 .next()
421 .unwrap_or("")
422 .to_ascii_lowercase()
423 .as_str()
424 {
425 "png" => "image/png",
426 "jpg" | "jpeg" => "image/jpeg",
427 "gif" => "image/gif",
428 "pdf" => "application/pdf",
429 "txt" => "text/plain",
430 "html" | "htm" => "text/html",
431 "json" => "application/json",
432 "zip" => "application/zip",
433 _ => "application/octet-stream",
434 }
435}