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}