From 08f12fa1587077d6650a698050a202f1ac4805cc Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 15:24:23 -0700 Subject: [PATCH] SMTP and JMAP are locked open, and the selector will show them so John, 2026-09-20: "SMTP and JMAP should be shown with the selector locked, we want to prevent those two protocols from being shutdown for now." The selector lists every mail protocol the server speaks, so the operator sees the whole surface at once; SMTP and JMAP sit in it named and visibly not switchable. JMAP was never closeable -- closing it locks everyone out of their mail and the operator out of INBUXA Admin, with no way back but the host -- and is now visibly so. SMTP is locked whole. LP-3 already spared inbound on 25; this extends that to submission on 465 and 587, which LP-1 would otherwise have closed by default. So closeSubmission has no effect while the lock stands, and is forced to false. A client that asks for true is not refused: the value is recorded, overruled, and the overrule reported, because the field is specified and the lock is meant to be temporary. is_locked() is consulted before anything else in closes(), so no phrasing of a request reaches past it. This costs the feature nothing. Submission's ports stay open and sign-in over them is still refused once LP-6 lands, which is the case acceptance test 2 already described: a mail app reaching 465 is told it cannot sign in rather than finding nothing listening. The operator also keeps a port they may well be forwarding, which is the LP-20 problem in miniature. The locked set is a server constant the front ends read, not a list they carry, so unlocking later is a server change and no admin release. The LP-3 tests stay as they are, to keep it covered if the lock is lifted. Recorded as LP-21, with acceptance tests 17 and 18. --- crates/features/src/security/listeners.rs | 12 ++- .../features/src/security/protocol_policy.rs | 94 ++++++++++++++++++- 2 files changed, 97 insertions(+), 9 deletions(-) diff --git a/crates/features/src/security/listeners.rs b/crates/features/src/security/listeners.rs index c7e4187..7751c31 100644 --- a/crates/features/src/security/listeners.rs +++ b/crates/features/src/security/listeners.rs @@ -230,11 +230,6 @@ mod tests { NetworkListenerProtocol::ManageSieve, &["[::]:4190"][..], ), - ( - "submissions", - NetworkListenerProtocol::Smtp, - &["[::]:465"][..], - ), ] { assert!( closes(&policy, &listener(name, protocol, binds)), @@ -244,6 +239,12 @@ mod tests { for (name, protocol, binds) in [ ("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]), + // Locked whole, so submission stays too (LP-21). + ( + "submissions", + NetworkListenerProtocol::Smtp, + &["[::]:465"][..], + ), ("https", NetworkListenerProtocol::Http, &["[::]:443"][..]), ("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]), ] { @@ -255,6 +256,7 @@ mod tests { } /// A submission listener that also binds 25 is inbound, and stays (LP-3). + /// Kept so LP-3 stays covered if the LP-21 lock is ever lifted. #[test] fn a_listener_that_also_binds_25_stays() { let policy = disabled(); diff --git a/crates/features/src/security/protocol_policy.rs b/crates/features/src/security/protocol_policy.rs index 057dfb2..8b50e96 100644 --- a/crates/features/src/security/protocol_policy.rs +++ b/crates/features/src/security/protocol_policy.rs @@ -106,6 +106,29 @@ pub const LEGACY_PROTOCOLS: &[&str] = &["imap", "pop3", "manageSieve"]; /// The port that always means inbound mail, and is never closed (LP-3). pub const INBOUND_SMTP_PORT: u16 = 25; +/// Protocols the switch may never close, whatever is asked of it (LP-21). +/// +/// `http` carries JMAP, so closing it would lock every account out of its mail +/// and the operator out of INBUXA Admin. `smtp` is locked whole — inbound and +/// submission alike — by John's decision of 2026-09-20; LP-3 already spared +/// inbound, and this extends it to 465 and 587. `lmtp` is internal and was +/// never a candidate. +/// +/// Locking submission costs the feature nothing: the ports stay open and +/// sign-in over them is still refused (LP-6), which is the case acceptance +/// test 2 already describes. +/// +/// The front ends read this list rather than carry their own copy, so +/// unlocking later is a server change and no admin release. +pub const LOCKED_PROTOCOLS: &[&str] = &["smtp", "lmtp", "http"]; + +/// Whether this protocol is locked open (LP-21). +pub fn is_locked(protocol: &str) -> bool { + LOCKED_PROTOCOLS + .iter() + .any(|locked| locked.eq_ignore_ascii_case(protocol)) +} + impl ProtocolPolicy { /// Whether a listener of this protocol and these ports is one the switch /// closes. A listener bound to port 25 is inbound whatever its name, and @@ -114,6 +137,11 @@ impl ProtocolPolicy { if !self.legacy_protocols.is_disabled() { return false; } + // The lock is checked first and answers for every caller, so no + // request phrasing can reach past it (LP-21). + if is_locked(protocol) { + return false; + } if LEGACY_PROTOCOLS.contains(&protocol) { return true; } @@ -122,6 +150,20 @@ impl ProtocolPolicy { && !ports.contains(&INBOUND_SMTP_PORT) } + /// Applies the locks to what a client asked for, returning what was + /// overruled so the response can say so (LP-21). + /// + /// `closeSubmission` is recorded and ignored rather than refused: the + /// field is specified, and the lock is meant to be temporary. + pub fn apply_locks(&mut self) -> Vec<&'static str> { + let mut overruled = Vec::new(); + if self.close_submission && is_locked("smtp") { + self.close_submission = false; + overruled.push("closeSubmission"); + } + overruled + } + /// Whether creating a listener of this protocol is refused right now /// (LP-4), so a closed port cannot be quietly reopened. pub fn refuses_new_listener(&self, protocol: &str, ports: &[u16]) -> bool { @@ -238,19 +280,62 @@ mod tests { } } - /// The mail-app protocols close, and so does submission by default (LP-1). + /// The mail-app protocols close. Submission does not, while SMTP is + /// locked (LP-1, LP-21). #[test] fn disabled_closes_the_legacy_protocols() { let policy = disabled(); assert!(policy.closes("imap", &[993])); assert!(policy.closes("pop3", &[995])); assert!(policy.closes("manageSieve", &[4190])); - assert!(policy.closes("smtp", &[465])); - assert!(policy.closes("smtp", &[587])); + assert!(!policy.closes("smtp", &[465]), "SMTP is locked (LP-21)"); + assert!(!policy.closes("smtp", &[587]), "SMTP is locked (LP-21)"); + } + + /// SMTP and JMAP cannot be closed, however the question is put (LP-21). + #[test] + fn smtp_and_jmap_are_locked() { + assert!(is_locked("smtp")); + assert!(is_locked("SMTP"), "the lock ignores case"); + assert!(is_locked("http")); + assert!(is_locked("lmtp")); + assert!(!is_locked("imap")); + assert!(!is_locked("pop3")); + assert!(!is_locked("manageSieve")); + + // Even asked for directly, with closeSubmission set by hand. + let forced = ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + close_submission: true, + ..Default::default() + }; + for ports in [vec![465], vec![587], vec![25], vec![2525]] { + assert!(!forced.closes("smtp", &ports), "smtp {ports:?}"); + } + assert!(!forced.closes("http", &[443])); + } + + /// A client asking to close submission is overruled, not refused, and the + /// overrule is reported (LP-21, acceptance test 18). + #[test] + fn close_submission_is_overruled_and_reported() { + let mut policy = ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + close_submission: true, + ..Default::default() + }; + + let overruled = policy.apply_locks(); + assert_eq!(overruled, vec!["closeSubmission"]); + assert!(!policy.close_submission); + + // Applying twice says nothing the second time. + assert!(policy.apply_locks().is_empty()); } /// Port 25 is inbound whatever the listener is called, and never closes - /// (LP-3). + /// (LP-3). It is doubly safe now that SMTP is locked (LP-21), and this + /// test stands so LP-3 stays covered if the lock is ever lifted. #[test] fn port_25_is_never_closed() { let policy = disabled(); @@ -270,6 +355,7 @@ mod tests { } /// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2). + /// The lock makes this the only behaviour for now (LP-21). #[test] fn submission_stays_open_when_asked() { let policy = ProtocolPolicy {