1use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::RwLock;
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28use lettre::message::header::ContentType;
29use lettre::message::{Attachment as LettreAttachment, Mailbox, MultiPart, SinglePart};
30use lettre::transport::smtp::authentication::Credentials;
31use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
32
33type PasswordResolver = Box<dyn Fn() -> Option<String> + Send + Sync>;
38static PASSWORD_RESOLVER: RwLock<Option<PasswordResolver>> = RwLock::new(None);
39
40pub fn set_password_resolver<F>(resolver: F)
43where
44 F: Fn() -> Option<String> + Send + Sync + 'static,
45{
46 if let Ok(mut guard) = PASSWORD_RESOLVER.write() {
47 *guard = Some(Box::new(resolver));
48 }
49}
50
51fn resolve_password() -> Option<String> {
54 let guard = PASSWORD_RESOLVER.read().ok()?;
55 let resolver = guard.as_ref()?;
56 resolver()
57}
58
59const SEND_TIMEOUT: Duration = Duration::from_secs(30);
62
63pub const SMTP_TRANSPORT_PREF_KEY: &str = "smtp-transport";
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct TransportPrefs {
73 pub host: String,
74 #[serde(default = "default_port")]
75 pub port: u16,
76 #[serde(default)]
77 pub username: String,
78 #[serde(default)]
79 pub from: String,
80 #[serde(default = "default_starttls")]
81 pub starttls: bool,
82}
83
84fn default_port() -> u16 {
85 587
86}
87
88fn default_starttls() -> bool {
89 true
90}
91
92pub fn apply_transport_prefs_json(json: &str) {
95 match serde_json::from_str::<TransportPrefs>(json) {
96 Ok(t) => set_transport(&t.host, t.port, &t.username, &t.from, t.starttls),
97 Err(_) => set_transport("", 0, "", "", true),
98 }
99}
100
101pub fn current_transport_prefs() -> Option<TransportPrefs> {
103 let guard = TRANSPORT.read().ok()?;
104 let t = guard.as_ref()?;
105 Some(TransportPrefs {
106 host: t.host.clone(),
107 port: t.port,
108 username: t.username.clone(),
109 from: t.from.clone(),
110 starttls: t.starttls,
111 })
112}
113
114#[derive(Debug, Clone)]
117pub struct EmailTransportConfig {
118 pub host: String,
119 pub port: u16,
120 pub username: String,
121 pub password: String,
122 pub from: String,
124 pub starttls: bool,
126}
127
128#[derive(Debug, Clone)]
130pub struct Attachment {
131 pub filename: String,
132 pub content_type: String,
133 pub bytes: Vec<u8>,
134}
135
136#[derive(Debug, Clone, Default)]
138pub struct OutboundEmail {
139 pub from: Option<String>,
141 pub to: Vec<String>,
142 pub cc: Vec<String>,
143 pub bcc: Vec<String>,
144 pub reply_to: Option<String>,
145 pub subject: String,
146 pub text: Option<String>,
147 pub html: Option<String>,
148 pub in_reply_to: Option<String>,
150 pub references: Option<String>,
151 pub attachments: Vec<Attachment>,
152}
153
154#[derive(Debug)]
155pub enum EmailError {
156 NotConfigured,
158 InvalidAddress(String),
160 Build(String),
162 Transport(String),
164 Send(String),
166 Timeout,
168}
169
170impl std::fmt::Display for EmailError {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::NotConfigured => write!(f, "email transport is not configured"),
174 Self::InvalidAddress(a) => write!(f, "invalid email address: {a}"),
175 Self::Build(e) => write!(f, "failed to build email: {e}"),
176 Self::Transport(e) => write!(f, "failed to build SMTP transport: {e}"),
177 Self::Send(e) => write!(f, "SMTP send failed: {e}"),
178 Self::Timeout => write!(f, "SMTP send timed out"),
179 }
180 }
181}
182
183impl std::error::Error for EmailError {}
184
185static TRANSPORT: RwLock<Option<StoredTransport>> = RwLock::new(None);
189
190#[derive(Debug, Clone)]
193struct StoredTransport {
194 host: String,
195 port: u16,
196 username: String,
197 from: String,
198 starttls: bool,
199}
200
201pub fn set_transport(host: &str, port: u16, username: &str, from: &str, starttls: bool) {
205 let host = host.trim();
206 if let Ok(mut guard) = TRANSPORT.write() {
207 *guard = if host.is_empty() {
208 None
209 } else {
210 Some(StoredTransport {
211 host: host.to_string(),
212 port,
213 username: username.trim().to_string(),
214 from: from.trim().to_string(),
215 starttls,
216 })
217 };
218 }
219}
220
221pub fn resolve_transport() -> Option<EmailTransportConfig> {
224 let password = resolve_password()?;
225
226 if let Ok(guard) = TRANSPORT.read() {
227 if let Some(t) = guard.as_ref() {
228 return Some(EmailTransportConfig {
229 host: t.host.clone(),
230 port: t.port,
231 username: t.username.clone(),
232 password,
233 from: t.from.clone(),
234 starttls: t.starttls,
235 });
236 }
237 }
238
239 let host = std::env::var("RYU_SMTP_HOST").ok()?;
242 let host = host.trim();
243 if host.is_empty() {
244 return None;
245 }
246 let port = std::env::var("RYU_SMTP_PORT")
247 .ok()
248 .and_then(|p| p.trim().parse::<u16>().ok())
249 .unwrap_or(587);
250 let username = std::env::var("RYU_SMTP_USERNAME").unwrap_or_default();
251 let from = std::env::var("RYU_SMTP_FROM").unwrap_or_else(|_| username.clone());
252 let starttls = std::env::var("RYU_SMTP_STARTTLS")
253 .map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
254 .unwrap_or(true);
255 Some(EmailTransportConfig {
256 host: host.to_string(),
257 port,
258 username: username.trim().to_string(),
259 password,
260 from: from.trim().to_string(),
261 starttls,
262 })
263}
264
265fn parse_mailbox(addr: &str) -> Result<Mailbox, EmailError> {
266 addr.trim()
267 .parse::<Mailbox>()
268 .map_err(|e| EmailError::InvalidAddress(format!("{addr}: {e}")))
269}
270
271fn generate_message_id(from: &str) -> String {
273 static COUNTER: AtomicU64 = AtomicU64::new(0);
274 let nanos = SystemTime::now()
275 .duration_since(UNIX_EPOCH)
276 .map(|d| d.as_nanos())
277 .unwrap_or(0);
278 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
279 let domain = from
281 .rsplit_once('@')
282 .map(|(_, d)| d.trim_end_matches('>').trim())
283 .filter(|d| !d.is_empty())
284 .unwrap_or("ryu.local");
285 format!("<{nanos}.{seq}@{domain}>")
286}
287
288fn build_body(msg: &OutboundEmail) -> Result<MultiPartOrSingle, EmailError> {
290 let content = match (msg.text.as_ref(), msg.html.as_ref()) {
291 (Some(text), Some(html)) => MultiPartOrSingle::Multi(MultiPart::alternative_plain_html(
292 text.clone(),
293 html.clone(),
294 )),
295 (Some(text), None) => MultiPartOrSingle::Single(SinglePart::plain(text.clone())),
296 (None, Some(html)) => MultiPartOrSingle::Single(SinglePart::html(html.clone())),
297 (None, None) => MultiPartOrSingle::Single(SinglePart::plain(String::new())),
298 };
299
300 if msg.attachments.is_empty() {
301 return Ok(content);
302 }
303
304 let mut mixed = MultiPart::mixed().multipart(match content {
306 MultiPartOrSingle::Multi(m) => m,
307 MultiPartOrSingle::Single(s) => MultiPart::mixed().singlepart(s),
308 });
309 for att in &msg.attachments {
310 let ct = ContentType::parse(&att.content_type)
311 .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
312 mixed = mixed
313 .singlepart(LettreAttachment::new(att.filename.clone()).body(att.bytes.clone(), ct));
314 }
315 Ok(MultiPartOrSingle::Multi(mixed))
316}
317
318enum MultiPartOrSingle {
319 Multi(MultiPart),
320 Single(SinglePart),
321}
322
323pub async fn send_email(
327 cfg: &EmailTransportConfig,
328 msg: &OutboundEmail,
329) -> Result<String, EmailError> {
330 if msg.to.is_empty() {
331 return Err(EmailError::InvalidAddress("no recipients".to_string()));
332 }
333 let from_addr = msg.from.as_deref().unwrap_or(cfg.from.as_str());
334 let message_id = generate_message_id(from_addr);
335
336 let mut builder = Message::builder()
337 .from(parse_mailbox(from_addr)?)
338 .subject(msg.subject.clone())
339 .message_id(Some(message_id.clone()));
340
341 for to in &msg.to {
342 builder = builder.to(parse_mailbox(to)?);
343 }
344 for cc in &msg.cc {
345 builder = builder.cc(parse_mailbox(cc)?);
346 }
347 for bcc in &msg.bcc {
348 builder = builder.bcc(parse_mailbox(bcc)?);
349 }
350 if let Some(reply_to) = msg.reply_to.as_ref() {
351 builder = builder.reply_to(parse_mailbox(reply_to)?);
352 }
353 if let Some(in_reply_to) = msg.in_reply_to.as_ref() {
354 builder = builder.in_reply_to(in_reply_to.clone());
355 }
356 if let Some(references) = msg.references.as_ref() {
357 builder = builder.references(references.clone());
358 }
359
360 let body = build_body(msg)?;
361 let email = match body {
362 MultiPartOrSingle::Multi(m) => builder.multipart(m),
363 MultiPartOrSingle::Single(s) => builder.singlepart(s),
364 }
365 .map_err(|e| EmailError::Build(e.to_string()))?;
366
367 let creds = Credentials::new(cfg.username.clone(), cfg.password.clone());
368 let transport = if cfg.starttls {
369 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
370 } else {
371 AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
372 }
373 .map_err(|e| EmailError::Transport(e.to_string()))?
374 .port(cfg.port)
375 .credentials(creds)
376 .build();
377
378 match tokio::time::timeout(SEND_TIMEOUT, transport.send(email)).await {
379 Err(_) => Err(EmailError::Timeout),
380 Ok(Err(e)) => Err(EmailError::Send(e.to_string())),
381 Ok(Ok(_response)) => Ok(message_id),
382 }
383}
384
385pub async fn send_email_alert(
387 cfg: &EmailTransportConfig,
388 to: &str,
389 subject: &str,
390 body: &str,
391) -> Result<String, EmailError> {
392 send_email(
393 cfg,
394 &OutboundEmail {
395 to: vec![to.to_string()],
396 subject: subject.to_string(),
397 text: Some(body.to_string()),
398 ..Default::default()
399 },
400 )
401 .await
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
413
414 fn body_bytes(msg: &OutboundEmail) -> Vec<u8> {
415 match build_body(msg).expect("body builds") {
416 MultiPartOrSingle::Multi(m) => m.formatted(),
417 MultiPartOrSingle::Single(s) => s.formatted(),
418 }
419 }
420
421 #[test]
424 fn message_id_extracts_domain_from_bare_address() {
425 let id = generate_message_id("alerts@node.example");
426 assert!(id.starts_with('<'), "wrapped: {id}");
427 assert!(id.ends_with("@node.example>"), "domain from address: {id}");
428 }
429
430 #[test]
431 fn message_id_extracts_domain_from_display_name_form() {
432 let id = generate_message_id("Ryu <alerts@node.example>");
434 assert!(id.ends_with("@node.example>"), "stripped `>`: {id}");
435 }
436
437 #[test]
438 fn message_id_falls_back_when_no_at_sign() {
439 let id = generate_message_id("no-at-sign-here");
440 assert!(id.ends_with("@ryu.local>"), "placeholder domain: {id}");
441 }
442
443 #[test]
444 fn message_id_falls_back_on_empty_domain() {
445 let id = generate_message_id("local@");
447 assert!(
448 id.ends_with("@ryu.local>"),
449 "empty domain → placeholder: {id}"
450 );
451 }
452
453 #[test]
454 fn message_ids_are_unique_across_calls() {
455 let a = generate_message_id("a@b.com");
456 let b = generate_message_id("a@b.com");
457 assert_ne!(a, b, "monotonic counter must differ consecutive ids");
458 }
459
460 #[test]
463 fn parse_mailbox_accepts_bare_and_display_forms() {
464 assert!(parse_mailbox("a@b.com").is_ok());
465 assert!(parse_mailbox("Alice <a@b.com>").is_ok());
466 }
467
468 #[test]
469 fn parse_mailbox_trims_surrounding_whitespace() {
470 assert!(parse_mailbox(" a@b.com ").is_ok());
471 }
472
473 #[test]
474 fn parse_mailbox_rejects_garbage() {
475 match parse_mailbox("not-an-email") {
476 Err(EmailError::InvalidAddress(a)) => assert!(a.contains("not-an-email")),
477 other => panic!("expected InvalidAddress, got {other:?}"),
478 }
479 }
480
481 #[test]
484 fn build_body_text_only_is_singlepart_plain() {
485 let bytes = body_bytes(&OutboundEmail {
486 text: Some("hello".into()),
487 ..Default::default()
488 });
489 let s = String::from_utf8_lossy(&bytes);
490 assert!(s.contains("text/plain"), "plain part: {s}");
491 assert!(s.contains("hello"));
492 }
493
494 #[test]
495 fn build_body_html_only_is_singlepart_html() {
496 let bytes = body_bytes(&OutboundEmail {
497 html: Some("<b>hi</b>".into()),
498 ..Default::default()
499 });
500 let s = String::from_utf8_lossy(&bytes);
501 assert!(s.contains("text/html"), "html part: {s}");
502 }
503
504 #[test]
505 fn build_body_text_and_html_is_alternative_multipart() {
506 let bytes = body_bytes(&OutboundEmail {
507 text: Some("plain".into()),
508 html: Some("<i>rich</i>".into()),
509 ..Default::default()
510 });
511 let s = String::from_utf8_lossy(&bytes);
512 assert!(s.contains("multipart/alternative"), "alternative: {s}");
513 assert!(s.contains("text/plain") && s.contains("text/html"));
514 }
515
516 #[test]
517 fn build_body_empty_is_singlepart_plain() {
518 match build_body(&OutboundEmail::default()).expect("builds") {
520 MultiPartOrSingle::Single(_) => {}
521 MultiPartOrSingle::Multi(_) => panic!("empty body should be singlepart"),
522 }
523 }
524
525 #[test]
526 fn build_body_with_attachment_wraps_in_mixed_multipart() {
527 let bytes = body_bytes(&OutboundEmail {
528 text: Some("see attached".into()),
529 attachments: vec![Attachment {
530 filename: "report.pdf".into(),
531 content_type: "application/pdf".into(),
532 bytes: b"%PDF-1.4".to_vec(),
533 }],
534 ..Default::default()
535 });
536 let s = String::from_utf8_lossy(&bytes);
537 assert!(s.contains("multipart/mixed"), "mixed wrapper: {s}");
538 assert!(s.contains("report.pdf"), "attachment filename present");
539 assert!(
540 s.contains("application/pdf"),
541 "attachment content-type present"
542 );
543 }
544
545 #[test]
546 fn build_body_alternative_with_attachment_nests_multipart() {
547 let bytes = body_bytes(&OutboundEmail {
550 text: Some("plain".into()),
551 html: Some("<i>rich</i>".into()),
552 attachments: vec![Attachment {
553 filename: "a.txt".into(),
554 content_type: "text/plain".into(),
555 bytes: b"data".to_vec(),
556 }],
557 ..Default::default()
558 });
559 let s = String::from_utf8_lossy(&bytes);
560 assert!(s.contains("multipart/mixed"), "outer mixed: {s}");
561 assert!(
562 s.contains("multipart/alternative"),
563 "nested alternative: {s}"
564 );
565 assert!(s.contains("a.txt"), "attachment present");
566 }
567
568 #[test]
569 fn build_body_malformed_content_type_falls_back_to_octet_stream() {
570 let bytes = body_bytes(&OutboundEmail {
572 html: Some("body".into()),
573 attachments: vec![Attachment {
574 filename: "blob.bin".into(),
575 content_type: "this is not a mime type".into(),
576 bytes: vec![0, 1, 2, 3],
577 }],
578 ..Default::default()
579 });
580 let s = String::from_utf8_lossy(&bytes);
581 assert!(
582 s.contains("application/octet-stream"),
583 "fallback content-type: {s}"
584 );
585 }
586
587 #[test]
590 fn email_error_display_covers_all_variants() {
591 assert_eq!(
592 EmailError::NotConfigured.to_string(),
593 "email transport is not configured"
594 );
595 assert_eq!(
596 EmailError::InvalidAddress("x@".into()).to_string(),
597 "invalid email address: x@"
598 );
599 assert_eq!(
600 EmailError::Build("boom".into()).to_string(),
601 "failed to build email: boom"
602 );
603 assert_eq!(
604 EmailError::Transport("tls".into()).to_string(),
605 "failed to build SMTP transport: tls"
606 );
607 assert_eq!(
608 EmailError::Send("550".into()).to_string(),
609 "SMTP send failed: 550"
610 );
611 assert_eq!(EmailError::Timeout.to_string(), "SMTP send timed out");
612 assert!(format!("{:?}", EmailError::Timeout).contains("Timeout"));
614 }
615
616 #[test]
619 fn transport_prefs_apply_serde_defaults() {
620 let prefs: TransportPrefs =
621 serde_json::from_str(r#"{"host":"smtp.example.com"}"#).expect("parses");
622 assert_eq!(prefs.host, "smtp.example.com");
623 assert_eq!(prefs.port, 587, "default_port");
624 assert_eq!(prefs.username, "", "default username");
625 assert_eq!(prefs.from, "", "default from");
626 assert!(prefs.starttls, "default_starttls");
627 }
628
629 #[test]
630 fn transport_prefs_honour_explicit_values() {
631 let prefs: TransportPrefs = serde_json::from_str(
632 r#"{"host":"h","port":465,"username":"u","from":"f@x.io","starttls":false}"#,
633 )
634 .expect("parses");
635 assert_eq!(prefs.port, 465);
636 assert_eq!(prefs.username, "u");
637 assert_eq!(prefs.from, "f@x.io");
638 assert!(!prefs.starttls);
639 }
640
641 fn a_config() -> EmailTransportConfig {
644 EmailTransportConfig {
645 host: "127.0.0.1".into(),
646 port: 0,
647 username: "u".into(),
648 password: "p".into(),
649 from: "from@node.example".into(),
650 starttls: true,
651 }
652 }
653
654 #[tokio::test]
655 async fn send_email_rejects_empty_recipient_list() {
656 let cfg = a_config();
657 let msg = OutboundEmail {
658 subject: "s".into(),
659 text: Some("b".into()),
660 ..Default::default()
661 };
662 match send_email(&cfg, &msg).await {
663 Err(EmailError::InvalidAddress(a)) => assert!(a.contains("no recipients")),
664 other => panic!("expected no-recipients error, got {other:?}"),
665 }
666 }
667
668 #[tokio::test]
669 async fn send_email_rejects_invalid_from_override() {
670 let cfg = a_config();
671 let msg = OutboundEmail {
672 from: Some("garbage".into()),
673 to: vec!["ok@node.example".into()],
674 ..Default::default()
675 };
676 assert!(matches!(
677 send_email(&cfg, &msg).await,
678 Err(EmailError::InvalidAddress(_))
679 ));
680 }
681
682 #[tokio::test]
683 async fn send_email_rejects_invalid_to() {
684 let cfg = a_config();
685 let msg = OutboundEmail {
686 to: vec!["not valid".into()],
687 ..Default::default()
688 };
689 assert!(matches!(
690 send_email(&cfg, &msg).await,
691 Err(EmailError::InvalidAddress(_))
692 ));
693 }
694
695 #[tokio::test]
696 async fn send_email_rejects_invalid_cc() {
697 let cfg = a_config();
698 let msg = OutboundEmail {
699 to: vec!["ok@node.example".into()],
700 cc: vec!["bad cc".into()],
701 ..Default::default()
702 };
703 assert!(matches!(
704 send_email(&cfg, &msg).await,
705 Err(EmailError::InvalidAddress(_))
706 ));
707 }
708
709 #[tokio::test]
710 async fn send_email_rejects_invalid_bcc() {
711 let cfg = a_config();
712 let msg = OutboundEmail {
713 to: vec!["ok@node.example".into()],
714 bcc: vec!["bad bcc".into()],
715 ..Default::default()
716 };
717 assert!(matches!(
718 send_email(&cfg, &msg).await,
719 Err(EmailError::InvalidAddress(_))
720 ));
721 }
722
723 #[tokio::test]
724 async fn send_email_rejects_invalid_reply_to() {
725 let cfg = a_config();
726 let msg = OutboundEmail {
727 to: vec!["ok@node.example".into()],
728 reply_to: Some("bad reply".into()),
729 ..Default::default()
730 };
731 assert!(matches!(
732 send_email(&cfg, &msg).await,
733 Err(EmailError::InvalidAddress(_))
734 ));
735 }
736
737 #[tokio::test]
738 async fn send_email_alert_rejects_invalid_recipient() {
739 let cfg = a_config();
740 assert!(matches!(
741 send_email_alert(&cfg, "not an address", "subj", "body").await,
742 Err(EmailError::InvalidAddress(_))
743 ));
744 }
745
746 fn accept_then_close_listener() -> u16 {
753 use std::net::TcpListener;
754 let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback");
755 let port = listener.local_addr().expect("addr").port();
756 std::thread::spawn(move || {
757 for conn in listener.incoming().take(4) {
760 if let Ok(stream) = conn {
761 drop(stream);
762 }
763 }
764 });
765 port
766 }
767
768 async fn expect_send_failure(starttls: bool, multipart_body: bool) {
769 let port = accept_then_close_listener();
770 let cfg = EmailTransportConfig {
771 host: "127.0.0.1".into(),
772 port,
773 username: "u".into(),
774 password: "p".into(),
775 from: "from@node.example".into(),
776 starttls,
777 };
778 let msg = OutboundEmail {
781 to: vec!["to@node.example".into()],
782 cc: vec!["cc@node.example".into()],
783 bcc: vec!["bcc@node.example".into()],
784 reply_to: Some("reply@node.example".into()),
785 subject: "hi".into(),
786 text: Some("plain".into()),
787 html: multipart_body.then(|| "<b>rich</b>".to_string()),
788 in_reply_to: Some("<prev@node.example>".into()),
789 references: Some("<root@node.example>".into()),
790 ..Default::default()
791 };
792 match send_email(&cfg, &msg).await {
795 Err(EmailError::Send(_)) => {}
796 Err(EmailError::Transport(_)) => {}
797 other => panic!("expected a send/transport failure, got {other:?}"),
798 }
799 }
800
801 #[tokio::test]
802 async fn send_email_starttls_multipart_send_failure() {
803 expect_send_failure(true, true).await;
804 }
805
806 #[tokio::test]
807 async fn send_email_implicit_tls_singlepart_send_failure() {
808 expect_send_failure(false, false).await;
810 }
811}