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 | |
| 17 | use oxedyne_fe2o3_core::{ |
| 18 | prelude::*, |
| 19 | test::test_it, |
| 20 | }; |
| 21 | use oxedyne_fe2o3_net::{ |
| 22 | imap::client::Security, |
| 23 | smtp::client::{ |
| 24 | OutboundClient, |
| 25 | SubmissionConfig, |
| 26 | }, |
| 27 | }; |
| 28 | |
| 29 | use std::{ |
| 30 | net::SocketAddr, |
| 31 | sync::{ |
| 32 | Arc, |
| 33 | Mutex, |
| 34 | }, |
| 35 | time::Duration, |
| 36 | }; |
| 37 | |
| 38 | use tokio::{ |
| 39 | io::{ |
| 40 | AsyncBufReadExt, |
| 41 | AsyncWriteExt, |
| 42 | BufReader, |
| 43 | }, |
| 44 | net::TcpListener, |
| 45 | }; |
| 46 | |
| 47 | |
| 48 | // What the stand-in provider will accept. |
| 49 | const USER: &str = "alice@example.com"; |
| 50 | const 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"`. |
| 55 | async 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. |
| 143 | fn 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. |
| 156 | fn runtime() -> Outcome<tokio::runtime::Runtime> { |
| 157 | tokio::runtime::Runtime::new() |
| 158 | .map_err(|e| err!(e, "Building a runtime."; IO, Init)) |
| 159 | } |
| 160 | |
| 161 | fn 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 | |
| 167 | pub 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 | } |