diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index f14aeb7..441c1d8 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -17,11 +17,16 @@ //! back on the next restart. Opening puts the object back first and then //! spawns, for the same reason in reverse. //! +//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over +//! a legacy protocol is refused before any password is looked at, so a +//! listener that exists by mistake still lets nobody in. +//! //! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! (LP-20). The server stops answering; what still routes the port is the //! operator's to reconcile. use crate::{Server, config::server::Listeners, network::TcpAcceptor}; +use directory::Credentials; use inbuxa_features::security::{ listeners, protocol_policy::{self, ProtocolPolicy, SavedListener}, @@ -209,3 +214,150 @@ impl Server { .collect()) } } + +/// A protocol a mail app signs in over, which the switch refuses (LP-6). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LegacyProtocol { + Imap, + Pop3, + ManageSieve, + /// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so + /// inbound delivery is untouched (LP-3). + Submission, +} + +impl LegacyProtocol { + pub fn as_str(&self) -> &'static str { + match self { + LegacyProtocol::Imap => "imap", + LegacyProtocol::Pop3 => "pop3", + LegacyProtocol::ManageSieve => "manageSieve", + LegacyProtocol::Submission => "submission", + } + } + + /// What the mail app is told, at server scope (LP-12, LP-6). Each + /// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting — + /// is added by its session; POP3 carries `[AUTH]` in the text, since its + /// errors have no separate code, and SMTP is the whole reply line. + pub fn refusal(&self) -> &'static str { + match self { + LegacyProtocol::Imap => { + "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + LegacyProtocol::Pop3 => { + "[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + } + LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.", + LegacyProtocol::Submission => { + "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + } + } + } + + /// The refusal as an error: `auth.legacy-protocol-refused`, not + /// `auth.failed`, so it never counts against the account or feeds the + /// auto-ban (LP-11). It names the protocol and the domain, never the + /// account; the session it is raised in adds the remote IP. + pub fn refused(&self, credentials: &Credentials) -> trc::Error { + trc::AuthEvent::LegacyProtocolRefused + .into_err() + .details(self.refusal()) + .ctx(trc::Key::Source, self.as_str()) + .ctx(trc::Key::Policy, "server") + .ctx_opt(trc::Key::Domain, domain_of(credentials)) + } +} + +/// The domain a sign-in is for, from the name it gives, if it gives one. +fn domain_of(credentials: &Credentials) -> Option { + let username = match credentials { + Credentials::Basic { username, .. } => Some(username.as_str()), + Credentials::Bearer { username, .. } => username.as_deref(), + }?; + username + .rsplit_once('@') + .map(|(_, domain)| domain.trim().to_lowercase()) + .filter(|domain| !domain.is_empty()) +} + +impl Server { + /// Refuses a sign-in over a legacy protocol while the server-wide switch + /// is off (LP-6). Called before the credentials are checked, so the + /// answer is the same for a right password, a wrong one and an account + /// that doesn't exist (LP-11). + /// + /// Read from the store on each sign-in rather than cached, so every node + /// of a cluster answers the same the moment the switch turns. + pub async fn refuse_legacy_sign_in( + &self, + protocol: LegacyProtocol, + credentials: &Credentials, + ) -> trc::Result<()> { + if self.protocol_policy().await?.legacy_protocols.is_disabled() { + Err(protocol.refused(credentials)) + } else { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn basic(username: &str) -> Credentials { + Credentials::Basic { + username: username.to_string(), + secret: "wrong or right, it is never read".to_string(), + mfa_token: None, + } + } + + #[test] + fn refusals_read_as_the_spec_writes_them() { + // LP-12, with "Your organization" read as "This server" (LP-6). + assert_eq!( + LegacyProtocol::Imap.refusal(), + "This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in." + ); + assert!( + LegacyProtocol::Pop3 + .refusal() + .starts_with("[AUTH] This server allows") + ); + assert_eq!( + LegacyProtocol::ManageSieve.refusal(), + "This server allows only INBUXA webmail and JMAP apps." + ); + assert_eq!( + LegacyProtocol::Submission.refusal(), + "535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n" + ); + } + + #[test] + fn a_refusal_is_not_a_failed_sign_in() { + let err = LegacyProtocol::Imap.refused(&basic("maria@Example.org")); + assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused))); + assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed))); + // The session stays open: the mail app is told, not thrown off. + assert!(!err.must_disconnect()); + assert!(err.should_write_err()); + assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org")); + assert_eq!(err.value_as_str(trc::Key::Source), Some("imap")); + assert_eq!(err.value_as_str(trc::Key::AccountName), None); + } + + #[test] + fn the_domain_comes_from_the_name_given() { + assert_eq!(domain_of(&basic("a@b.test")), Some("b.test".to_string())); + assert_eq!(domain_of(&basic("no-domain")), None); + assert_eq!(domain_of(&basic("trailing@")), None); + let bearer = Credentials::Bearer { + username: None, + token: "t".to_string(), + }; + assert_eq!(domain_of(&bearer), None); + } +} diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index 1338dc1..e68f9d1 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -2,12 +2,14 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::core::{Session, SessionData, State}; use common::{ auth::AuthRequest, - network::{SessionStream, limiter::LimiterResult}, + network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult}, }; use directory::Credentials; use imap_proto::{ @@ -67,6 +69,12 @@ impl Session { } pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> { + // inbuxa: legacy-protocols LP-6, before the password is looked at + self.server + .refuse_legacy_sign_in(LegacyProtocol::Imap, &credentials) + .await + .map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?; + // Authenticate let access_token = self .server diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index 3f117cf..635443f 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -2,12 +2,14 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::core::{Command, Session, State, StatusResponse}; use common::{ auth::AuthRequest, - network::{SessionStream, limiter::LimiterResult}, + network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult}, }; use directory::Credentials; use imap_proto::{ @@ -65,6 +67,11 @@ impl Session { } }; + // inbuxa: legacy-protocols LP-6, before the password is looked at + self.server + .refuse_legacy_sign_in(LegacyProtocol::ManageSieve, &credentials) + .await?; + // Authenticate let access_token = self .server diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index cf423e3..1a7c832 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::{ @@ -10,7 +12,7 @@ use crate::{ }; use common::{ auth::AuthRequest, - network::{SessionStream, limiter::LimiterResult}, + network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult}, }; use directory::Credentials; use mail_parser::decoders::base64::base64_decode; @@ -61,6 +63,11 @@ impl Session { } pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> { + // inbuxa: legacy-protocols LP-6, before the password is looked at + self.server + .refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials) + .await?; + // Authenticate let access_token = self .server diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 044cf3f..b6c5b3e 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -2,10 +2,15 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::core::Session; -use common::{auth::AuthRequest, network::SessionStream}; +use common::{ + auth::AuthRequest, + network::{SessionStream, legacy::LegacyProtocol}, +}; use directory::Credentials; use mail_parser::decoders::base64::base64_decode; use registry::schema::enums::Permission; @@ -108,6 +113,26 @@ impl Session { } pub async fn authenticate(&mut self, credentials: Credentials) -> Result { + // inbuxa: legacy-protocols LP-6. Refused before the password is looked + // at, and not counted as an authentication error (LP-11). Only mail + // apps authenticate, so this never touches inbound delivery (LP-3). + if let Err(err) = self + .server + .refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials) + .await + { + let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused)); + trc::error!(err.span_id(self.data.session_id)); + if refused { + self.write(LegacyProtocol::Submission.refusal().as_bytes()) + .await?; + } else { + self.write(b"454 4.7.0 Temporary authentication failure\r\n") + .await?; + } + return Ok(false); + } + // Authenticate let result = self .server diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index a5e2878..a1ac625 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -8,8 +8,9 @@ // This file is auto-generated. Do not edit directly. -// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54) -pub const TOTAL_EVENT_COUNT: usize = 642; +// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is +// auth.legacy-protocol-refused (legacy-protocols LP-6) +pub const TOTAL_EVENT_COUNT: usize = 643; pub const TOTAL_METRIC_COUNT: usize = 369; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -116,6 +117,8 @@ pub enum AuthEvent { Error = 34, Warning = 595, CredentialExpired = 276, + // inbuxa: legacy-protocols LP-6 + LegacyProtocolRefused = 642, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 10b7bbf..06f9bbe 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -56,6 +56,8 @@ impl EventType { b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired), b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), + // inbuxa: legacy-protocols LP-6 + b"auth.legacy-protocol-refused" => EventType::Auth(AuthEvent::LegacyProtocolRefused), b"auth.error" => EventType::Auth(AuthEvent::Error), b"auth.warning" => EventType::Auth(AuthEvent::Warning), b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired), @@ -705,6 +707,8 @@ impl EventType { EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required", EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused) => "auth.legacy-protocol-refused", EventType::Auth(AuthEvent::Error) => "auth.error", EventType::Auth(AuthEvent::Warning) => "auth.warning", EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired", @@ -1489,6 +1493,8 @@ impl EventType { EventType::Auth(AuthEvent::MfaRequired) => 36, EventType::Auth(AuthEvent::TooManyAttempts) => 38, EventType::Auth(AuthEvent::ClientRegistration) => 555, + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642, EventType::Auth(AuthEvent::Error) => 34, EventType::Auth(AuthEvent::Warning) => 595, EventType::Auth(AuthEvent::CredentialExpired) => 276, @@ -2137,6 +2143,8 @@ impl EventType { 36 => Some(EventType::Auth(AuthEvent::MfaRequired)), 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), + // inbuxa: legacy-protocols LP-6 + 642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)), 34 => Some(EventType::Auth(AuthEvent::Error)), 595 => Some(EventType::Auth(AuthEvent::Warning)), 276 => Some(EventType::Auth(AuthEvent::CredentialExpired)), @@ -2848,6 +2856,8 @@ impl EventType { EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info, EventType::Auth(AuthEvent::Success) => Level::Info, EventType::Auth(AuthEvent::ClientRegistration) => Level::Info, + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused) => Level::Info, EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info, @@ -3187,6 +3197,8 @@ impl EventType { EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused", EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", @@ -3951,6 +3963,8 @@ impl EventType { } EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps", EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", @@ -4259,6 +4273,8 @@ impl EventType { EventType::Auth(AuthEvent::MfaRequired), EventType::Auth(AuthEvent::TooManyAttempts), EventType::Auth(AuthEvent::ClientRegistration), + // inbuxa: legacy-protocols LP-6 + EventType::Auth(AuthEvent::LegacyProtocolRefused), EventType::Auth(AuthEvent::Error), EventType::Auth(AuthEvent::Warning), EventType::Auth(AuthEvent::CredentialExpired), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index f0dfa52..1e85d01 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index fdb5f1d..af97834 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -nuvjIy1CjdKzUWRaCOjYVxDDThjb3eTz-7viHfo4lfM \ No newline at end of file +q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY \ No newline at end of file diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 2e669a9..a7ed06d 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -12,6 +12,12 @@ This is the part unit tests cannot reach: whether a socket actually closes on a running server (LP-2), and whether a listener put back actually binds again (LP-5). Acceptance tests 15, 17 and 18. +It also checks the second lock (LP-6): while the switch is off, sign-in over +submission -- locked open -- and over an IMAP listener that exists by mistake +is refused with the spec's words, with the right password and with a wrong +one, and refusals never add up to a disconnect (LP-11). And that a normal +IMAP sign-in works with the switch on, before and after. + Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. """ @@ -25,8 +31,12 @@ HTTP = "http://127.0.0.1:18080" # makes the host side accept connections whether or not anything is listening # inside the container, so a bare connect proves nothing: each port has to be # made to speak. -PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025} -TLS_PORTS = {18993, 18995, 18465} +PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025, "mistake": 18994} +TLS_PORTS = {18993, 18995, 18465, 18994} +IMAP_REFUSAL = ("NO [ALERT] This server allows only INBUXA webmail and JMAP apps. " + "This mail app can't sign in.") +SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. " + "This mail app can't send.") INBUXA = "urn:inbuxa:jmap" failures = [] @@ -60,7 +70,8 @@ def start(env_file=None): "-p", f"127.0.0.1:{PORTS['submissions']}:465", "-p", f"127.0.0.1:{PORTS['imap']}:993", "-p", f"127.0.0.1:{PORTS['pop3']}:995", - "-p", f"127.0.0.1:{PORTS['smtp']}:25"] + "-p", f"127.0.0.1:{PORTS['smtp']}:25", + "-p", f"127.0.0.1:{PORTS['mistake']}:1993"] if env_file: args += ["--env-file", env_file] args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"] @@ -122,6 +133,64 @@ def accepts(port, timeout=5): return False +def tls(port, timeout=10): + ctx = ssl.create_default_context() + ctx.check_hostname = False + ctx.verify_mode = ssl.CERT_NONE + return ctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=timeout)) + + +def lines(sock): + """Yields reply lines, CRLF stripped.""" + buf = b"" + while True: + while b"\r\n" not in buf: + chunk = sock.recv(4096) + if not chunk: + return + buf += chunk + line, buf = buf.split(b"\r\n", 1) + yield line.decode(errors="replace") + + +def imap_login(port, user, password): + """The tagged reply to LOGIN, over implicit TLS.""" + with tls(port) as sock: + read = lines(sock) + next(read) # greeting + quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"' + sock.sendall(f"a1 LOGIN {quote(user)} {quote(password)}\r\n".encode()) + for line in read: + if line.startswith("a1 "): + return line[3:] + return "" + + +def smtp_auths(port, user, passwords): + """The reply to AUTH PLAIN for each password in turn, on one connection. + A reply of "" means the server hung up.""" + # Every connection reaches the server from Docker's gateway, one IP, and + # the stock inbound throttle takes five a second from it. The port checks + # just before can use those up, so wait the second out. + time.sleep(1.1) + replies = [] + with tls(port) as sock: + read = lines(sock) + next(read) # greeting + sock.sendall(b"EHLO e2e.test\r\n") + for line in read: + if line[3:4] == " ": + break + for password in passwords: + token = base64.b64encode(f"\0{user}\0{password}".encode()).decode() + try: + sock.sendall(f"AUTH PLAIN {token}\r\n".encode()) + replies.append(next(read, "")) + except OSError: + replies.append("") + return replies + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -176,6 +245,12 @@ def main(): check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch") + # A normal sign-in works with the switch on -- the control for LP-6. + check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), + "IMAP sign-in works with the switch on") + check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"), + "submission sign-in works with the switch on") + # What the screen reads: the locked set and what would close (LP-16, LP-21). got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get) if got[0] != "inbuxa:ProtocolPolicy/get": @@ -212,12 +287,40 @@ def main(): print(" savedListeners:", sorted(saved)) check(saved, "the closed listeners were saved (LP-1)") + # The second lock (LP-6). Submission stays open, being locked, so sign-in + # over it is refused instead -- right password or wrong, the same words, + # and never enough of them to be thrown off (LP-11, test 2, test 18). + replies = smtp_auths(PORTS["submissions"], admin, [admin_pw] + ["wrong"] * 6) + check(replies[0] == SMTP_REFUSAL, "submission refuses the right password (LP-6)") + check(all(r == SMTP_REFUSAL for r in replies[1:]), + "submission refuses wrong passwords the same way, and doesn't hang up (LP-11)") + if not all(r == SMTP_REFUSAL for r in replies): + print(" replies:", replies) + + # An IMAP listener that exists by mistake: created while the switch is off + # (LP-4 will refuse this later), and live after the restart below. + res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": { + "name": "imap-mistake", "protocol": "imap", "bind": {"0.0.0.0:1993": True}, + "tlsImplicit": True}}}) + mistake = (res[1].get("created") or {}).get("m", {}).get("id") + check(mistake is not None, "an IMAP listener can still be created by mistake") + # A restart must not reopen them: the objects are gone, not just the sockets. stop() start() check(settle(PORTS["imap"], False), "IMAP still closed after a restart") check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart") + if mistake: + check(settle(PORTS["mistake"], True), "the mistaken IMAP listener is up") + check(imap_login(PORTS["mistake"], admin, admin_pw) == IMAP_REFUSAL, + "the mistaken listener refuses the right password (LP-6)") + check(imap_login(PORTS["mistake"], admin, "wrong") == IMAP_REFUSAL, + "and a wrong one, the same way (LP-11)") + check(imap_login(PORTS["mistake"], "nobody@legacy.test", "x") == IMAP_REFUSAL, + "and an account that doesn't exist (LP-11)") + one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [mistake]}) + # Turn it back on: the listeners come back and bind again (LP-5). res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", policy_set({"legacyProtocols": "enabled"})) @@ -231,6 +334,12 @@ def main(): check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") + # And sign-in works again, with no restart. + check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"), + "IMAP sign-in works again once the switch is back on") + check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"), + "submission sign-in works again once the switch is back on") + print() if failures: print(f"{len(failures)} FAILED:")