Skip to main content

cloud/reconciler/
smtp_driver.rs

1//! Bring-up for the dev/pond-tier `smtp` capability driver — the
2//! `yah-smtp-dev` workload (W265, R584-F2).
3//!
4//! Third instance of the capability/driver shape behind [`super::pg_driver`],
5//! and deliberately the same shape rather than a better one: a free function
6//! the camp daemon calls once at tier bring-up, not a [`super::Reconciler`],
7//! because a driver is the *tier's* implementation of a capability rather than
8//! anybody's component. Read `pg_driver`'s module doc for why that distinction
9//! exists; everything it says about kamaji's role applies here unchanged.
10//!
11//! # What differs from pg, and why
12//!
13//! **Two listeners, both named.** mailcrab serves SMTP and an HTTP inbox, and
14//! the inbox is the half the operator actually asked for. A workload declaring
15//! two ports and naming neither gets no `http` — `yah cloud apply` refuses to
16//! guess which one the front door fronts, and the Run tab has nothing to build
17//! a URL from. So [`up_smtp_driver`] states both names in
18//! `expose.mesh.ports`, which is what earns the inbox a `PORT` alias and a
19//! reachable URL. See `.yah/docs/guides/write-a-service-toml.md` §"Ports".
20//!
21//! **Name-only, not pinned.** Both entries are [`MeshPort::named`] — kamaji's
22//! native backend allocates the number, remembers it per (workload, port name)
23//! across a supervisor restart, and tells the driver via `PORT_SMTP` /
24//! `PORT_HTTP`. That is the guide's preferred spelling and it is the right one
25//! here: mailcrab's documented defaults (1025/1080) are *conventions*, not
26//! reservations, and two camps on one laptop would collide on them. The driver
27//! falls back to 1025/1080 when nothing allocated a port — i.e. when an
28//! operator runs it by hand — so the familiar numbers still work outside
29//! kamaji. Consumers read the real ports out of `coords.json`.
30//!
31//! **No per-service fan-out.** pg needs one database per service and therefore
32//! a list; a mail catcher is one shared inbox, so activation is a boolean:
33//! does any mirror at this tier bind `[drivers.smtp]`.
34
35use std::collections::BTreeMap;
36use std::net::Ipv4Addr;
37use std::path::{Path, PathBuf};
38use std::sync::Arc;
39use std::time::{Duration, Instant};
40
41use anyhow::{Context, Result};
42use kamaji::native::NativeRuntime;
43use kamaji::{Kamaji, MeshAssignment, MeshIdent};
44use tracing::{info, warn};
45use workload_spec::MeshPort;
46
47use super::native_support::{native_spec, sanitize_ident};
48use crate::capability::Capability;
49use crate::config::{MirrorConfig, Provider, ServiceWithMirrors};
50
51/// Mesh ident of the camp's single SMTP driver. Camp-scoped, not per-service —
52/// one catcher holds every service's outbound mail, which is also what makes
53/// the inbox useful to look at.
54pub const SMTP_DRIVER_IDENT: &str = "yah-smtp-dev";
55
56/// Environment variable overriding the `yah-smtp-dev` binary path, mirroring
57/// [`super::pg_driver::PG_DEV_BIN_ENV`].
58pub const SMTP_DEV_BIN_ENV: &str = "YAH_SMTP_DEV_BIN";
59
60/// Port names declared in `expose.mesh.ports`. These are the strings kamaji
61/// uppercases into `PORT_SMTP` / `PORT_HTTP`, and `http` specifically is the
62/// name that earns the `PORT` alias and the front-door URL — renaming it to
63/// something more descriptive (`inbox`, `ui`) would silently cost both.
64pub const PORT_NAME_SMTP: &str = "smtp";
65pub const PORT_NAME_HTTP: &str = "http";
66
67/// Tiers whose mirrors may bind this driver. Unlike pg — which is dev-only
68/// because the pond tier runs containers — a mail *catcher* is correct at both
69/// dev and pond: neither should ever deliver real mail, and pond gains nothing
70/// from a containerized catcher that a supervised 10 MB binary does not give
71/// it. Cloud and ha are deliberately absent; a driver that swallows mail must
72/// not be bindable at a tier where mail is expected to arrive.
73const CATCHER_ENVS: &[&str] = &["dev", "pond"];
74
75/// How the camp brings the driver up.
76#[derive(Debug, Clone, Default)]
77pub struct SmtpDriverOptions {
78    /// Explicit binary path. Falls back to [`SMTP_DEV_BIN_ENV`], then to bare
79    /// `yah-smtp-dev` resolved on `PATH` at spawn time.
80    pub binary: Option<PathBuf>,
81    /// How long to wait for `coords.json` after the workload is deployed.
82    /// Default 120s — a *cold* camp downloads a ~10 MB mailcrab release inside
83    /// this window; warm bring-up is a fork+exec and a TCP probe.
84    pub ready_timeout: Option<Duration>,
85}
86
87impl SmtpDriverOptions {
88    fn resolved_binary(&self) -> PathBuf {
89        if let Some(ref p) = self.binary {
90            return p.clone();
91        }
92        if let Some(p) = std::env::var_os(SMTP_DEV_BIN_ENV) {
93            return PathBuf::from(p);
94        }
95        PathBuf::from("yah-smtp-dev")
96    }
97
98    fn ready_timeout(&self) -> Duration {
99        self.ready_timeout.unwrap_or(Duration::from_secs(120))
100    }
101}
102
103/// A brought-up SMTP driver.
104pub struct RunningSmtpDriver {
105    /// Port the SMTP listener accepted on, read back out of `coords.json`.
106    pub smtp_port: u16,
107    /// Port the web inbox is served on.
108    pub http_port: u16,
109    /// Browser URL for the inbox — the operator-facing half of this driver.
110    pub inbox_url: String,
111    runtime: Arc<NativeRuntime>,
112    ident: MeshIdent,
113}
114
115impl RunningSmtpDriver {
116    /// Stop the driver, which in turn stops mailcrab.
117    ///
118    /// Captured mail is held in mailcrab's memory and is gone either way — a
119    /// catcher is a window onto what an app *just* sent, not an archive. That
120    /// is why teardown here is a plain kill and not the careful clean-shutdown
121    /// dance `pg_driver` needs.
122    pub async fn teardown(&self) {
123        self.runtime.teardown_workload(&self.ident).await.ok();
124    }
125}
126
127/// `true` when some mirror in this camp, at a tier a catcher belongs to, binds
128/// `smtp` to `local-mailcrab`.
129///
130/// `false` means no service asked for mail capture and the caller should not
131/// spawn the driver at all. That is the same call [`super::pg_driver`] makes
132/// and for the same reason: most services never send mail, so a driver that
133/// was default-on would have every camp on the machine downloading and
134/// supervising a mail catcher nobody opens.
135pub fn camp_binds_smtp_driver(services: &BTreeMap<String, ServiceWithMirrors>) -> bool {
136    services.values().any(|svc| {
137        CATCHER_ENVS.iter().any(|env| {
138            svc.mirrors
139                .get(*env)
140                .is_some_and(|mirror| binds_local_mailcrab(mirror))
141        })
142    })
143}
144
145/// `true` when this mirror binds `smtp` to the mailcrab driver.
146fn binds_local_mailcrab(mirror: &MirrorConfig) -> bool {
147    mirror
148        .driver(Capability::Smtp)
149        .and_then(|slot| slot.inline_kind())
150        == Some(Provider::LocalMailcrab)
151}
152
153/// Path of the driver's coordinates file. Mirrors `yah_smtp_dev::coords_path`.
154pub fn coords_path(workspace_root: &Path) -> PathBuf {
155    workspace_root.join(".yah/infra/state/dev/smtp/coords.json")
156}
157
158/// Deploy `yah-smtp-dev` on a kamaji [`NativeRuntime`] and wait for it to
159/// publish coordinates.
160pub async fn up_smtp_driver(
161    workspace_root: &Path,
162    opts: &SmtpDriverOptions,
163) -> Result<RunningSmtpDriver> {
164    let binary = opts.resolved_binary();
165    let ident_str = sanitize_ident(SMTP_DRIVER_IDENT);
166    let ident = MeshIdent(ident_str.clone());
167
168    let argv: Vec<String> = vec![
169        binary.display().to_string(),
170        "serve".to_string(),
171        "--workspace".to_string(),
172        workspace_root.display().to_string(),
173    ];
174
175    // Coordinates from a previous run describe listeners that may or may not
176    // still be up. Retract them first so `wait_for_coords` cannot succeed on a
177    // stale file and hand the camp two dead ports.
178    let coords = coords_path(workspace_root);
179    let _ = std::fs::remove_file(&coords);
180
181    let mut spec = native_spec(&ident_str, argv, Vec::new());
182    // The two named listeners. See the module doc for why both are name-only
183    // and why the second one is called `http` rather than `inbox`.
184    spec.expose.mesh.ports = vec![
185        MeshPort::named(PORT_NAME_SMTP),
186        MeshPort::named(PORT_NAME_HTTP),
187    ];
188
189    let state_dir = workspace_root.join(".yah/jit/native");
190    let runtime = Arc::new(NativeRuntime::new(&state_dir));
191    let mesh = MeshAssignment::inlined(Ipv4Addr::LOCALHOST);
192
193    info!(
194        binary = %binary.display(),
195        ident = %ident_str,
196        "spawning yah-smtp-dev (kamaji native backend)",
197    );
198
199    runtime
200        .deploy_workload(&spec, &mesh)
201        .await
202        .with_context(|| {
203            format!(
204                "deploying the dev-tier smtp driver via kamaji — install it with \
205                 `cargo install --path crates/yah/smtp-dev` or point {SMTP_DEV_BIN_ENV} \
206                 at the binary ({})",
207                binary.display(),
208            )
209        })?;
210
211    let timeout = opts.ready_timeout();
212    let Some(ready) = wait_for_coords(&coords, timeout).await else {
213        warn!(timeout = ?timeout, "yah-smtp-dev did not publish coords; tearing down");
214        runtime.teardown_workload(&ident).await.ok();
215        let (_out, err) = super::native_support::capture_paths(&state_dir, &ident_str);
216        anyhow::bail!(
217            "the dev-tier smtp driver did not become ready within {timeout:?} — \
218             check {} for why",
219            err.display(),
220        );
221    };
222
223    info!(
224        smtp_port = ready.smtp_port,
225        http_port = ready.http_port,
226        inbox = %ready.inbox_url,
227        "dev-tier smtp driver ready",
228    );
229    Ok(RunningSmtpDriver {
230        smtp_port: ready.smtp_port,
231        http_port: ready.http_port,
232        inbox_url: ready.inbox_url,
233        runtime,
234        ident,
235    })
236}
237
238/// The subset of the driver's `coords.json` the camp needs. Read structurally
239/// rather than by depending on `yah_smtp_dev::Coords`, for the same reason
240/// `pg_driver` re-spells its database-name rule: `cloud` must not take a
241/// dependency on a separately-built plugin crate.
242#[derive(Debug, Clone, PartialEq, Eq)]
243struct ReadyCoords {
244    smtp_port: u16,
245    http_port: u16,
246    inbox_url: String,
247}
248
249/// Poll for the driver's `coords.json`. See [`super::pg_driver`] for why this
250/// polls rather than watches.
251async fn wait_for_coords(path: &Path, timeout: Duration) -> Option<ReadyCoords> {
252    let deadline = Instant::now() + timeout;
253    while Instant::now() < deadline {
254        if let Some(coords) = read_coords(path) {
255            return Some(coords);
256        }
257        tokio::time::sleep(Duration::from_millis(100)).await;
258    }
259    None
260}
261
262/// Coordinates from a *complete* `coords.json`, or `None` when the file is
263/// absent, half-written, or reports a zero port on either listener.
264///
265/// Both ports are required: a file naming only the SMTP port describes a
266/// driver whose inbox is not up, and handing that to the Run tab would render
267/// a URL card pointing at nothing.
268fn read_coords(path: &Path) -> Option<ReadyCoords> {
269    let bytes = std::fs::read(path).ok()?;
270    let v: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
271    let port = |key: &str| -> Option<u16> {
272        let n = u16::try_from(v.get(key)?.as_u64()?).ok()?;
273        (n != 0).then_some(n)
274    };
275    let smtp_port = port("smtp_port")?;
276    let http_port = port("http_port")?;
277    let inbox_url = v
278        .get("inbox_url")
279        .and_then(|u| u.as_str())
280        .map(str::to_string)
281        .unwrap_or_else(|| format!("http://127.0.0.1:{http_port}/"));
282    Some(ReadyCoords {
283        smtp_port,
284        http_port,
285        inbox_url,
286    })
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::config::ServiceConfig;
293
294    fn service(mirrors: &[(&str, &str)]) -> ServiceWithMirrors {
295        let service: ServiceConfig =
296            toml::from_str("schema_version = 1\nname = \"svc\"\ndomain = \"svc.example\"\n")
297                .expect("parse service");
298        ServiceWithMirrors {
299            service,
300            mirrors: mirrors
301                .iter()
302                .map(|(env, src)| {
303                    (
304                        (*env).to_string(),
305                        toml::from_str::<MirrorConfig>(src).expect("parse mirror"),
306                    )
307                })
308                .collect(),
309            component_transform_recipes: BTreeMap::new(),
310            passway_machines: BTreeMap::new(),
311        }
312    }
313
314    const BINDS_SMTP: &str = r#"
315schema_version = 1
316shape = "local"
317[drivers.smtp]
318kind = "local-mailcrab"
319"#;
320
321    const BINDS_PG: &str = r#"
322schema_version = 1
323shape = "local"
324[drivers.pg]
325kind = "local-pg-dev"
326"#;
327
328    fn services(entries: Vec<(&str, ServiceWithMirrors)>) -> BTreeMap<String, ServiceWithMirrors> {
329        entries
330            .into_iter()
331            .map(|(n, s)| (n.to_string(), s))
332            .collect()
333    }
334
335    #[test]
336    fn one_mirror_binding_smtp_activates_the_camps_driver() {
337        let svcs = services(vec![
338            ("quiet", service(&[("dev", BINDS_PG)])),
339            ("mailer", service(&[("dev", BINDS_SMTP)])),
340        ]);
341        assert!(camp_binds_smtp_driver(&svcs));
342    }
343
344    #[test]
345    fn a_camp_that_binds_no_smtp_driver_spawns_nothing() {
346        let svcs = services(vec![("quiet", service(&[("dev", BINDS_PG)]))]);
347        assert!(!camp_binds_smtp_driver(&svcs));
348    }
349
350    /// pond binds the same catcher; prod must not be able to.
351    #[test]
352    fn pond_counts_and_cloud_does_not() {
353        assert!(camp_binds_smtp_driver(&services(vec![(
354            "mailer",
355            service(&[("pond", BINDS_SMTP)])
356        )])));
357        assert!(!camp_binds_smtp_driver(&services(vec![(
358            "mailer",
359            service(&[("prod", BINDS_SMTP)])
360        )])));
361    }
362
363    /// The two named listeners are the acceptance criterion of R584-F2, and
364    /// `http` in particular is load-bearing: it is the name `PORT` aliases and
365    /// the one the Run tab resolves the front door from.
366    #[test]
367    fn the_spec_declares_both_listeners_by_name() {
368        let mut spec = native_spec("yah-smtp-dev", vec!["yah-smtp-dev".to_string()], Vec::new());
369        spec.expose.mesh.ports = vec![
370            MeshPort::named(PORT_NAME_SMTP),
371            MeshPort::named(PORT_NAME_HTTP),
372        ];
373        let names = spec.expose.mesh.names();
374        assert!(names.contains(&"smtp"), "missing smtp: {names:?}");
375        assert!(names.contains(&"http"), "missing http: {names:?}");
376        // Name-only: kamaji picks the numbers. A pinned number here would be a
377        // collision waiting for the second camp on this laptop.
378        assert!(spec.expose.mesh.ports.iter().all(|p| p.number.is_none()));
379        workload_spec::validate::shape(&spec).expect("spec must validate");
380    }
381
382    #[tokio::test]
383    async fn coords_are_incomplete_until_both_listeners_report() {
384        let tmp = tempfile::tempdir().unwrap();
385        let path = tmp.path().join("coords.json");
386        let brief = Duration::from_millis(150);
387
388        assert_eq!(wait_for_coords(&path, brief).await, None);
389        // SMTP up, inbox not yet — not ready.
390        std::fs::write(&path, br#"{"smtp_port":1025,"http_port":0}"#).unwrap();
391        assert_eq!(wait_for_coords(&path, brief).await, None);
392        // Half-written file.
393        std::fs::write(&path, br#"{"smtp_port":102"#).unwrap();
394        assert_eq!(wait_for_coords(&path, brief).await, None);
395    }
396
397    #[tokio::test]
398    async fn a_complete_coords_file_yields_both_ports_and_the_inbox_url() {
399        let tmp = tempfile::tempdir().unwrap();
400        let path = tmp.path().join("coords.json");
401        std::fs::write(
402            &path,
403            br#"{"smtp_port":51001,"http_port":51002,"inbox_url":"http://127.0.0.1:51002/"}"#,
404        )
405        .unwrap();
406        assert_eq!(
407            wait_for_coords(&path, Duration::from_secs(1)).await,
408            Some(ReadyCoords {
409                smtp_port: 51001,
410                http_port: 51002,
411                inbox_url: "http://127.0.0.1:51002/".to_string(),
412            })
413        );
414    }
415
416    /// An older driver that published ports but no URL still has to be usable —
417    /// the URL is derivable from the port it did publish.
418    #[test]
419    fn a_missing_inbox_url_is_derived_from_the_http_port() {
420        let tmp = tempfile::tempdir().unwrap();
421        let path = tmp.path().join("coords.json");
422        std::fs::write(&path, br#"{"smtp_port":1025,"http_port":1080}"#).unwrap();
423        assert_eq!(
424            read_coords(&path).unwrap().inbox_url,
425            "http://127.0.0.1:1080/"
426        );
427    }
428
429    #[test]
430    fn binary_resolution_prefers_explicit_over_env_over_path() {
431        let explicit = SmtpDriverOptions {
432            binary: Some(PathBuf::from("/opt/yah-smtp-dev")),
433            ..Default::default()
434        };
435        assert_eq!(
436            explicit.resolved_binary(),
437            PathBuf::from("/opt/yah-smtp-dev")
438        );
439        if std::env::var_os(SMTP_DEV_BIN_ENV).is_none() {
440            assert_eq!(
441                SmtpDriverOptions::default().resolved_binary(),
442                PathBuf::from("yah-smtp-dev")
443            );
444        }
445    }
446}