Files
inbuxa-server/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs
T
jcoffey-dev 8e9cedbe97
ci / fork-checks (pull_request) Successful in 43s
ci / build (pull_request) Successful in 7m40s
Give IMAP, POP3 and ManageSieve a switch each
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.
2026-09-27 23:12:32 -07:00

213 lines
6.3 KiB
Rust

/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
//! `inbuxa:ProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: the
//! server-wide legacy mail protocols switch (legacy-protocols spec). A
//! singleton, id `singleton`.
//!
//! Three of its properties are the server's to say, not the client's:
//! `savedListeners` (LP-1), `lockedProtocols` (LP-21) and `wouldClose`
//! (LP-16). A client that sets them is answered with `invalidProperties`.
use crate::object::{AnyId, JmapObject, JmapObjectId};
use jmap_tools::{Element, Key, Property};
use std::{borrow::Cow, str::FromStr};
use types::id::Id;
#[derive(Debug, Clone, Default)]
pub struct ProtocolPolicy;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyProperty {
Id,
/// The kill-all: `enabled` or `disabled`; reads `disabled` when all
/// three protocols are off, and sets all three.
LegacyProtocols,
/// Each protocol's own switch: `enabled` or `disabled`.
Imap,
Pop3,
ManageSieve,
/// Whether submission closes with it. Forced false while SMTP is locked.
CloseSubmission,
/// Server-set: the listeners taken away, for LP-5.
SavedListeners,
ChangedAt,
ChangedBy,
/// Server-set: the protocols that cannot be closed, so the selector can
/// render them locked rather than carry its own list (LP-21).
LockedProtocols,
/// Server-set: exactly which listeners turning the switch would close,
/// by name and port, for the confirmation (LP-16).
WouldClose,
/// Server-set: who signed in over a legacy protocol in the last 30
/// days, and when, for the impact panel (LP-15).
RecentLegacyUse,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ProtocolPolicyValue {
Id(Id),
}
impl Property for ProtocolPolicyProperty {
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
ProtocolPolicyProperty::parse(value)
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ProtocolPolicyProperty::Id => "id",
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
ProtocolPolicyProperty::Imap => "imap",
ProtocolPolicyProperty::Pop3 => "pop3",
ProtocolPolicyProperty::ManageSieve => "manageSieve",
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
ProtocolPolicyProperty::SavedListeners => "savedListeners",
ProtocolPolicyProperty::ChangedAt => "changedAt",
ProtocolPolicyProperty::ChangedBy => "changedBy",
ProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
ProtocolPolicyProperty::WouldClose => "wouldClose",
}
.into()
}
}
impl ProtocolPolicyProperty {
fn parse(value: &str) -> Option<Self> {
hashify::tiny_map!(value.as_bytes(),
b"id" => ProtocolPolicyProperty::Id,
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
b"imap" => ProtocolPolicyProperty::Imap,
b"pop3" => ProtocolPolicyProperty::Pop3,
b"manageSieve" => ProtocolPolicyProperty::ManageSieve,
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
b"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
)
}
}
impl ProtocolPolicyProperty {
/// Whether this property is the server's to say. A client that sets one
/// is answered with `invalidProperties`.
pub fn is_server_set(&self) -> bool {
matches!(
self,
ProtocolPolicyProperty::SavedListeners
| ProtocolPolicyProperty::ChangedAt
| ProtocolPolicyProperty::ChangedBy
| ProtocolPolicyProperty::RecentLegacyUse
| ProtocolPolicyProperty::LockedProtocols
| ProtocolPolicyProperty::WouldClose
)
}
}
impl FromStr for ProtocolPolicyProperty {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
ProtocolPolicyProperty::parse(s).ok_or(())
}
}
impl Element for ProtocolPolicyValue {
type Property = ProtocolPolicyProperty;
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
match key {
Key::Property(ProtocolPolicyProperty::Id) => Id::from_str(value).ok().map(ProtocolPolicyValue::Id),
_ => None,
}
}
fn to_cow(&self) -> Cow<'static, str> {
match self {
ProtocolPolicyValue::Id(id) => id.to_string().into(),
}
}
}
impl JmapObject for ProtocolPolicy {
type Property = ProtocolPolicyProperty;
type Element = ProtocolPolicyValue;
type Id = Id;
type Filter = ();
type Comparator = ();
type GetArguments = ();
type SetArguments<'de> = ();
type QueryArguments = ();
type CopyArguments = ();
type ParseArguments = ();
const ID_PROPERTY: Self::Property = ProtocolPolicyProperty::Id;
}
impl From<Id> for ProtocolPolicyValue {
fn from(id: Id) -> Self {
ProtocolPolicyValue::Id(id)
}
}
impl JmapObjectId for ProtocolPolicyValue {
fn as_id(&self) -> Option<Id> {
match self {
ProtocolPolicyValue::Id(id) => Some(*id),
}
}
fn as_any_id(&self) -> Option<AnyId> {
match self {
ProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)),
}
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, new_id: AnyId) -> bool {
if let AnyId::Id(id) = new_id {
*self = ProtocolPolicyValue::Id(id);
true
} else {
false
}
}
}
impl JmapObjectId for ProtocolPolicyProperty {
fn as_id(&self) -> Option<Id> {
None
}
fn as_any_id(&self) -> Option<AnyId> {
None
}
fn as_id_ref(&self) -> Option<&str> {
None
}
fn try_set_id(&mut self, _: AnyId) -> bool {
false
}
}