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)) => {
292            MultiPartOrSingle::Multi(MultiPart::alternative_plain_html(text.clone(), html.clone()))
293        }
294        (Some(text), None) => MultiPartOrSingle::Single(SinglePart::plain(text.clone())),
295        (None, Some(html)) => MultiPartOrSingle::Single(SinglePart::html(html.clone())),
296        (None, None) => MultiPartOrSingle::Single(SinglePart::plain(String::new())),
297    };
298
299    if msg.attachments.is_empty() {
300        return Ok(content);
301    }
302
303    // With attachments, wrap the body in a mixed multipart.
304    let mut mixed = MultiPart::mixed().multipart(match content {
305        MultiPartOrSingle::Multi(m) => m,
306        MultiPartOrSingle::Single(s) => MultiPart::mixed().singlepart(s),
307    });
308    for att in &msg.attachments {
309        let ct = ContentType::parse(&att.content_type)
310            .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
311        mixed = mixed.singlepart(
312            LettreAttachment::new(att.filename.clone()).body(att.bytes.clone(), ct),
313        );
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}