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.
This commit is contained in:
2026-09-20 15:24:23 -07:00
parent f04dbc3417
commit 08f12fa158
2 changed files with 97 additions and 9 deletions
+7 -5
View File
@@ -230,11 +230,6 @@ mod tests {
NetworkListenerProtocol::ManageSieve, NetworkListenerProtocol::ManageSieve,
&["[::]:4190"][..], &["[::]:4190"][..],
), ),
(
"submissions",
NetworkListenerProtocol::Smtp,
&["[::]:465"][..],
),
] { ] {
assert!( assert!(
closes(&policy, &listener(name, protocol, binds)), closes(&policy, &listener(name, protocol, binds)),
@@ -244,6 +239,12 @@ mod tests {
for (name, protocol, binds) in [ for (name, protocol, binds) in [
("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]), ("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]),
// Locked whole, so submission stays too (LP-21).
(
"submissions",
NetworkListenerProtocol::Smtp,
&["[::]:465"][..],
),
("https", NetworkListenerProtocol::Http, &["[::]:443"][..]), ("https", NetworkListenerProtocol::Http, &["[::]:443"][..]),
("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]), ("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]),
] { ] {
@@ -255,6 +256,7 @@ mod tests {
} }
/// A submission listener that also binds 25 is inbound, and stays (LP-3). /// 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] #[test]
fn a_listener_that_also_binds_25_stays() { fn a_listener_that_also_binds_25_stays() {
let policy = disabled(); let policy = disabled();
@@ -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). /// The port that always means inbound mail, and is never closed (LP-3).
pub const INBOUND_SMTP_PORT: u16 = 25; 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 { impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the switch /// 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 /// 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() { if !self.legacy_protocols.is_disabled() {
return false; 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) { if LEGACY_PROTOCOLS.contains(&protocol) {
return true; return true;
} }
@@ -122,6 +150,20 @@ impl ProtocolPolicy {
&& !ports.contains(&INBOUND_SMTP_PORT) && !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 /// Whether creating a listener of this protocol is refused right now
/// (LP-4), so a closed port cannot be quietly reopened. /// (LP-4), so a closed port cannot be quietly reopened.
pub fn refuses_new_listener(&self, protocol: &str, ports: &[u16]) -> bool { 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] #[test]
fn disabled_closes_the_legacy_protocols() { fn disabled_closes_the_legacy_protocols() {
let policy = disabled(); let policy = disabled();
assert!(policy.closes("imap", &[993])); assert!(policy.closes("imap", &[993]));
assert!(policy.closes("pop3", &[995])); assert!(policy.closes("pop3", &[995]));
assert!(policy.closes("manageSieve", &[4190])); assert!(policy.closes("manageSieve", &[4190]));
assert!(policy.closes("smtp", &[465])); assert!(!policy.closes("smtp", &[465]), "SMTP is locked (LP-21)");
assert!(policy.closes("smtp", &[587])); 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 /// 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] #[test]
fn port_25_is_never_closed() { fn port_25_is_never_closed() {
let policy = disabled(); let policy = disabled();
@@ -270,6 +355,7 @@ mod tests {
} }
/// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2). /// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2).
/// The lock makes this the only behaviour for now (LP-21).
#[test] #[test]
fn submission_stays_open_when_asked() { fn submission_stays_open_when_asked() {
let policy = ProtocolPolicy { let policy = ProtocolPolicy {