Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_net/tests/smtp_submit.rs

11.0 KiB, 12 runs

created by r1870400018:13705, 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#![cfg(feature = "async")]
2//! Submitting a message through a provider, which means proving the account is ours first.
3//!
4//! Delivery to a recipient's MX needs no credential: the receiving server takes the message because
5//! it is responsible for the recipient. Submission through the *sender's own* provider is the
6//! opposite -- the provider carries nothing until the sender authenticates -- and it is the
7//! conversation every mail client actually has. The client could not have it at all until `submit`
8//! existed, so this drives it end to end against a server that demands a login.
9//!
10//! The server here is a stand-in, spoken to over loopback in the clear, because what is under test
11//! is the client's half of the exchange: does it read the mechanism list, choose one it can speak,
12//! encode the credential correctly, and refuse to go on when the login is rejected.
13//!
14//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
15//! Anthropic Claude
16
17use oxedyne_fe2o3_core::{
18 prelude::*,
19 test::test_it,
20};
21use oxedyne_fe2o3_net::{
22 imap::client::Security,
23 smtp::client::{
24 OutboundClient,
25 SubmissionConfig,
26 },
27};
28
29use std::{
30 net::SocketAddr,
31 sync::{
32 Arc,
33 Mutex,
34 },
35 time::Duration,
36};
37
38use tokio::{
39 io::{
40 AsyncBufReadExt,
41 AsyncWriteExt,
42 BufReader,
43 },
44 net::TcpListener,
45};
46
47
48// What the stand-in provider will accept.
49const USER: &str = "alice@example.com";
50const PASS: &str = "app-password-not-the-real-one";
51
52
53/// A provider that demands a login, and remembers the conversation so the test can read it back.
54/// `mechanisms` is advertised after `AUTH` in the `EHLO` reply, e.g. `"PLAIN LOGIN"`.
55async fn fake_provider(
56 mechanisms: &'static str,
57 accept: bool,
58)
59 -> Outcome<(SocketAddr, Arc<Mutex<Vec<String>>>)>
60{
61 let listener = res!(TcpListener::bind("127.0.0.1:0").await
62 .map_err(|e| err!(e, "Binding the stand-in provider."; IO, Network)));
63 let addr = res!(listener.local_addr()
64 .map_err(|e| err!(e, "Reading the stand-in provider's address."; IO, Network)));
65
66 let seen: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
67 let log = seen.clone();
68
69 tokio::spawn(async move {
70 let (sock, _) = match listener.accept().await {
71 Ok(x) => x,
72 Err(_) => return,
73 };
74 let (r, mut w) = sock.into_split();
75 let mut lines = BufReader::new(r).lines();
76
77 let _ = w.write_all(b"220 provider.example.com ESMTP\r\n").await;
78
79 let mut in_data = false;
80 let mut await_user = false;
81 let mut await_pass = false;
82
83 while let Ok(Some(line)) = lines.next_line().await {
84 if let Ok(mut g) = log.lock() {
85 g.push(line.clone());
86 }
87
88 if in_data {
89 if line == "." {
90 in_data = false;
91 let _ = w.write_all(b"250 2.0.0 Ok: queued as STANDIN1\r\n").await;
92 }
93 continue;
94 }
95 if await_user {
96 await_user = false;
97 await_pass = true;
98 let _ = w.write_all(b"334 UGFzc3dvcmQ6\r\n").await; // "Password:"
99 continue;
100 }
101 if await_pass {
102 await_pass = false;
103 let _ = w.write_all(if accept {
104 &b"235 2.7.0 Accepted\r\n"[..]
105 } else {
106 &b"535 5.7.8 Username and Password not accepted\r\n"[..]
107 }).await;
108 continue;
109 }
110
111 let upper = line.to_uppercase();
112 if upper.starts_with("EHLO") {
113 let _ = w.write_all(
114 fmt!("250-provider.example.com\r\n250-SIZE 35882577\r\n250-AUTH {}\r\n250 8BITMIME\r\n",
115 mechanisms).as_bytes()).await;
116 } else if upper.starts_with("AUTH PLAIN") {
117 let _ = w.write_all(if accept {
118 &b"235 2.7.0 Accepted\r\n"[..]
119 } else {
120 &b"535 5.7.8 Username and Password not accepted\r\n"[..]
121 }).await;
122 } else if upper.starts_with("AUTH LOGIN") {
123 await_user = true;
124 let _ = w.write_all(b"334 VXNlcm5hbWU6\r\n").await; // "Username:"
125 } else if upper.starts_with("MAIL FROM") || upper.starts_with("RCPT TO") {
126 let _ = w.write_all(b"250 2.1.0 Ok\r\n").await;
127 } else if upper.starts_with("DATA") {
128 in_data = true;
129 let _ = w.write_all(b"354 End data with <CR><LF>.<CR><LF>\r\n").await;
130 } else if upper.starts_with("QUIT") {
131 let _ = w.write_all(b"221 2.0.0 Bye\r\n").await;
132 return;
133 } else {
134 let _ = w.write_all(b"250 2.0.0 Ok\r\n").await;
135 }
136 }
137 });
138
139 Ok((addr, seen))
140}
141
142/// One body line deliberately begins with a full stop, so dot-stuffing is under test on every case.
143fn body() -> Vec<u8> {
144 let mut s = String::new();
145 s.push_str("From: Alice <alice@example.com>\r\n");
146 s.push_str("To: Bob <bob@example.net>\r\n");
147 s.push_str("Subject: Hello\r\n");
148 s.push_str("\r\n");
149 s.push_str("A line.\r\n");
150 s.push_str(".A line that begins with a full stop, which must survive dot-stuffing.\r\n");
151 s.into_bytes()
152}
153
154/// A runtime per case. `test_it` takes a closure that outlives this function, so the runtime is
155/// built inside each one rather than borrowed from around them.
156fn runtime() -> Outcome<tokio::runtime::Runtime> {
157 tokio::runtime::Runtime::new()
158 .map_err(|e| err!(e, "Building a runtime."; IO, Init))
159}
160
161fn cfg(addr: SocketAddr) -> SubmissionConfig {
162 SubmissionConfig::new("provider.example.com", addr.port(), Security::Plain, USER, PASS)
163 .with_addr(addr)
164 .with_timeout(Duration::from_secs(10))
165}
166
167pub fn test_smtp_submit(filter: &'static str) -> Outcome<()> {
168
169
170 res!(test_it(filter, &["Submit with AUTH PLAIN", "all", "smtp", "submit"], || {
171 res!(runtime()).block_on(async {
172 let (addr, seen) = res!(fake_provider("PLAIN LOGIN", true).await);
173 let client = res!(OutboundClient::with_system_roots("daimond.test"));
174 let qid = res!(client.submit(
175 &cfg(addr),
176 "alice@example.com",
177 &[fmt!("bob@example.net")],
178 &body(),
179 ).await);
180 req!(true, qid.contains("STANDIN1"));
181
182 let lines = match seen.lock() {
183 Ok(g) => g.clone(),
184 Err(_) => return Err(err!("The provider's log was poisoned."; Lock, Poisoned)),
185 };
186 // PLAIN is offered first and must be the one chosen, carrying the credential in the
187 // command itself: an empty authorisation identity, the account, then the password.
188 let auth = match lines.iter().find(|l| l.to_uppercase().starts_with("AUTH PLAIN")) {
189 Some(l) => l.clone(),
190 None => return Err(err!(
191 "The client never sent AUTH PLAIN. It said: {:?}", lines; Test, Missing)),
192 };
193 let b64 = auth["AUTH PLAIN ".len()..].trim().to_string();
194 let raw = res!(base64::decode(&b64));
195 let expect = fmt!("\0{}\0{}", USER, PASS);
196 req!(expect.as_bytes().to_vec(), raw);
197
198 // A line that begins with a full stop must reach the server doubled, or it would have
199 // ended the message early.
200 req!(true, lines.iter().any(|l| l.starts_with("..A line that begins")));
201 Ok(())
202 })
203 }));
204
205 res!(test_it(filter, &["Submit with AUTH LOGIN", "all", "smtp", "submit"], || {
206 res!(runtime()).block_on(async {
207 // A provider that offers only LOGIN. The client must fall back to it rather than give
208 // up because its first choice was absent.
209 let (addr, seen) = res!(fake_provider("LOGIN", true).await);
210 let client = res!(OutboundClient::with_system_roots("daimond.test"));
211 let qid = res!(client.submit(
212 &cfg(addr),
213 "alice@example.com",
214 &[fmt!("bob@example.net")],
215 &body(),
216 ).await);
217 req!(true, qid.contains("STANDIN1"));
218
219 let lines = match seen.lock() {
220 Ok(g) => g.clone(),
221 Err(_) => return Err(err!("The provider's log was poisoned."; Lock, Poisoned)),
222 };
223 req!(true, lines.iter().any(|l| l.to_uppercase().starts_with("AUTH LOGIN")));
224 // The account and the password each go over on their own line, base64 and nothing more.
225 req!(true, lines.iter().any(|l| base64::decode(l.trim())
226 .map(|b| b == USER.as_bytes()).unwrap_or(false)));
227 req!(true, lines.iter().any(|l| base64::decode(l.trim())
228 .map(|b| b == PASS.as_bytes()).unwrap_or(false)));
229 Ok(())
230 })
231 }));
232
233 res!(test_it(filter, &["A refused credential is an error, not a send", "all", "smtp", "submit"], || {
234 res!(runtime()).block_on(async {
235 let (addr, seen) = res!(fake_provider("PLAIN LOGIN", false).await);
236 let client = res!(OutboundClient::with_system_roots("daimond.test"));
237 let result = client.submit(
238 &cfg(addr),
239 "alice@example.com",
240 &[fmt!("bob@example.net")],
241 &body(),
242 ).await;
243 if result.is_ok() {
244 return Err(err!(
245 "The provider refused the credential and the client reported success.";
246 Test, Invalid));
247 }
248 // And it must not have gone on to offer the message anyway.
249 let lines = match seen.lock() {
250 Ok(g) => g.clone(),
251 Err(_) => return Err(err!("The provider's log was poisoned."; Lock, Poisoned)),
252 };
253 if lines.iter().any(|l| l.to_uppercase().starts_with("MAIL FROM")) {
254 return Err(err!(
255 "The client sent MAIL FROM after its login was rejected.";
256 Test, Invalid));
257 }
258 Ok(())
259 })
260 }));
261
262 res!(test_it(filter, &["A server offering no mechanism is refused", "all", "smtp", "submit"], || {
263 res!(runtime()).block_on(async {
264 // Advertise a mechanism this client cannot speak. Submitting anyway would mean sending
265 // the password into a conversation that cannot use it.
266 let (addr, _seen) = res!(fake_provider("XOAUTH2", true).await);
267 let client = res!(OutboundClient::with_system_roots("daimond.test"));
268 let result = client.submit(
269 &cfg(addr),
270 "alice@example.com",
271 &[fmt!("bob@example.net")],
272 &body(),
273 ).await;
274 if result.is_ok() {
275 return Err(err!(
276 "The client claimed to submit through a server whose only mechanism it cannot \
277 speak."; Test, Invalid));
278 }
279 Ok(())
280 })
281 }));
282
283 Ok(())
284}