Skip to main content

ryu_email_send/
lib.rs

1//! BYOK SMTP email sink for self-host — an extracted Core capability crate.
2//!
3//! The delivery leg for self-host alerts (budget/firewall policy alerts, monitor
4//! notifications) and, later, agent-inbox send. Delivery is "what runs" (Core),
5//! not policy (Gateway): the Gateway decides an alert fires; the node opens the
6//! socket and sends.
7//!
8//! Nothing hardcoded: the transport is a swappable BYO SMTP relay resolved from
9//! preferences (desktop Settings) first, then environment for headless setups.
10//! There is no default provider — with no relay configured the sink is a no-op
11//! (`resolve_transport` returns `None`) and callers simply skip email. SMTP is one
12//! swappable sink; the SES agent-inbox path (`packages/mail`) is another.
13//!
14//! The public sink is a rich builder ([`OutboundEmail`]) — multi-recipient,
15//! cc/bcc/reply-to, text+html multipart, threading headers, and attachments — so
16//! the agent-inbox send path (which needs all of that to preserve mail-client
17//! threading) and the one-line alert path ([`send_email_alert`]) share one
18//! transport.
19//!
20//! Secret custody stays kernel-side: the SMTP password is never held here. Core
21//! injects a resolver via [`set_password_resolver`] (backed by its `smtp_auth`
22//! BYO-key store), so this crate has ZERO dependency on `apps/core`.
23
24use 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
33/// The injected SMTP-password resolver. The secret itself is custodied Core-side
34/// (`smtp_auth`, prefs-first + `RYU_SMTP_PASSWORD` env fallback); this crate only
35/// calls the hook at resolve time. `None` (unwired) means email is disabled — a
36/// fail-safe no-op, never a plaintext leak.
37type PasswordResolver = Box<dyn Fn() -> Option<String> + Send + Sync>;
38static PASSWORD_RESOLVER: RwLock<Option<PasswordResolver>> = RwLock::new(None);
39
40/// Wire the SMTP-password resolver. Core calls this once at startup with a closure
41/// over its `smtp_auth` store. Idempotent replace.
42pub 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
51/// Resolve the active SMTP password through the injected hook (`None` when the
52/// hook is unwired or the store has no password).
53fn resolve_password() -> Option<String> {
54    let guard = PASSWORD_RESOLVER.read().ok()?;
55    let resolver = guard.as_ref()?;
56    resolver()
57}
58
59/// A wedged relay must not hang a monitor check or an inbox-send request forever
60/// — `lettre` has no built-in timeout, so every send is bounded by this.
61const SEND_TIMEOUT: Duration = Duration::from_secs(30);
62
63/// Preferences key holding the non-secret transport JSON (host/port/username/
64/// from/starttls). The password is stored separately via [`crate::smtp_auth`].
65/// Core loads it on startup and on change so the desktop card takes effect with
66/// no restart.
67pub const SMTP_TRANSPORT_PREF_KEY: &str = "smtp-transport";
68
69/// The non-secret transport fields persisted under [`SMTP_TRANSPORT_PREF_KEY`] and
70/// exchanged with the desktop SMTP card. The password never appears here.
71#[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
92/// Apply a persisted [`TransportPrefs`] JSON value to the in-process cache. Called
93/// at startup and whenever the pref changes. A malformed value clears the cache.
94pub 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
101/// Read the currently-cached non-secret transport prefs, if any (for `GET`).
102pub 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/// Non-secret SMTP transport config. The password is resolved separately via
115/// [`crate::smtp_auth`] so the secret surface stays isolated.
116#[derive(Debug, Clone)]
117pub struct EmailTransportConfig {
118    pub host: String,
119    pub port: u16,
120    pub username: String,
121    pub password: String,
122    /// The `From` mailbox, e.g. `"Ryu <alerts@your-node.example>"`.
123    pub from: String,
124    /// STARTTLS on a submission port (587) vs implicit TLS (465).
125    pub starttls: bool,
126}
127
128/// A file attached to an outbound email.
129#[derive(Debug, Clone)]
130pub struct Attachment {
131    pub filename: String,
132    pub content_type: String,
133    pub bytes: Vec<u8>,
134}
135
136/// A fully-specified outbound email. Built once; the alert path wraps it.
137#[derive(Debug, Clone, Default)]
138pub struct OutboundEmail {
139    /// Overrides the transport `from` when set (agent inboxes send as an inbox).
140    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    /// RFC 5322 threading headers (agent-inbox replies).
149    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    /// No relay configured (no host or no password) — email is disabled.
157    NotConfigured,
158    /// A recipient/from address failed to parse.
159    InvalidAddress(String),
160    /// Building the MIME message failed.
161    Build(String),
162    /// Building the SMTP transport failed (bad host/TLS).
163    Transport(String),
164    /// The relay rejected the send.
165    Send(String),
166    /// The send exceeded [`SEND_TIMEOUT`].
167    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
185/// In-process transport config cache, populated from preferences (the desktop
186/// SMTP card writes it; a prefs handler calls [`set_transport`]). `None` falls
187/// back to the `RYU_SMTP_*` environment for headless self-host.
188static TRANSPORT: RwLock<Option<StoredTransport>> = RwLock::new(None);
189
190/// The non-secret transport fields held in the cache (password comes from
191/// [`crate::smtp_auth`] at resolve time, never cached here).
192#[derive(Debug, Clone)]
193struct StoredTransport {
194    host: String,
195    port: u16,
196    username: String,
197    from: String,
198    starttls: bool,
199}
200
201/// Set (or clear, when `host` is empty) the in-process transport config from a
202/// preferences value. The password is set separately via
203/// [`crate::smtp_auth::set_password`].
204pub 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
221/// Resolve the effective transport: cached prefs first, else `RYU_SMTP_*` env.
222/// Returns `None` when no host or no password is available (email disabled).
223pub 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    // Headless self-host fallback: RYU_SMTP_HOST / _PORT / _USERNAME / _FROM /
240    // _STARTTLS (password already resolved above).
241    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
271/// Generate a deterministic-enough, collision-free Message-ID for threading.
272fn 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    // Domain part from the `from` address if present, else a stable placeholder.
280    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
288/// Assemble the MIME body (text / html / multipart) plus any attachments.
289fn 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    // With attachments, wrap the body in a mixed multipart.
305    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
323/// Send a fully-specified email over the given BYO SMTP transport. Returns the
324/// Message-ID on success (for threading / provider-id records). Bounded by
325/// [`SEND_TIMEOUT`].
326pub 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
385/// Thin single-recipient plain-text alert send over the given transport.
386pub 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    //! Unit coverage for the pure/private message-construction seams and the
407    //! `send_email` early-return validation paths. None of these tests touch the
408    //! process-global `TRANSPORT` / `PASSWORD_RESOLVER` statics or environment, so
409    //! they are parallel-safe. The env-fallback + transport-cache branches (which
410    //! *do* mutate globals) live in the single serialized `tests/sink.rs` test.
411
412    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    // --- generate_message_id: domain extraction + uniqueness -----------------
422
423    #[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        // `Name <local@domain>` — the trailing `>` must be stripped.
433        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        // `local@` — an empty domain part must not produce `@>`.
446        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    // --- parse_mailbox: valid / display-name / trimming / invalid ------------
461
462    #[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    // --- build_body: every content branch + attachment fallback --------------
482
483    #[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        // Neither text nor html ⇒ an empty plain part (not an error).
519        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        // text + html (an alternative multipart) *and* an attachment: the
548        // alternative body is nested directly inside the mixed wrapper.
549        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        // A bad content-type must not panic — it falls back to octet-stream.
571        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    // --- EmailError: Display for every variant + Debug ------------------------
588
589    #[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        // Debug is derived; exercise it so the derive is covered.
613        assert!(format!("{:?}", EmailError::Timeout).contains("Timeout"));
614    }
615
616    // --- TransportPrefs: serde defaults for omitted fields -------------------
617
618    #[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    // --- send_email: validation error paths (return before any socket) -------
642
643    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    // --- send_email: the SMTP transport-build + send() legs, exercised against
747    // a loopback listener that accepts then immediately closes. This is fully
748    // hermetic (loopback only, ephemeral port, no DNS, no external egress, no
749    // secret leaves the box); the reset surfaces as `EmailError::Send`. Covers
750    // both transport-build branches (STARTTLS submission vs implicit-TLS relay).
751
752    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            // Accept a few connection attempts, dropping each at once so the peer
758            // sees a reset while reading the SMTP greeting / TLS handshake.
759            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        // `multipart_body` toggles the send-path body branch: a text+html
779        // alternative (multipart send) vs a text-only singlepart send.
780        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        // The message + transport build must succeed; the send itself must fail
793        // (never NotConfigured — the config here is fully specified).
794        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        // Implicit-TLS branch *and* the singlepart send branch.
809        expect_send_failure(false, false).await;
810    }
811}