1use rainier_support::{Error, Result};
5use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct Address {
10 pub email: String,
12 pub name: Option<String>,
14}
15
16impl Address {
17 pub fn new(email: impl Into<String>) -> Self {
19 Self { email: email.into(), name: None }
20 }
21
22 pub fn named(email: impl Into<String>, name: impl Into<String>) -> Self {
24 Self { email: email.into(), name: Some(name.into()) }
25 }
26
27 pub fn to_header(&self) -> String {
35 match &self.name {
36 Some(name) if is_safe_display_name(name) => format!("{name} <{}>", self.email),
37 _ => self.email.clone(),
38 }
39 }
40
41 pub fn is_valid(&self) -> bool {
43 let Some((local, domain)) = self.email.split_once('@') else {
44 return false;
45 };
46 !local.is_empty()
47 && domain.contains('.')
48 && !domain.starts_with('.')
49 && !domain.ends_with('.')
50 && !self.email.chars().any(|c| c.is_whitespace() || c == '\r' || c == '\n')
51 }
52}
53
54fn is_safe_display_name(name: &str) -> bool {
55 !name.is_empty()
56 && !name
57 .chars()
58 .any(|c| c == '"' || c == '<' || c == '>' || c == ',' || c == ':' || c.is_control())
59}
60
61impl From<&str> for Address {
62 fn from(email: &str) -> Self {
63 Address::new(email)
64 }
65}
66
67impl From<String> for Address {
68 fn from(email: String) -> Self {
69 Address::new(email)
70 }
71}
72
73impl std::fmt::Display for Address {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.write_str(&self.to_header())
76 }
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Envelope {
82 pub from: Option<Address>,
84 pub reply_to: Vec<Address>,
86 pub to: Vec<Address>,
88 pub cc: Vec<Address>,
90 pub bcc: Vec<Address>,
92 pub subject: String,
94}
95
96impl Envelope {
97 pub fn new(subject: impl Into<String>) -> Self {
99 Self { subject: subject.into(), ..Self::default() }
100 }
101
102 pub fn from(mut self, address: impl Into<Address>) -> Self {
104 self.from = Some(address.into());
105 self
106 }
107
108 pub fn to(mut self, address: impl Into<Address>) -> Self {
110 self.to.push(address.into());
111 self
112 }
113
114 pub fn cc(mut self, address: impl Into<Address>) -> Self {
116 self.cc.push(address.into());
117 self
118 }
119
120 pub fn bcc(mut self, address: impl Into<Address>) -> Self {
122 self.bcc.push(address.into());
123 self
124 }
125
126 pub fn reply_to(mut self, address: impl Into<Address>) -> Self {
128 self.reply_to.push(address.into());
129 self
130 }
131
132 pub fn recipients(&self) -> Vec<&Address> {
134 self.to.iter().chain(&self.cc).chain(&self.bcc).collect()
135 }
136
137 pub fn has_recipients(&self) -> bool {
139 !self.recipients().is_empty()
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
145pub enum Content {
146 View {
148 html: String,
150 text: Option<String>,
152 data: serde_json::Value,
154 },
155 Literal {
157 html: Option<String>,
159 text: Option<String>,
161 },
162}
163
164impl Content {
165 pub fn view(name: impl Into<String>, data: impl Serialize) -> Result<Self> {
167 Ok(Content::View { html: name.into(), text: None, data: serde_json::to_value(data)? })
168 }
169
170 pub fn with_text_view(mut self, name: impl Into<String>) -> Self {
172 if let Content::View { text, .. } = &mut self {
173 *text = Some(name.into());
174 }
175 self
176 }
177
178 pub fn html(body: impl Into<String>) -> Self {
180 Content::Literal { html: Some(body.into()), text: None }
181 }
182
183 pub fn text(body: impl Into<String>) -> Self {
185 Content::Literal { html: None, text: Some(body.into()) }
186 }
187}
188
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
191pub struct Attachment {
192 pub file_name: String,
194 pub content_type: String,
196 pub bytes: Vec<u8>,
198}
199
200impl Attachment {
201 pub fn from_bytes(
203 file_name: impl Into<String>,
204 content_type: impl Into<String>,
205 bytes: impl Into<Vec<u8>>,
206 ) -> Self {
207 Self { file_name: file_name.into(), content_type: content_type.into(), bytes: bytes.into() }
208 }
209
210 pub fn from_path(path: impl AsRef<std::path::Path>) -> Result<Self> {
212 let path = path.as_ref();
213 let bytes = std::fs::read(path)
214 .map_err(|e| Error::internal(format!("could not attach {}: {e}", path.display())))?;
215
216 let file_name = path
217 .file_name()
218 .map(|name| name.to_string_lossy().into_owned())
219 .unwrap_or_else(|| "attachment".to_string());
220
221 let content_type = guess_content_type(&file_name);
222 Ok(Self { file_name, content_type, bytes })
223 }
224
225 pub fn size(&self) -> usize {
227 self.bytes.len()
228 }
229
230 pub fn to_base64(&self) -> String {
232 use base64::Engine as _;
233 base64::engine::general_purpose::STANDARD.encode(&self.bytes)
234 }
235}
236
237fn guess_content_type(file_name: &str) -> String {
238 let extension =
239 file_name.rsplit_once('.').map(|(_, ext)| ext.to_ascii_lowercase()).unwrap_or_default();
240
241 match extension.as_str() {
242 "pdf" => "application/pdf",
243 "png" => "image/png",
244 "jpg" | "jpeg" => "image/jpeg",
245 "gif" => "image/gif",
246 "svg" => "image/svg+xml",
247 "csv" => "text/csv",
248 "txt" => "text/plain",
249 "html" | "htm" => "text/html",
250 "json" => "application/json",
251 "zip" => "application/zip",
252 _ => "application/octet-stream",
255 }
256 .to_string()
257}
258
259#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
261pub struct Message {
262 pub envelope: Envelope,
264 pub html: Option<String>,
266 pub text: Option<String>,
268 pub attachments: Vec<Attachment>,
270 pub headers: Vec<(String, String)>,
272}
273
274impl Message {
275 pub fn new(envelope: Envelope) -> Self {
277 Self { envelope, html: None, text: None, attachments: Vec::new(), headers: Vec::new() }
278 }
279
280 pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
283 let (name, value) = (name.into(), value.into());
284 if !name.contains(['\r', '\n']) && !value.contains(['\r', '\n']) {
285 self.headers.push((name, value));
286 }
287 self
288 }
289
290 pub fn has_body(&self) -> bool {
292 self.html.is_some() || self.text.is_some()
293 }
294
295 pub fn text_body(&self) -> String {
298 match (&self.text, &self.html) {
299 (Some(text), _) => text.clone(),
300 (None, Some(html)) => strip_tags(html),
301 (None, None) => String::new(),
302 }
303 }
304
305 pub fn validate(&self) -> Result<()> {
307 if !self.envelope.has_recipients() {
308 return Err(Error::internal("the message has no recipients"));
309 }
310 if self.envelope.from.is_none() {
311 return Err(Error::internal(
312 "the message has no sender — set one on the mailable or in `mail.from`",
313 ));
314 }
315 for address in self.envelope.recipients() {
316 if !address.is_valid() {
317 return Err(Error::internal(format!(
318 "`{}` is not a deliverable address",
319 address.email
320 )));
321 }
322 }
323 if !self.has_body() {
324 return Err(Error::internal("the message has no body"));
325 }
326 Ok(())
327 }
328}
329
330fn strip_tags(html: &str) -> String {
335 let mut out = String::with_capacity(html.len());
336 let mut inside = false;
337
338 for character in html.chars() {
339 match character {
340 '<' => inside = true,
341 '>' => {
342 inside = false;
343 if !out.ends_with(char::is_whitespace) && !out.is_empty() {
346 out.push(' ');
347 }
348 }
349 other if !inside => out.push(other),
350 _ => {}
351 }
352 }
353 out.split_whitespace().collect::<Vec<_>>().join(" ")
354}
355
356#[cfg(test)]
357mod tests {
358 use super::*;
359
360 #[test]
361 fn addresses_render_with_and_without_a_name() {
362 assert_eq!(Address::new("ada@example.com").to_header(), "ada@example.com");
363 assert_eq!(
364 Address::named("ada@example.com", "Ada Lovelace").to_header(),
365 "Ada Lovelace <ada@example.com>"
366 );
367 }
368
369 #[test]
370 fn a_hostile_display_name_is_dropped_rather_than_escaped() {
371 for hostile in ["Ada\r\nBcc: attacker@evil.test", "Ada \"quoted\"", "a<b>", "a,b", "a:b"] {
373 let rendered = Address::named("ada@example.com", hostile).to_header();
374 assert_eq!(rendered, "ada@example.com", "for `{hostile}`");
375 }
376 }
377
378 #[test]
379 fn address_validity() {
380 assert!(Address::new("ada@example.com").is_valid());
381 assert!(Address::new("a.b+c@sub.example.co.uk").is_valid());
382
383 for bad in ["", "ada", "@example.com", "ada@example", "ada @example.com", "a@.com", "a@b."]
384 {
385 assert!(!Address::new(bad).is_valid(), "`{bad}` should be invalid");
386 }
387 assert!(!Address::new("ada@example.com\r\nBcc: x@y.z").is_valid());
388 }
389
390 #[test]
391 fn an_envelope_collects_every_recipient() {
392 let envelope = Envelope::new("Hello")
393 .from(Address::named("app@example.com", "App"))
394 .to("a@example.com")
395 .cc("b@example.com")
396 .bcc("c@example.com")
397 .reply_to("support@example.com");
398
399 assert_eq!(envelope.recipients().len(), 3);
400 assert!(envelope.has_recipients());
401 assert_eq!(envelope.reply_to.len(), 1);
402 assert_eq!(envelope.subject, "Hello");
403 }
404
405 #[test]
406 fn an_empty_envelope_has_no_recipients() {
407 assert!(!Envelope::new("Hello").has_recipients());
408 }
409
410 #[test]
411 fn content_variants() {
412 let view = Content::view("mail.welcome", serde_json::json!({ "name": "Ada" }))
413 .unwrap()
414 .with_text_view("mail.welcome_text");
415
416 let Content::View { html, text, data } = &view else { panic!("expected a view") };
417 assert_eq!(html, "mail.welcome");
418 assert_eq!(text.as_deref(), Some("mail.welcome_text"));
419 assert_eq!(data["name"], "Ada");
420
421 assert_eq!(
422 Content::html("<p>hi</p>"),
423 Content::Literal { html: Some("<p>hi</p>".into()), text: None }
424 );
425 }
426
427 #[test]
428 fn attachments_carry_their_bytes_and_type() {
429 let attachment = Attachment::from_bytes("report.pdf", "application/pdf", b"%PDF".to_vec());
430
431 assert_eq!(attachment.size(), 4);
432 assert_eq!(attachment.to_base64(), "JVBERg==");
433 }
434
435 #[test]
436 fn content_types_are_guessed_from_the_extension() {
437 assert_eq!(guess_content_type("a.pdf"), "application/pdf");
438 assert_eq!(guess_content_type("a.PNG"), "image/png");
439 assert_eq!(guess_content_type("a.jpeg"), "image/jpeg");
440 assert_eq!(guess_content_type("noextension"), "application/octet-stream");
441 assert_eq!(guess_content_type("a.unknownext"), "application/octet-stream");
442 }
443
444 fn deliverable() -> Message {
445 let mut message =
446 Message::new(Envelope::new("Hi").from("app@example.com").to("ada@example.com"));
447 message.text = Some("Hello".into());
448 message
449 }
450
451 #[test]
452 fn a_deliverable_message_validates() {
453 assert!(deliverable().validate().is_ok());
454 }
455
456 #[test]
457 fn validation_catches_each_way_a_message_can_be_undeliverable() {
458 let mut no_recipients = deliverable();
459 no_recipients.envelope.to.clear();
460 assert!(no_recipients.validate().unwrap_err().message().contains("recipients"));
461
462 let mut no_sender = deliverable();
463 no_sender.envelope.from = None;
464 assert!(no_sender.validate().unwrap_err().message().contains("sender"));
465
466 let mut no_body = deliverable();
467 no_body.text = None;
468 assert!(no_body.validate().unwrap_err().message().contains("body"));
469
470 let mut bad_address = deliverable();
471 bad_address.envelope.to = vec![Address::new("not-an-address")];
472 assert!(bad_address.validate().unwrap_err().message().contains("deliverable"));
473 }
474
475 #[test]
476 fn headers_with_newlines_are_refused() {
477 let message = deliverable()
478 .with_header("X-Ok", "fine")
479 .with_header("X-Bad", "value\r\nBcc: attacker@evil.test");
480
481 assert_eq!(message.headers.len(), 1);
482 assert_eq!(message.headers[0].0, "X-Ok");
483 }
484
485 #[test]
486 fn the_text_body_falls_back_to_stripped_html() {
487 let mut message = deliverable();
488 message.text = None;
489 message.html = Some("<p>Hello</p><p>Ada</p>".into());
490
491 assert_eq!(message.text_body(), "Hello Ada");
492 }
493
494 #[test]
495 fn an_explicit_text_body_wins_over_the_html() {
496 let mut message = deliverable();
497 message.html = Some("<p>html</p>".into());
498 message.text = Some("plain".into());
499
500 assert_eq!(message.text_body(), "plain");
501 }
502
503 #[test]
504 fn stripping_tags_keeps_word_boundaries() {
505 assert_eq!(strip_tags("<h1>A</h1><p>B</p>"), "A B");
506 assert_eq!(strip_tags("no tags here"), "no tags here");
507 assert_eq!(strip_tags("<br/>"), "");
508 }
509}