Give IMAP, POP3 and ManageSieve a switch each
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s

The legacy-protocols switch was all or nothing. An operator can now stop
POP3 and keep IMAP: each of IMAP, POP3 and ManageSieve has its own
switch, server-wide on inbuxa:ProtocolPolicy and per tenant on
inbuxa:TenantProtocolPolicy (properties imap, pop3, manageSieve).

legacyProtocols stays as the kill-all: setting it sets all three, and it
reads "disabled" exactly when all three are off. A policy stored before
this has only legacyProtocols and reads as all three at that value, so
existing servers and tenants carry over unchanged. In one /set, a
protocol named beside legacyProtocols overrides it.

SMTP submission keeps no switch of its own: sign-in over it is refused
only when all three are off, as the single switch did (LP-6), so
turning one protocol off never stops a mail app sending. For a tenant,
the server's switches and the tenant's count together.

Server-wide, a change closes the listeners of whatever is now off and
puts back the saved listeners of whatever is on again, both in one
change if asked; listeners of a protocol still off stay saved. Sign-in,
autoconfig, autodiscover, PACC (now prepared once per combination) and
the suggested DNS records all follow each protocol separately. A tenant
may turn a protocol on only while the server has it on (LP-9), and the
refusal names which. The JMAP session adds legacyAllowed, the protocols
still allowed for the account; legacyProtocols there keeps its meaning
for older webmail builds. Events name the switches ("pop3 disabled"),
and audit before/after reads every switch even from an older policy.

Tested: unit tests for the switches, the old-policy reading, the
server/tenant combination, the tenant refusal and listener refusal; and
tests/e2e/legacy_protocols.py against a running server, all 100 checks,
including new ones: POP3 alone off closes only its port and refuses
only its sign-in while IMAP and sending go on; only POP3 stops being
advertised; one change closes IMAP and reopens POP3; a tenant turns
POP3 off for itself, and can't turn IMAP on while the server has it off.
This commit is contained in:
2026-09-27 23:12:32 -07:00
parent 7ba9ec9fa0
commit 8e9cedbe97
15 changed files with 930 additions and 199 deletions
+229 -11
View File
@@ -8,6 +8,17 @@
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
//!
//! Each mail-app protocol has its own switch (legacy-protocols spec,
//! "Revisit: one switch per protocol"): IMAP, POP3 and ManageSieve.
//! `legacyProtocols` is the kill-all: setting it sets all three, and it reads
//! `disabled` exactly when all three are off. A policy stored before the
//! per-protocol switches has only `legacyProtocols`, and reads as all three
//! at that value.
//!
//! SMTP submission has no switch of its own here: sign-in over it is refused
//! only when all three are off, as it was by the single switch (LP-6), so
//! turning off one protocol never stops a mail app sending.
//!
//! This module is the fact, not the act. It holds what the operator chose and
//! which listeners were taken away to honour it. Closing sockets belongs to
//! `common`, which owns the listener registry, and removing the listener
@@ -59,12 +70,30 @@ pub struct SavedListener {
pub object: serde_json::Value,
}
/// The server-wide switch.
/// The protocols with a switch of their own, as the schema and JMAP spell
/// them.
pub const SWITCHED: &[&str] = &["imap", "pop3", "manageSieve"];
/// The name sign-in uses for SMTP AUTH, which follows the kill-all.
pub const SUBMISSION: &str = "submission";
/// The server-wide switches.
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct ProtocolPolicy {
/// The switch itself.
/// The kill-all: `disabled` exactly when all three protocols are off,
/// once [`ProtocolPolicy::normalize`] has run. In a policy stored before
/// the per-protocol switches, it is the value of all three.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// With `disabled`, also close SMTP submission (LP-3). The inbound
/// listener on port 25 is never closed, whatever this says.
pub close_submission: bool,
@@ -80,6 +109,9 @@ impl Default for ProtocolPolicy {
fn default() -> Self {
ProtocolPolicy {
legacy_protocols: LegacyProtocols::Enabled,
imap: None,
pop3: None,
manage_sieve: None,
close_submission: true,
saved_listeners: Vec::new(),
changed_at: None,
@@ -91,6 +123,9 @@ impl Default for ProtocolPolicy {
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
pub const PROPERTIES: &[&str] = &[
"legacyProtocols",
"imap",
"pop3",
"manageSieve",
"closeSubmission",
"savedListeners",
"changedAt",
@@ -129,23 +164,137 @@ pub fn is_locked(protocol: &str) -> bool {
.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
/// any other SMTP listener counts as submission (LP-3).
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
if !self.legacy_protocols.is_disabled() {
return false;
/// The switch fields, by protocol name.
pub trait Switches {
/// The kill-all, which an unset per-protocol switch reads as.
fn all(&self) -> LegacyProtocols;
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>>;
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>>;
fn set_all_field(&mut self, value: LegacyProtocols);
/// One protocol's switch. `submission` follows the kill-all: it is off
/// only when all three are. Anything else has no switch and is on.
fn switch(&self, protocol: &str) -> LegacyProtocols {
if protocol == SUBMISSION {
return if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
}
match self.slot(protocol) {
Some(value) => value.unwrap_or(self.all()),
None => LegacyProtocols::Enabled,
}
}
/// Whether this protocol is off.
fn is_off(&self, protocol: &str) -> bool {
self.switch(protocol).is_disabled()
}
/// Whether all three protocols are off.
fn all_off(&self) -> bool {
SWITCHED.iter().all(|protocol| {
self.slot(protocol)
.and_then(|value| *value)
.unwrap_or(self.all())
.is_disabled()
})
}
/// Sets one protocol's switch; false if it has none.
fn set(&mut self, protocol: &str, value: LegacyProtocols) -> bool {
match self.slot_mut(protocol) {
Some(slot) => {
*slot = Some(value);
true
}
None => false,
}
}
/// The kill-all: all three at once.
fn set_all(&mut self, value: LegacyProtocols) {
for protocol in SWITCHED {
self.set(protocol, value);
}
self.set_all_field(value);
}
/// Writes out every switch and derives the kill-all from them, so what is
/// stored and shown never depends on how it was reached.
fn normalize(&mut self) {
let values: Vec<_> = SWITCHED.iter().map(|p| self.switch(p)).collect();
for (protocol, value) in SWITCHED.iter().zip(values) {
self.set(protocol, value);
}
let all = if self.all_off() {
LegacyProtocols::Disabled
} else {
LegacyProtocols::Enabled
};
self.set_all_field(all);
}
/// The protocols that are off.
fn off(&self) -> Vec<&'static str> {
SWITCHED
.iter()
.copied()
.filter(|p| self.is_off(p))
.collect()
}
}
macro_rules! switches {
($t:ty) => {
impl Switches for $t {
fn all(&self) -> LegacyProtocols {
self.legacy_protocols
}
fn slot(&self, protocol: &str) -> Option<&Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&self.imap),
"pop3" => Some(&self.pop3),
"manageSieve" => Some(&self.manage_sieve),
_ => None,
}
}
fn slot_mut(&mut self, protocol: &str) -> Option<&mut Option<LegacyProtocols>> {
match protocol {
"imap" => Some(&mut self.imap),
"pop3" => Some(&mut self.pop3),
"manageSieve" => Some(&mut self.manage_sieve),
_ => None,
}
}
fn set_all_field(&mut self, value: LegacyProtocols) {
self.legacy_protocols = value;
}
}
};
}
pub(crate) use switches;
switches!(ProtocolPolicy);
impl ProtocolPolicy {
/// Whether a listener of this protocol and these ports is one the
/// switches close. A listener bound to port 25 is inbound whatever its
/// name, and any other SMTP listener counts as submission (LP-3), closed
/// only with all three off and `closeSubmission`.
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
// 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;
return self.is_off(protocol);
}
protocol.eq_ignore_ascii_case("smtp")
&& self.all_off()
&& self.close_submission
&& !ports.contains(&INBOUND_SMTP_PORT)
}
@@ -258,7 +407,10 @@ mod tests {
"an unset closeSubmission reads as the default, true"
);
let json = serde_json::to_value(&policy).unwrap();
// As shown: normalized, every switch written out.
let mut shown = policy.clone();
shown.normalize();
let json = serde_json::to_value(&shown).unwrap();
for property in PROPERTIES {
assert!(json.get(property).is_some(), "{property}");
}
@@ -410,6 +562,72 @@ mod tests {
);
}
/// A policy stored before the per-protocol switches reads as all three
/// at its one value.
#[test]
fn an_old_policy_reads_as_all_three() {
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.all_off() && old.is_off(SUBMISSION));
let old: ProtocolPolicy =
serde_json::from_str(r#"{"legacyProtocols": "enabled"}"#).unwrap();
assert!(old.off().is_empty() && !old.is_off(SUBMISSION));
}
/// One protocol off closes only its listeners, and leaves sending alone.
#[test]
fn one_protocol_off() {
let mut policy = ProtocolPolicy::default();
policy.set("pop3", LegacyProtocols::Disabled);
policy.normalize();
assert!(policy.closes("pop3", &[995]));
assert!(!policy.closes("imap", &[993]));
assert!(!policy.closes("manageSieve", &[4190]));
assert!(!policy.is_off(SUBMISSION), "sending goes on");
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
assert_eq!(policy.off(), vec!["pop3"]);
let json = serde_json::to_value(&policy).unwrap();
assert_eq!(json["pop3"], "disabled");
assert_eq!(json["imap"], "enabled");
}
/// Turning the three off one at a time is the kill-all, and the kill-all
/// back on turns all three on.
#[test]
fn the_kill_all_is_all_three() {
let mut policy = ProtocolPolicy::default();
for p in SWITCHED {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
assert!(policy.legacy_protocols.is_disabled());
assert!(policy.is_off(SUBMISSION));
policy.set_all(LegacyProtocols::Enabled);
policy.normalize();
assert!(policy.off().is_empty());
assert!(!policy.legacy_protocols.is_disabled());
// The kill-all then one back on: no longer all off.
policy.set_all(LegacyProtocols::Disabled);
policy.set("imap", LegacyProtocols::Enabled);
policy.normalize();
assert!(!policy.legacy_protocols.is_disabled());
assert_eq!(policy.off(), vec!["pop3", "manageSieve"]);
}
/// Protocols without a switch are never off.
#[test]
fn unswitched_protocols_are_on() {
let policy = disabled();
for p in ["smtp", "http", "lmtp", "jmap"] {
assert!(!policy.is_off(p), "{p}");
}
}
/// A saved listener with no id is refused, naming the property.
#[test]
fn a_nameless_saved_listener_is_refused() {
@@ -9,12 +9,18 @@
//! the tenant id in the fork's subspace; a tenant with nothing stored has
//! legacy protocols on.
//!
//! A tenant has the same three switches as the server (IMAP, POP3,
//! ManageSieve) and the same kill-all; a protocol off server-wide is off for
//! every tenant whatever the tenant's own switch says.
//!
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
//! refuses sign-in on the tenant's domains, and keeps client configuration
//! for them from offering what's refused. That is all it is: one fact per
//! tenant, easy to turn back, touching no listener, role or permission.
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
use crate::security::protocol_policy::{
LegacyProtocols, ProtocolPolicy, SUBMISSION, SWITCHED, Switches, switches,
};
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
use store::{
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
@@ -26,24 +32,92 @@ use trc::AddContext;
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
#[serde(rename_all = "camelCase", default)]
pub struct TenantProtocolPolicy {
/// The switch itself.
/// The kill-all, as on the server's policy.
pub legacy_protocols: LegacyProtocols,
/// IMAP's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub imap: Option<LegacyProtocols>,
/// POP3's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub pop3: Option<LegacyProtocols>,
/// ManageSieve's switch. Unset reads as `legacy_protocols`.
#[serde(skip_serializing_if = "Option::is_none")]
pub manage_sieve: Option<LegacyProtocols>,
/// When it last changed, in milliseconds since the epoch.
pub changed_at: Option<u64>,
/// The account that last changed it.
pub changed_by: Option<String>,
}
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
switches!(TenantProtocolPolicy);
/// Why a tenant's switches can't be set this way, if they can't (LP-9).
///
/// A tenant can always turn legacy protocols off for itself. It can turn
/// them back on only while the server has them on: server off means off for
/// everyone.
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
so they can't be turned back on for one organization.",
)
/// A tenant can always turn a protocol off for itself. It can turn one on
/// only while the server has it on: server off means off for everyone.
/// `turned_on` is what the request sets to `enabled`, by protocol name.
pub fn refusal(server: &ProtocolPolicy, turned_on: &[&str]) -> Option<String> {
let blocked: Vec<&str> = turned_on
.iter()
.copied()
.filter(|protocol| server.is_off(protocol))
.collect();
(!blocked.is_empty()).then(|| {
format!(
"{} off for the whole server (inbuxa:ProtocolPolicy), so {} can't be turned \
back on for one organization.",
names(&blocked),
if blocked.len() == 1 { "it" } else { "they" }
)
})
}
/// Protocol names as people read them: "IMAP and POP3 are", "POP3 is".
fn names(protocols: &[&str]) -> String {
let named: Vec<&str> = protocols
.iter()
.map(|p| match *p {
"imap" => "IMAP",
"pop3" => "POP3",
"manageSieve" => "ManageSieve",
other => other,
})
.collect();
let list = match named.as_slice() {
[one] => one.to_string(),
[rest @ .., last] => format!("{} and {last}", rest.join(", ")),
[] => String::new(),
};
format!("{list} {}", if named.len() == 1 { "is" } else { "are" })
}
/// Whose switch turns a protocol off, if any.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffBy {
Server,
Tenant,
}
/// Whether this protocol is off for an account or domain, and by whose
/// switch: the server's first (LP-6), then the tenant's (LP-10). Submission
/// is off when all three protocols are, counting both switches together.
pub fn off_by(
server: &ProtocolPolicy,
tenant: Option<&TenantProtocolPolicy>,
protocol: &str,
) -> Option<OffBy> {
if server.is_off(protocol) {
return Some(OffBy::Server);
}
let tenant = tenant?;
let off = if protocol == SUBMISSION {
SWITCHED
.iter()
.all(|p| server.is_off(p) || tenant.is_off(p))
} else {
tenant.is_off(protocol)
};
off.then_some(OffBy::Tenant)
}
fn key(tenant_id: u32) -> ValueClass {
@@ -115,6 +189,15 @@ mod tests {
}
}
fn tenant_off(protocols: &[&str]) -> TenantProtocolPolicy {
let mut policy = TenantProtocolPolicy::default();
for p in protocols {
policy.set(p, LegacyProtocols::Disabled);
}
policy.normalize();
policy
}
#[test]
fn a_tenant_starts_with_legacy_protocols_on() {
assert!(
@@ -127,20 +210,59 @@ mod tests {
#[test]
fn a_tenant_can_always_turn_them_off() {
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
assert_eq!(refusal(&server(s), &[]), None);
}
}
#[test]
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
// LP-9, acceptance test 9.
assert_eq!(
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
None
);
let why =
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
assert_eq!(refusal(&server(LegacyProtocols::Enabled), SWITCHED), None);
let why = refusal(&server(LegacyProtocols::Disabled), SWITCHED).expect("refused");
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
assert!(
why.starts_with("IMAP, POP3 and ManageSieve are off"),
"{why}"
);
}
#[test]
fn a_tenant_can_turn_on_what_the_server_allows() {
// The server has only POP3 off: IMAP may come back, POP3 may not.
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
assert_eq!(refusal(&s, &["imap"]), None);
let why = refusal(&s, &["imap", "pop3"]).expect("refused");
assert!(why.starts_with("POP3 is off"), "{why}");
}
#[test]
fn whose_switch_turns_a_protocol_off() {
let mut s = ProtocolPolicy::default();
s.set("pop3", LegacyProtocols::Disabled);
let t = tenant_off(&["imap"]);
assert_eq!(off_by(&s, Some(&t), "pop3"), Some(OffBy::Server));
assert_eq!(off_by(&s, Some(&t), "imap"), Some(OffBy::Tenant));
assert_eq!(off_by(&s, Some(&t), "manageSieve"), None);
assert_eq!(off_by(&s, None, "imap"), None);
// Sending goes on while any protocol is still allowed.
assert_eq!(off_by(&s, Some(&t), SUBMISSION), None);
// Between them, all three off: submission follows (LP-6, LP-10).
let t = tenant_off(&["imap", "manageSieve"]);
assert_eq!(off_by(&s, Some(&t), SUBMISSION), Some(OffBy::Tenant));
assert_eq!(
off_by(&server(LegacyProtocols::Disabled), None, SUBMISSION),
Some(OffBy::Server)
);
}
#[test]
fn an_old_tenant_policy_reads_as_all_three() {
let Json(old) = Json::deserialize(br#"{"legacyProtocols":"disabled"}"#).unwrap();
for p in SWITCHED {
assert!(old.is_off(p), "{p}");
}
assert!(old.is_off(SUBMISSION));
}
#[test]
@@ -158,6 +280,7 @@ mod tests {
legacy_protocols: LegacyProtocols::Disabled,
changed_at: Some(1),
changed_by: Some("b".into()),
..Default::default()
};
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
assert_eq!(back, policy);