Files
inbuxa-server/crates/jmap-proto/src/object/inbuxa_protocol_policy.rs
T
jcoffey-dev 3f40b36032 The switch knows who still uses legacy mail apps (LP-15, server)
The impact panel's data. Every successful sign-in over IMAP, POP3,
ManageSieve or SMTP AUTH records, per account and per protocol, one
timestamp -- nothing else: no address, no IP, no client. It is written at
most once an hour per account and protocol, so a mail app polling every
minute costs a read per sign-in and a write an hour. A record that can't be
written is logged and the sign-in goes ahead.

Both switches serve it as a read-only property, recentLegacyUse, as
wouldClose serves the confirmation: a list of {accountId, name, protocol,
lastUsedAt} for sign-ins in the last 30 days, most recent first.
inbuxa:ProtocolPolicy lists every account; inbuxa:TenantProtocolPolicy
lists only its tenant's own (MT-1). Accounts since deleted are left out. It
is computed only when the property is asked for.

The recording sits where the tenant check already runs once the account is
known, which becomes admit_legacy_session: refuse if the account's tenant
has legacy protocols off, otherwise record. A refused sign-in is never
recorded.

The spec leaves the interface to the implementation; a property on each
switch keeps the panel's data behind the same permission as the switch
itself, with no new object.

Unit tests hold the 30-day window to acceptance test 11 (three days ago
listed, forty not), the hourly throttle and the keys. The e2e proves on a
running server that the admin's IMAP and submission sign-ins are listed
with their time, that a second sign-in within the hour isn't written again,
and that a tenant's list holds its own user and nobody outside the tenant.
All 70 checks pass.
2026-09-21 11:45:05 -07:00

202 lines
5.8 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 switch: `enabled` or `disabled`.
LegacyProtocols,
/// 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::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"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
}
}