Skip to main content

wasm_smtp_component/
lib.rs

1//! # wasm-smtp-component
2//!
3//! WASM Component Model interface for [`wasm-smtp`].
4//!
5//! This crate exports the [`smtp-send`][`crate`] WIT interface defined in
6//! `wit/smtp.wit`, allowing any language with WIT bindings to send email
7//! through `wasm-smtp` without writing Rust.
8//!
9//! ## Building
10//!
11//! ```sh
12//! # Requires cargo-component and the wasm32-wasip2 target.
13//! cargo component build --target wasm32-wasip2 -p wasm-smtp-component
14//! # Output: target/wasm32-wasip2/debug/wasm_smtp_component.wasm
15//! ```
16//!
17//! ## Running tests on native
18//!
19//! ```sh
20//! cargo test -p wasm-smtp-component
21//! ```
22//!
23//! ## Credential security
24//!
25//! Credentials cross the host-component boundary as plain WIT strings on
26//! every `send` call. They are not retained by the component between calls.
27//! See `docs/src/adapters/component-model.md` for the full threat model.
28//!
29//! [`wasm-smtp`]: https://docs.rs/wasm-smtp
30
31// ── WIT bindings (wasm32 only) ────────────────────────────────────────────
32
33#[cfg(target_arch = "wasm32")]
34mod bindings {
35    wit_bindgen::generate!({
36        path: "../../wit",
37        world: "smtp-client",
38        // Disable the default std export so the component doesn't pull in
39        // the standard library's panic handler unconditionally.
40        exports: {
41            "wasm-smtp:smtp/smtp-send": super::SmtpSendImpl,
42        },
43    });
44}
45
46// ── Type aliases (shared between wasm32 and native stubs) ─────────────────
47
48#[cfg(target_arch = "wasm32")]
49use bindings::exports::wasm_smtp::smtp::smtp_send::{
50    Guest, SendError, SendResult, SmtpConfig, SmtpCredentials, SmtpMessage, TlsMode,
51};
52
53/// Native-only stubs so the crate compiles and tests on non-WASM hosts.
54#[cfg(not(target_arch = "wasm32"))]
55mod stubs;
56
57#[cfg(not(target_arch = "wasm32"))]
58use stubs::{SendError, SendResult, SmtpConfig, SmtpCredentials, SmtpMessage};
59
60// ── Core implementation ───────────────────────────────────────────────────
61
62/// The struct that implements the `smtp-send` WIT interface.
63pub struct SmtpSendImpl;
64
65impl SmtpSendImpl {
66    /// Core send logic, shared between the WIT export and native tests.
67    #[allow(unused_variables)]
68    pub fn send_impl(
69        config: SmtpConfig,
70        credentials: SmtpCredentials,
71        message: SmtpMessage,
72    ) -> Result<SendResult, SendError> {
73        use wasm_smtp::SmtpError;
74
75        // Build the transport and SmtpClient using wasm-smtp-wasi.
76        #[cfg(target_arch = "wasm32")]
77        let client_result = {
78            let opts = ConnectOptions::default();
79            let fut = match config.tls_mode {
80                TlsMode::Implicit => wasm_smtp_wasi::connect_smtps(
81                    &config.host,
82                    config.port,
83                    &config.ehlo_domain,
84                ),
85                TlsMode::Starttls => wasm_smtp_wasi::connect_smtp_starttls(
86                    &config.host,
87                    config.port,
88                    &config.ehlo_domain,
89                ),
90            };
91            // Drive the async future synchronously via WASI blocking poll.
92            // On wasm32-wasip2, the executor is provided by the runtime.
93            wasm_smtp_component_rt::block_on(fut)
94        };
95
96        #[cfg(not(target_arch = "wasm32"))]
97        let client_result: Result<_, SmtpError> =
98            Err(SmtpError::Io(wasm_smtp::IoError::new(
99                "wasm-smtp-component only runs on wasm32-wasip2; \
100                 use cargo test for native unit tests",
101            )));
102
103        let mut _client = client_result.map_err(smtp_error_to_wit)?;
104
105        // Authenticate.
106        #[cfg(target_arch = "wasm32")]
107        wasm_smtp_component_rt::block_on(
108            client.login(&credentials.username, &credentials.password),
109        )
110        .map_err(smtp_error_to_wit)?;
111
112        // Send the message.
113        let to_refs: Vec<&str> = message.to.iter().map(String::as_str).collect();
114
115        #[cfg(target_arch = "wasm32")]
116        let outcome = wasm_smtp_component_rt::block_on(client.send_mail(
117            &message.from,
118            &to_refs,
119            &message.raw_message,
120        ))
121        .map_err(smtp_error_to_wit)?;
122
123        #[cfg(not(target_arch = "wasm32"))]
124        #[allow(unreachable_code)]
125        let _outcome: wasm_smtp::SendOutcome = {
126            let _ = (&message.from, &to_refs, &message.raw_message);
127            return Err(SendError::Io("unreachable on native host".into()));
128            unreachable!()
129        };
130
131        // QUIT (best-effort; ignore errors to not mask a successful send).
132        #[cfg(target_arch = "wasm32")]
133        { wasm_smtp_component_rt::block_on(client.quit()).ok(); }
134
135        #[cfg(target_arch = "wasm32")]
136        return Ok(SendResult { reply_code: outcome.code });
137        #[cfg(not(target_arch = "wasm32"))]
138        Ok(SendResult { reply_code: _outcome.code })
139    }
140}
141
142// ── WIT Guest impl (wasm32 only) ──────────────────────────────────────────
143
144#[cfg(target_arch = "wasm32")]
145impl Guest for SmtpSendImpl {
146    fn send(
147        config: SmtpConfig,
148        credentials: SmtpCredentials,
149        message: SmtpMessage,
150    ) -> Result<SendResult, SendError> {
151        Self::send_impl(config, credentials, message)
152    }
153}
154
155// ── Error mapping ─────────────────────────────────────────────────────────
156
157fn smtp_error_to_wit(e: wasm_smtp::SmtpError) -> SendError {
158    match e {
159        wasm_smtp::SmtpError::Io(io) => SendError::Io(io.to_string()),
160        wasm_smtp::SmtpError::Protocol(p) => SendError::Protocol(p.to_string()),
161        wasm_smtp::SmtpError::Auth(_) => SendError::AuthRejected,
162        wasm_smtp::SmtpError::InvalidInput(i) => SendError::InvalidInput(i.to_string()),
163        wasm_smtp::SmtpError::Policy(p) => SendError::PolicyRejected(p.to_string()),
164    }
165}
166
167// ── Tests ─────────────────────────────────────────────────────────────────
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172
173    #[test]
174    fn send_error_io_variant_carries_message() {
175        let e = SendError::Io("connection refused".into());
176        match e {
177            SendError::Io(msg) => assert_eq!(msg, "connection refused"),
178            _ => panic!("wrong variant"),
179        }
180    }
181
182    #[test]
183    fn send_error_auth_rejected_has_no_payload() {
184        let e = SendError::AuthRejected;
185        assert!(matches!(e, SendError::AuthRejected));
186    }
187
188    #[test]
189    fn smtp_config_fields_accessible() {
190        let cfg = SmtpConfig {
191            host: "smtp.example.com".into(),
192            port: 465,
193            ehlo_domain: "client.example.com".into(),
194            tls_mode: TlsMode::Implicit,
195        };
196        assert_eq!(cfg.host, "smtp.example.com");
197        assert_eq!(cfg.port, 465);
198    }
199
200    #[test]
201    fn smtp_message_to_is_vec() {
202        let msg = SmtpMessage {
203            from: "a@example.com".into(),
204            to: vec!["b@example.com".into(), "c@example.com".into()],
205            raw_message: "Subject: hi\r\n\r\nbody\r\n".into(),
206        };
207        assert_eq!(msg.to.len(), 2);
208    }
209
210    #[test]
211    fn credentials_fields_accessible() {
212        let cred = SmtpCredentials {
213            username: "user@example.com".into(),
214            password: "secret".into(),
215        };
216        assert_eq!(cred.username, "user@example.com");
217        // password is accessible (it's a plain struct field) but must never
218        // appear in any log, error, or debug output.
219        assert!(!format!("{cred:?}").contains("secret"),
220                "credentials must not appear in Debug output");
221    }
222}