Skip to main content

qualia_client_core/
mail_transport.rs

1//! SMTP send + IMAP fetch transport for the semantic mail client.
2//!
3//! This module is the **network edge** for mail: it puts bytes on the wire (SMTP over
4//! STARTTLS) and pulls unseen messages off it (IMAP over implicit TLS). It deliberately does
5//! *not* make delivery decisions — those belong to the pure rules layer in
6//! [`crate::mail_rules`]. The bridge between the two is [`build_inbound`], a pure function that
7//! constructs the [`crate::mail_rules::InboundMessage`] envelope the rules engine consumes; the
8//! IMAP path uses it to turn each fetched message into something [`crate::mail_rules::evaluate`]
9//! can rule on.
10//!
11//! Network functions are gated `#[cfg(not(target_arch = "wasm32"))]` — there is no raw-socket
12//! SMTP/IMAP on the `wasm32` target, so only the pure surface ([`SmtpConfig`], [`ImapConfig`],
13//! [`OutgoingMail`], [`build_inbound`]) compiles there.
14//!
15//! All fallible network functions map their underlying errors to `String` so callers get a flat,
16//! transport-agnostic error surface.
17
18use serde::{Deserialize, Serialize};
19
20/// Connection + credentials for an outbound SMTP submission server (STARTTLS on the submission port).
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct SmtpConfig {
23    /// The SMTP server hostname (e.g. `smtp.example.org`).
24    pub host: String,
25    /// The submission port (commonly `587` for STARTTLS).
26    pub port: u16,
27    /// The SMTP username (usually the full mailbox address).
28    pub username: String,
29    /// The SMTP password / app-password / token.
30    pub password: String,
31}
32
33/// Connection + credentials for an inbound IMAP server (implicit TLS on the IMAPS port).
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct ImapConfig {
36    /// The IMAP server hostname (e.g. `imap.example.org`).
37    pub host: String,
38    /// The IMAPS port (commonly `993` for implicit TLS).
39    pub port: u16,
40    /// The IMAP username (usually the full mailbox address).
41    pub username: String,
42    /// The IMAP password / app-password / token.
43    pub password: String,
44}
45
46/// A message to be sent via [`send`].
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OutgoingMail {
49    /// The sender address (`from`), e.g. `me@example.org`.
50    pub from: String,
51    /// The recipient address (`to`), e.g. `alice@example.org`.
52    pub to: String,
53    /// The subject line.
54    pub subject: String,
55    /// The plain-text body.
56    pub body: String,
57}
58
59/// Construct an [`InboundMessage`](crate::mail_rules::InboundMessage) envelope for the rules engine.
60///
61/// This is **pure**: it only populates the struct from its arguments — no I/O, no clock. It is the
62/// single place both real IMAP delivery and tests build the envelope the rules engine consumes, so
63/// the mapping from wire data to the rules' view lives in exactly one spot.
64pub fn build_inbound(
65    from: &str,
66    to: &str,
67    subject: &str,
68    size: usize,
69    sender_verified: bool,
70    sender_did: Option<String>,
71) -> crate::mail_rules::InboundMessage {
72    crate::mail_rules::InboundMessage {
73        from_address: from.to_string(),
74        to_address: to.to_string(),
75        sender_did,
76        sender_verified,
77        subject: subject.to_string(),
78        size_bytes: size,
79    }
80}
81
82/// Send `mail` through the SMTP submission server described by `cfg` (STARTTLS).
83///
84/// Builds a plain-text [`lettre::Message`], opens a pooled STARTTLS relay to `cfg.host:cfg.port`
85/// authenticating with `cfg.username` / `cfg.password`, and submits the message. All underlying
86/// errors (address parse, transport build, send) are flattened to `String`.
87#[cfg(not(target_arch = "wasm32"))]
88pub fn send(cfg: &SmtpConfig, mail: &OutgoingMail) -> Result<(), String> {
89    use lettre::transport::smtp::authentication::Credentials;
90    use lettre::{Message, SmtpTransport, Transport};
91
92    let from = mail
93        .from
94        .parse::<lettre::message::Mailbox>()
95        .map_err(|e| format!("invalid from address {:?}: {}", mail.from, e))?;
96    let to = mail
97        .to
98        .parse::<lettre::message::Mailbox>()
99        .map_err(|e| format!("invalid to address {:?}: {}", mail.to, e))?;
100
101    let msg: Message = Message::builder()
102        .from(from)
103        .to(to)
104        .subject(mail.subject.clone())
105        .body(mail.body.clone())
106        .map_err(|e| format!("failed to build message: {}", e))?;
107
108    let creds = Credentials::new(cfg.username.clone(), cfg.password.clone());
109
110    let transport = SmtpTransport::starttls_relay(&cfg.host)
111        .map_err(|e| format!("failed to build STARTTLS relay for {:?}: {}", cfg.host, e))?
112        .port(cfg.port)
113        .credentials(creds)
114        .build();
115
116    transport
117        .send(&msg)
118        .map(|_| ())
119        .map_err(|e| format!("SMTP send failed: {}", e))
120}
121
122/// Fetch the unseen messages from `mailbox` on the IMAP server described by `cfg` (implicit TLS).
123///
124/// Connects over implicit TLS to `cfg.host:cfg.port`, logs in, selects `mailbox`, searches for
125/// `UNSEEN` messages, and for each fetches `RFC822.SIZE ENVELOPE`. Each fetched message is turned
126/// into an [`InboundMessage`](crate::mail_rules::InboundMessage) via [`build_inbound`] using the
127/// envelope's `from` (first sender address, reconstructed as `local@host`) and `subject`, its
128/// reported size, and `mailbox` as the recipient. Because IMAP does not itself attest sender
129/// identity, `sender_verified` is `false` and `sender_did` is `None`; verification is a higher-layer
130/// concern. The session is logged out before returning. This is written defensively — a message with
131/// no envelope or no size is skipped rather than failing the whole fetch — and all underlying errors
132/// are flattened to `String`.
133#[cfg(not(target_arch = "wasm32"))]
134pub fn fetch_unseen(
135    cfg: &ImapConfig,
136    mailbox: &str,
137) -> Result<Vec<crate::mail_rules::InboundMessage>, String> {
138    let tls = native_tls::TlsConnector::builder()
139        .build()
140        .map_err(|e| format!("failed to build TLS connector: {}", e))?;
141
142    let client = imap::connect((cfg.host.as_str(), cfg.port), &cfg.host, &tls)
143        .map_err(|e| format!("IMAP connect to {:?}:{} failed: {}", cfg.host, cfg.port, e))?;
144
145    let mut session = client
146        .login(&cfg.username, &cfg.password)
147        // On login failure imap returns (Error, Client); keep only the error text.
148        .map_err(|e| format!("IMAP login failed: {}", e.0))?;
149
150    // Ensure we log out even if a later step fails.
151    let result = (|| -> Result<Vec<crate::mail_rules::InboundMessage>, String> {
152        session
153            .select(mailbox)
154            .map_err(|e| format!("IMAP SELECT {:?} failed: {}", mailbox, e))?;
155
156        let unseen = session
157            .search("UNSEEN")
158            .map_err(|e| format!("IMAP SEARCH UNSEEN failed: {}", e))?;
159
160        let mut out: Vec<crate::mail_rules::InboundMessage> = Vec::with_capacity(unseen.len());
161
162        for uid in unseen {
163            // Fetch by sequence number; ask only for the envelope + size.
164            let fetches = match session.fetch(uid.to_string(), "RFC822.SIZE ENVELOPE") {
165                Ok(f) => f,
166                // Be defensive: skip a message that can't be fetched rather than aborting all.
167                Err(_) => continue,
168            };
169
170            for fetch in fetches.iter() {
171                let envelope = match fetch.envelope() {
172                    Some(e) => e,
173                    None => continue,
174                };
175
176                // Subject — envelope fields are raw bytes; decode lossily.
177                let subject = envelope
178                    .subject
179                    .as_ref()
180                    .map(|s| String::from_utf8_lossy(s).into_owned())
181                    .unwrap_or_default();
182
183                // Reconstruct `local@host` from raw IMAP mailbox/host byte fields.
184                let parts_to_string =
185                    |mailbox_bytes: Option<&[u8]>, host_bytes: Option<&[u8]>| -> String {
186                        let mailbox_part = mailbox_bytes
187                            .map(|m| String::from_utf8_lossy(m).into_owned())
188                            .unwrap_or_default();
189                        let host_part = host_bytes
190                            .map(|h| String::from_utf8_lossy(h).into_owned())
191                            .unwrap_or_default();
192                        if host_part.is_empty() {
193                            mailbox_part
194                        } else {
195                            format!("{mailbox_part}@{host_part}")
196                        }
197                    };
198
199                // From — first sender address, if present.
200                let from = envelope
201                    .from
202                    .as_ref()
203                    .and_then(|addrs| addrs.first())
204                    .map(|addr| {
205                        parts_to_string(
206                            addr.mailbox.as_ref().map(|m| m.as_ref()),
207                            addr.host.as_ref().map(|h| h.as_ref()),
208                        )
209                    })
210                    .unwrap_or_default();
211
212                // To — first envelope recipient when it looks like an address; else IMAP folder
213                // name (often "INBOX"). mail_fetch falls back to IMAP username for non-@ targets.
214                let to = envelope
215                    .to
216                    .as_ref()
217                    .and_then(|addrs| addrs.first())
218                    .map(|addr| {
219                        parts_to_string(
220                            addr.mailbox.as_ref().map(|m| m.as_ref()),
221                            addr.host.as_ref().map(|h| h.as_ref()),
222                        )
223                    })
224                    .filter(|s| s.contains('@'))
225                    .unwrap_or_else(|| mailbox.to_string());
226
227                // Size — `RFC822.SIZE` populates `fetch.size`; default to 0 when absent.
228                let size = fetch.size.unwrap_or(0) as usize;
229
230                out.push(build_inbound(
231                    &from, &to, &subject, size,
232                    false, // sender_verified — IMAP does not attest identity
233                    None,  // sender_did — verification is a higher-layer concern
234                ));
235            }
236        }
237
238        Ok(out)
239    })();
240
241    // Best-effort logout; the fetch result takes precedence.
242    let _ = session.logout();
243
244    result
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn build_inbound_populates_fields() {
253        let msg = build_inbound(
254            "alice@example.org",
255            "me@example.org",
256            "Hello there",
257            2048,
258            true,
259            Some("did:example:alice".to_string()),
260        );
261
262        assert_eq!(msg.from_address, "alice@example.org");
263        assert_eq!(msg.to_address, "me@example.org");
264        assert_eq!(msg.subject, "Hello there");
265        assert_eq!(msg.size_bytes, 2048);
266        assert!(msg.sender_verified);
267        assert_eq!(msg.sender_did.as_deref(), Some("did:example:alice"));
268    }
269
270    #[test]
271    fn build_inbound_defaults_unverified_no_did() {
272        let msg = build_inbound("spam@nowhere.test", "junk@example.org", "", 0, false, None);
273
274        assert_eq!(msg.from_address, "spam@nowhere.test");
275        assert_eq!(msg.to_address, "junk@example.org");
276        assert_eq!(msg.subject, "");
277        assert_eq!(msg.size_bytes, 0);
278        assert!(!msg.sender_verified);
279        assert!(msg.sender_did.is_none());
280    }
281}