Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_mail/tests/imap_roundtrip.rs

8.7 KiB, 3 runs

created by r1870400018:13338, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The IMAP client and the IMAP server, talking to each other.
2//!
3//! Neither side is mocked: a real [`MaildirStore`] on disk, the real
4//! [`ImapServer`] over a real loopback socket, and the real
5//! [`ImapClient`] reading it back. What is being tested is the wire --
6//! literals, flags, UID sets, sequence numbers -- because that is where an
7//! IMAP implementation is either right or subtly, silently wrong.
8//!
9//! The messages deliberately include one whose body contains a bare CRLF
10//! and a `)`, which is exactly what breaks a client that parses FETCH
11//! responses line by line.
12
13use oxedyne_fe2o3_core::prelude::*;
14use oxedyne_fe2o3_mail::maildir::MaildirStore;
15use oxedyne_fe2o3_net::{
16 imap::{
17 client::{
18 FetchWhat,
19 FlagOp,
20 ImapClient,
21 ImapConfig,
22 Security,
23 },
24 server::ImapServer,
25 },
26 mail::{
27 store::{
28 FolderName,
29 MailStore,
30 MailUser,
31 MessageFlags,
32 },
33 user::UserStore,
34 },
35};
36
37use std::{
38 fs,
39 path::PathBuf,
40 time::Duration,
41};
42
43use tokio::net::TcpListener;
44
45
46const USER: &str = "alice@test.local";
47const PASS: &str = "correct-horse";
48
49/// A one-user store, so the test exercises the protocol rather than a
50/// password file.
51#[derive(Clone, Debug)]
52struct OneUser {
53 /// Where this user's Maildir lives.
54 delivery_key: String,
55}
56
57impl UserStore for OneUser {
58 fn authenticate(&self, address: &str, password: &str) -> Outcome<Option<MailUser>> {
59 if address.eq_ignore_ascii_case(USER) && password == PASS {
60 return Ok(self.lookup(address).ok().flatten());
61 }
62 Ok(None)
63 }
64
65 fn lookup(&self, address: &str) -> Outcome<Option<MailUser>> {
66 if !address.eq_ignore_ascii_case(USER) {
67 return Ok(None);
68 }
69 Ok(Some(MailUser {
70 local: fmt!("alice"),
71 domain: fmt!("test.local"),
72 delivery_key: self.delivery_key.clone(),
73 send_as: Vec::new(),
74 }))
75 }
76}
77
78/// A message whose body would break a line-oriented parser.
79fn awkward_message() -> Vec<u8> {
80 let mut m = Vec::new();
81 m.extend_from_slice(b"From: bob@example.org\r\n");
82 m.extend_from_slice(b"To: alice@test.local\r\n");
83 m.extend_from_slice(b"Subject: literals\r\n");
84 m.extend_from_slice(b"\r\n");
85 m.extend_from_slice(b"A body with a blank line,\r\n");
86 m.extend_from_slice(b"\r\n");
87 m.extend_from_slice(b") a stray close paren, and {17} a fake literal.\r\n");
88 m
89}
90
91fn plain_message(n: usize) -> Vec<u8> {
92 fmt!("From: bob@example.org\r\n\
93 To: alice@test.local\r\n\
94 Subject: message {}\r\n\
95 \r\n\
96 Body of message {}.\r\n", n, n).into_bytes()
97}
98
99#[tokio::test]
100async fn imap_client_reads_what_the_imap_server_serves() -> Outcome<()> {
101
102 // ── A Maildir with three messages in it ──────────────────────
103 let root = std::env::temp_dir().join(fmt!("fe2o3_imap_rt_{}", std::process::id()));
104 let _ = fs::remove_dir_all(&root);
105 res!(fs::create_dir_all(&root), IO, File);
106
107 let store = res!(MaildirStore::new(PathBuf::from(&root), "test.local"));
108 let users = OneUser { delivery_key: fmt!("test.local/alice") };
109 let user = match res!(users.lookup(USER)) {
110 Some(u) => u,
111 None => return Err(err!("The test user did not resolve."; Test, Bug)),
112 };
113 res!(store.ensure_user(&user));
114
115 let inbox = FolderName::new("INBOX");
116 let bodies = vec![plain_message(1), awkward_message(), plain_message(3)];
117 for body in &bodies {
118 res!(store.append(&user, &inbox, body, MessageFlags::default(), None));
119 }
120
121 // ── The server, on a loopback socket ─────────────────────────
122 let listener = res!(TcpListener::bind("127.0.0.1:0").await, IO, Network);
123 let addr = res!(listener.local_addr(), IO, Network);
124 let server = ImapServer {
125 store: store.clone(),
126 users: users.clone(),
127 hostname: std::sync::Arc::new(fmt!("test.local")),
128 };
129 tokio::spawn(async move {
130 while let Ok((sock, peer)) = listener.accept().await {
131 let srv = server.clone();
132 tokio::spawn(async move {
133 let _ = srv.run(sock, peer).await;
134 });
135 }
136 });
137
138 // ── The client ───────────────────────────────────────────────
139 let cfg = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::Plain)
140 .with_timeout(Duration::from_secs(10));
141 let mut c = res!(ImapClient::connect(&cfg).await);
142 assert!(c.has_cap("IMAP4REV1"), "the server did not advertise IMAP4rev1");
143
144 res!(c.login(USER, PASS).await);
145
146 let folders = res!(c.list("", "*").await);
147 assert!(folders.iter().any(|f| f.name.eq_ignore_ascii_case("INBOX")),
148 "INBOX was not in the LIST reply: {:?}", folders);
149
150 let st = res!(c.select("INBOX").await);
151 assert_eq!(st.exists, 3, "wrong message count");
152 assert!(st.uid_validity > 0, "the server gave no UIDVALIDITY");
153 assert!(st.uid_next > 3, "UIDNEXT should be past the messages we appended");
154
155 let uids = res!(c.uid_search("ALL").await);
156 assert_eq!(uids.len(), 3, "UID SEARCH ALL found {} of 3", uids.len());
157
158 // ── The whole point: the bytes come back byte-identical ──────
159 let msgs = res!(c.uid_fetch(&uids, FetchWhat::Full).await);
160 assert_eq!(msgs.len(), 3, "UID FETCH returned {} of 3", msgs.len());
161 for (i, msg) in msgs.iter().enumerate() {
162 assert_eq!(msg.body, bodies[i],
163 "message {} came back changed:\n--- got ---\n{}\n--- want ---\n{}",
164 i,
165 String::from_utf8_lossy(&msg.body),
166 String::from_utf8_lossy(&bodies[i]));
167 assert_eq!(msg.size as usize, bodies[i].len(), "RFC822.SIZE disagrees");
168 assert!(msg.uid > 0, "message {} came back with no UID", i);
169 }
170
171 // Headers-only: the body stops at the blank line, and the size still
172 // describes the whole message.
173 let heads = res!(c.uid_fetch(&uids[..1], FetchWhat::Headers).await);
174 assert_eq!(heads.len(), 1);
175 let head = String::from_utf8_lossy(&heads[0].body).into_owned();
176 assert!(head.contains("Subject: message 1"), "header block missing subject: {:?}", head);
177 assert!(!head.contains("Body of message 1"), "headers-only fetch returned the body");
178 assert_eq!(heads[0].size as usize, bodies[0].len());
179
180 // ── Flags round-trip ─────────────────────────────────────────
181 res!(c.uid_store_flags(&uids[..1], FlagOp::Add, &["\\Seen"]).await);
182 let after = res!(c.uid_fetch(&uids[..1], FetchWhat::Meta).await);
183 assert!(after[0].flags.iter().any(|f| f.eq_ignore_ascii_case("\\Seen")),
184 "\\Seen did not stick: {:?}", after[0].flags);
185
186 // ── Append, and see it arrive ────────────────────────────────
187 let sent = plain_message(4);
188 res!(c.append("INBOX", &["\\Seen"], &sent).await);
189 let st = res!(c.select("INBOX").await);
190 assert_eq!(st.exists, 4, "the appended message did not land");
191
192 res!(c.logout().await);
193 let _ = fs::remove_dir_all(&root);
194 Ok(())
195}
196
197/// A wrong password must be a clean, named failure -- not a hang, and not
198/// a success.
199#[tokio::test]
200async fn imap_client_reports_a_bad_password() -> Outcome<()> {
201 let root = std::env::temp_dir().join(fmt!("fe2o3_imap_auth_{}", std::process::id()));
202 let _ = fs::remove_dir_all(&root);
203 res!(fs::create_dir_all(&root), IO, File);
204
205 let store = res!(MaildirStore::new(PathBuf::from(&root), "test.local"));
206 let users = OneUser { delivery_key: fmt!("test.local/alice") };
207 if let Some(u) = res!(users.lookup(USER)) {
208 res!(store.ensure_user(&u));
209 }
210
211 let listener = res!(TcpListener::bind("127.0.0.1:0").await, IO, Network);
212 let addr = res!(listener.local_addr(), IO, Network);
213 let server = ImapServer {
214 store,
215 users,
216 hostname: std::sync::Arc::new(fmt!("test.local")),
217 };
218 tokio::spawn(async move {
219 while let Ok((sock, peer)) = listener.accept().await {
220 let srv = server.clone();
221 tokio::spawn(async move { let _ = srv.run(sock, peer).await; });
222 }
223 });
224
225 let cfg = ImapConfig::new(fmt!("127.0.0.1"), addr.port(), Security::Plain)
226 .with_timeout(Duration::from_secs(10));
227 let mut c = res!(ImapClient::connect(&cfg).await);
228 let outcome = c.login(USER, "wrong").await;
229 assert!(outcome.is_err(), "a wrong password authenticated");
230
231 let _ = fs::remove_dir_all(&root);
232 Ok(())
233}