/* * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use super::harness::*; fn config(port: u16, mapping_path: &str) -> String { format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{mapping_path}" [capabilities.pop3] allow_plain_auth_without_tls = true [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" proxy_protocol = false allow_plaintext_auth = true [destination.legacy.protocol.pop3] port = {port} tls = "plain" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" "# ) } async fn backend_expect_auth(mut sock: tokio::net::TcpStream, want_user: &'static str) { sock.write_all(b"+OK backend ready\r\n").await.unwrap(); let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; loop { let n = sock.read(&mut scratch).await.unwrap(); if n == 0 { return; } buf.extend_from_slice(&scratch[..n]); if find(&buf, b"\r\n").is_some() { break; } } let line_end = find(&buf, b"\r\n").unwrap(); let line = String::from_utf8_lossy(&buf[..line_end]).into_owned(); assert!(line.starts_with("AUTH PLAIN "), "got: {line}"); let b64 = line.split_whitespace().nth(2).unwrap(); let raw = BASE64.decode(b64).unwrap(); assert!( find(&raw, want_user.as_bytes()).is_some(), "decoded IR {:?} should contain {want_user}", String::from_utf8_lossy(&raw) ); sock.write_all(b"+OK logged in\r\n").await.unwrap(); let mut rest = buf[line_end + 2..].to_vec(); loop { if find(&rest, b"STAT").is_some() { break; } let n = sock.read(&mut scratch).await.unwrap(); if n == 0 { return; } rest.extend_from_slice(&scratch[..n]); } sock.write_all(b"+OK 0 0\r\n").await.unwrap(); } #[tokio::test] async fn pop3_user_pass_routes_replays_and_bridges() { let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; let mapping = mapping_file(""); let toml = config(port, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "127.0.0.1:40000".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let greeting = read_line(&mut client).await; assert!(greeting.starts_with("+OK"), "greeting: {greeting}"); send( &mut client, b"USER user@example.com\r\nPASS secret\r\nSTAT\r\n", ) .await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"+OK logged in").is_some(), "expected +OK logged in, got {:?}", String::from_utf8_lossy(&resp) ); assert!( find(&resp, b"+OK 0 0").is_some(), "expected bridged STAT reply, got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; handler.abort(); } #[tokio::test] async fn pop3_auth_plain_with_ir() { let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await; let mapping = mapping_file(""); let toml = config(port, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "127.0.0.1:40001".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let _greeting = read_line(&mut client).await; let ir = BASE64.encode(b"\0alice@example.com\0secret"); send( &mut client, format!("AUTH PLAIN {ir}\r\nSTAT\r\n").as_bytes(), ) .await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"+OK logged in").is_some(), "expected +OK logged in, got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; handler.abort(); } fn count(haystack: &[u8], needle: &[u8]) -> usize { if needle.is_empty() { return 0; } haystack .windows(needle.len()) .filter(|w| *w == needle) .count() } async fn spawn_pop3(ctx: &std::sync::Arc) -> tokio::net::TcpStream { let listener = ctx.config.listener["pop3"].clone(); let (client, server) = tcp_pair().await; let ctx2 = ctx.clone(); tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "127.0.0.1:40010".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); client } #[tokio::test] async fn pop3_bad_inline_ir_does_not_loop() { let mapping = mapping_file(""); let toml = config(1, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; let ir = BASE64.encode(b"nonulhere"); send( &mut client, format!("AUTH PLAIN {ir}\r\nQUIT\r\n").as_bytes(), ) .await; let resp = read_to_eof(&mut client).await; assert_eq!( count(&resp, b"-ERR"), 1, "one bad AUTH must produce exactly one -ERR, got {:?}", String::from_utf8_lossy(&resp) ); assert!( find(&resp, b"+OK Bye").is_some(), "the pipelined QUIT after a bad AUTH must still be processed, got {:?}", String::from_utf8_lossy(&resp) ); } #[tokio::test] async fn pop3_bare_pass_is_rejected() { let mapping = mapping_file(""); let toml = config(1, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"PASS secret\r\n").await; let resp = read_until(&mut client, b"\r\n").await; assert!( find(&resp, b"-ERR").is_some(), "bare PASS must be rejected, got {:?}", String::from_utf8_lossy(&resp) ); } #[tokio::test] async fn pop3_auth_continuation_when_ir_omitted() { let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "carol@example.com")).await; let mapping = mapping_file(""); let toml = config(port, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"AUTH PLAIN\r\n").await; let cont = read_until(&mut client, b"\r\n").await; assert!( cont.starts_with(b"+ "), "expected continuation prompt, got {:?}", String::from_utf8_lossy(&cont) ); let ir = BASE64.encode(b"\0carol@example.com\0secret"); send(&mut client, format!("{ir}\r\nSTAT\r\n").as_bytes()).await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"+OK logged in").is_some(), "continuation auth should succeed, got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; } #[tokio::test] async fn pop3_aborts_when_backend_does_not_advertise_stls() { async fn backend_no_stls(mut sock: tokio::net::TcpStream) { sock.write_all(b"+OK backend ready\r\n").await.unwrap(); let mut scratch = [0u8; 1024]; let _ = sock.read(&mut scratch).await; sock.write_all(b"+OK capa\r\nTOP\r\nUIDL\r\n.\r\n") .await .unwrap(); let _ = sock.read(&mut scratch).await; } let (port, backend) = dummy_backend(backend_no_stls).await; let mapping = mapping_file(""); let toml = format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{}" [capabilities.pop3] allow_plain_auth_without_tls = true [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" tls_server_name = "backend.example.com" proxy_protocol = false [destination.legacy.protocol.pop3] port = {port} tls = "starttls" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" "#, mapping.path().display() ); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"USER user@example.com\r\nPASS secret\r\n").await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"-ERR backend unavailable").is_some(), "credentials must not egress to a backend that won't do STLS; got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; } #[tokio::test] async fn pop3_user_rejected_without_tls_when_plain_disabled() { let mapping = mapping_file(""); let toml = format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{}" [capabilities.pop3] allow_plain_auth_without_tls = false [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" tls_server_name = "backend.example.com" [destination.legacy.protocol.pop3] port = 1 tls = "implicit" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" "#, mapping.path().display() ); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "127.0.0.1:40002".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let _greeting = read_line(&mut client).await; send(&mut client, b"CAPA\r\n").await; let capa = read_until(&mut client, b".\r\n").await; let capa_str = String::from_utf8_lossy(&capa); assert!( !capa_str.contains("USER\r\n"), "CAPA should omit USER, got {capa_str}" ); assert!( !capa_str.to_uppercase().contains("PLAIN"), "CAPA should omit PLAIN, got {capa_str}" ); send(&mut client, b"USER user@example.com\r\n").await; let resp = read_until(&mut client, b"-ERR").await; assert!( find(&resp, b"-ERR").is_some(), "expected -ERR, got {:?}", String::from_utf8_lossy(&resp) ); handler.abort(); } #[tokio::test] async fn pop3_auth_login_replays_as_plain() { let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; let mapping = mapping_file(""); let toml = config(port, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"CAPA\r\n").await; let capa = read_until(&mut client, b".\r\n").await; let capa = String::from_utf8_lossy(&capa).into_owned(); assert!( capa.contains("LOGIN"), "CAPA must advertise SASL LOGIN, got {capa}" ); send(&mut client, b"AUTH LOGIN\r\n").await; let user_prompt = read_until(&mut client, b"\r\n").await; assert_eq!( String::from_utf8_lossy(&user_prompt).trim_end(), "+ VXNlcm5hbWU6" ); send( &mut client, format!("{}\r\n", BASE64.encode("user@example.com")).as_bytes(), ) .await; let pass_prompt = read_until(&mut client, b"\r\n").await; assert_eq!( String::from_utf8_lossy(&pass_prompt).trim_end(), "+ UGFzc3dvcmQ6" ); send( &mut client, format!("{}\r\nSTAT\r\n", BASE64.encode("secret")).as_bytes(), ) .await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"+OK logged in").is_some(), "expected +OK logged in, got {:?}", String::from_utf8_lossy(&resp) ); assert!( find(&resp, b"+OK 0 0").is_some(), "expected bridged STAT reply, got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; } #[tokio::test] async fn pop3_auth_login_accepts_username_as_initial_response() { let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await; let mapping = mapping_file(""); let toml = config(port, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send( &mut client, format!("AUTH LOGIN {}\r\n", BASE64.encode("alice@example.com")).as_bytes(), ) .await; let pass_prompt = read_until(&mut client, b"\r\n").await; assert_eq!( String::from_utf8_lossy(&pass_prompt).trim_end(), "+ UGFzc3dvcmQ6" ); send( &mut client, format!("{}\r\nSTAT\r\n", BASE64.encode("secret")).as_bytes(), ) .await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"+OK logged in").is_some(), "expected +OK logged in, got {:?}", String::from_utf8_lossy(&resp) ); let _ = backend.await; } #[tokio::test] async fn pop3_auth_login_cancelled_by_client() { let mapping = mapping_file(""); let toml = config(1, &mapping.path().display().to_string()); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"AUTH LOGIN\r\n").await; let _user_prompt = read_until(&mut client, b"\r\n").await; send(&mut client, b"*\r\nQUIT\r\n").await; let resp = read_to_eof(&mut client).await; assert!( find(&resp, b"-ERR authentication cancelled").is_some(), "expected a cancel error, got {:?}", String::from_utf8_lossy(&resp) ); assert!( find(&resp, b"+OK Bye").is_some(), "session must survive a cancelled AUTH, got {:?}", String::from_utf8_lossy(&resp) ); } #[tokio::test] async fn pop3_auth_login_refused_without_tls_when_plain_disabled() { let mapping = mapping_file(""); let toml = format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{}" [capabilities.pop3] allow_plain_auth_without_tls = false [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" tls_server_name = "backend.example.com" [destination.legacy.protocol.pop3] port = 1 tls = "implicit" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" "#, mapping.path().display() ); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"CAPA\r\n").await; let capa = read_until(&mut client, b".\r\n").await; let capa = String::from_utf8_lossy(&capa).into_owned(); assert!( !capa.to_uppercase().contains("LOGIN"), "CAPA must omit LOGIN without TLS, got {capa}" ); send(&mut client, b"AUTH LOGIN\r\n").await; let resp = read_until(&mut client, b"-ERR").await; assert!( find(&resp, b"cleartext authentication requires TLS").is_some(), "expected a cleartext refusal, got {:?}", String::from_utf8_lossy(&resp) ); } #[tokio::test] async fn pop3_auth_login_rejected_when_not_configured() { let mapping = mapping_file(""); let toml = format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{}" [capabilities.pop3] allow_plain_auth_without_tls = true sasl = ["PLAIN", "OAUTHBEARER"] [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" proxy_protocol = false allow_plaintext_auth = true [destination.legacy.protocol.pop3] port = 1 tls = "plain" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" "#, mapping.path().display() ); let ctx = make_ctx(&toml).await; let mut client = spawn_pop3(&ctx).await; let _greeting = read_line(&mut client).await; send(&mut client, b"AUTH LOGIN\r\n").await; let resp = read_until(&mut client, b"-ERR").await; assert!( find(&resp, b"unsupported mechanism").is_some(), "expected an unsupported-mechanism error, got {:?}", String::from_utf8_lossy(&resp) ); } fn retry_config(backend_port: u16, mapping_path: &str, max_auth_attempts: u32) -> String { format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{mapping_path}" [capabilities.pop3] allow_plain_auth_without_tls = true [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" forwarding = "none" allow_plaintext_auth = true [destination.legacy.protocol.pop3] port = {backend_port} tls = "plain" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" max_auth_attempts = {max_auth_attempts} "# ) } async fn rejecting_backend( attempts: std::sync::Arc, ) -> (u16, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind(unused_addr()).await.unwrap(); let port = listener.local_addr().unwrap().port(); let handle = tokio::spawn(async move { while let Ok((mut sock, _)) = listener.accept().await { attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let _ = sock.set_nodelay(true); tokio::spawn(async move { if sock.write_all(b"+OK ready\r\n").await.is_err() { return; } let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; while find(&buf, b"\r\n").is_none() { match sock.read(&mut scratch).await { Ok(0) | Err(_) => return, Ok(n) => buf.extend_from_slice(&scratch[..n]), } } let _ = sock.write_all(b"-ERR bad password\r\n").await; let mut sink = [0u8; 1024]; loop { match sock.read(&mut sink).await { Ok(0) | Err(_) => break, Ok(_) => {} } } }); } }); (port, handle) } #[tokio::test] async fn pop3_failed_backend_auth_does_not_hand_the_client_a_raw_bridge() { let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (backend_port, backend) = rejecting_backend(attempts.clone()).await; let mapping = mapping_file("user@example.com\tlegacy\n"); let toml = retry_config(backend_port, &mapping.path().display().to_string(), 10); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "203.0.113.9:40000".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let greeting = read_line(&mut client).await; assert!(greeting.starts_with("+OK"), "greeting: {greeting}"); send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await; let failure = read_until(&mut client, b"-ERR").await; assert!(find(&failure, b"-ERR").is_some(), "expected a rejection"); send(&mut client, b"CAPA\r\n").await; let capa = read_until(&mut client, b"\r\n.\r\n").await; assert!( find(&capa, b"USER").is_some(), "the proxy must still be parsing after a failed auth, got {:?}", String::from_utf8_lossy(&capa) ); assert_eq!( attempts.load(std::sync::atomic::Ordering::SeqCst), 1, "CAPA must be answered by the proxy, not forwarded to a backend" ); drop(client); backend.abort(); let _ = handler.await; } #[tokio::test] async fn pop3_rejected_auth_closes_the_backend_leg_with_quit() { let (backend_port, backend) = dummy_backend(|mut sock| async move { sock.write_all(b"+OK ready\r\n").await.unwrap(); let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; while find(&buf, b"\r\n").is_none() { match sock.read(&mut scratch).await { Ok(0) | Err(_) => return, Ok(n) => buf.extend_from_slice(&scratch[..n]), } } let _ = sock.write_all(b"-ERR bad password\r\n").await; buf.clear(); loop { match sock.read(&mut scratch).await { Ok(0) | Err(_) => break, Ok(n) => buf.extend_from_slice(&scratch[..n]), } } assert!( find(&buf, b"QUIT").is_some(), "backend leg must be closed with QUIT, saw {:?}", String::from_utf8_lossy(&buf) ); }) .await; let mapping = mapping_file("user@example.com\tlegacy\n"); let toml = retry_config(backend_port, &mapping.path().display().to_string(), 10); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "203.0.113.9:40003".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let _ = read_line(&mut client).await; send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await; let _ = read_until(&mut client, b"-ERR").await; drop(client); backend.await.expect("backend assertion failed"); let _ = handler.await; } #[tokio::test] async fn pop3_backend_auth_failures_count_toward_max_auth_attempts() { let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (backend_port, backend) = rejecting_backend(attempts.clone()).await; let mapping = mapping_file("user@example.com\tlegacy\n"); let toml = retry_config(backend_port, &mapping.path().display().to_string(), 2); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "203.0.113.9:40001".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let _ = read_line(&mut client).await; send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await; let _ = read_until(&mut client, b"-ERR").await; send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await; let tail = read_to_eof(&mut client).await; assert!( find(&tail, b"-ERR").is_some(), "expected the second rejection, got {:?}", String::from_utf8_lossy(&tail) ); assert_eq!( attempts.load(std::sync::atomic::Ordering::SeqCst), 2, "max_auth_attempts must bound the backend connections a client can force" ); backend.abort(); let _ = handler.await; } #[tokio::test] async fn pop3_retry_after_failed_auth_is_routed_and_registered_for_its_own_account() { async fn accepting_backend( seen: std::sync::Arc>>, ) -> (u16, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind(unused_addr()).await.unwrap(); let port = listener.local_addr().unwrap().port(); let handle = tokio::spawn(async move { while let Ok((mut sock, _)) = listener.accept().await { let seen = seen.clone(); let _ = sock.set_nodelay(true); tokio::spawn(async move { if sock.write_all(b"+OK ready\r\n").await.is_err() { return; } let mut buf = Vec::new(); let mut scratch = [0u8; 1024]; while find(&buf, b"\r\n").is_none() { match sock.read(&mut scratch).await { Ok(0) | Err(_) => return, Ok(n) => buf.extend_from_slice(&scratch[..n]), } } seen.lock().await.extend_from_slice(&buf); let _ = sock.write_all(b"+OK logged in\r\n").await; let mut sink = [0u8; 1024]; loop { match sock.read(&mut sink).await { Ok(0) | Err(_) => break, Ok(_) => {} } } }); } }); (port, handle) } let rejects = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (legacy_port, legacy) = rejecting_backend(rejects.clone()).await; let seen = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new())); let (modern_port, modern) = accepting_backend(seen.clone()).await; let mapping = mapping_file("alice@example.com\tlegacy\nbob@example.com\tmodern\n"); let toml = format!( r#" [mapping] source = "file" normalize = "lowercase" [mapping.file] path = "{}" [capabilities.pop3] allow_plain_auth_without_tls = true [routing] default_destination = "legacy" [destination.legacy] host = "127.0.0.1" forwarding = "none" allow_plaintext_auth = true [destination.legacy.protocol.pop3] port = {legacy_port} tls = "plain" [destination.modern] host = "127.0.0.1" forwarding = "none" allow_plaintext_auth = true [destination.modern.protocol.pop3] port = {modern_port} tls = "plain" [listener.pop3] protocol = "pop3" bind = ["127.0.0.1:0"] tls = "plain" max_auth_attempts = 10 "#, mapping.path().display() ); let ctx = make_ctx(&toml).await; let listener = ctx.config.listener["pop3"].clone(); let (mut client, server) = tcp_pair().await; let ctx2 = ctx.clone(); let handler = tokio::spawn(async move { let _ = crate::proto::pop3::handle( &ctx2, &listener, Box::new(server), "203.0.113.9:40002".parse().unwrap(), "127.0.0.1:110".parse().unwrap(), ) .await; }); let _ = read_line(&mut client).await; send(&mut client, b"USER alice@example.com\r\nPASS wrong\r\n").await; let _ = read_until(&mut client, b"-ERR").await; send(&mut client, b"USER bob@example.com\r\nPASS right\r\n").await; let ok = read_until(&mut client, b"+OK logged in").await; assert!( find(&ok, b"+OK logged in").is_some(), "second account should authenticate, got {:?}", String::from_utf8_lossy(&ok) ); let replayed = seen.lock().await.clone(); let expected = BASE64.encode("\0bob@example.com\0right"); assert!( find(&replayed, expected.as_bytes()).is_some(), "the retry must be re-routed to bob's destination, backend saw {:?}", String::from_utf8_lossy(&replayed) ); assert_eq!( rejects.load(std::sync::atomic::Ordering::SeqCst), 1, "alice's destination must see only the first attempt" ); assert_eq!( ctx.conns.kick(&ctx.router.normalize("alice@example.com")), 0, "the failed account must not hold a registration" ); assert_eq!( ctx.conns.kick(&ctx.router.normalize("bob@example.com")), 1, "the account that actually authenticated must be kickable" ); let n = tokio::time::timeout( std::time::Duration::from_secs(5), client.read(&mut [0u8; 64]), ) .await .expect("kick did not disconnect the client in time") .unwrap(); assert_eq!(n, 0, "expected EOF after kick, got {n} bytes"); legacy.abort(); modern.abort(); let _ = handler.await; }