From cd99037ca4a62990c9c3d512ac4f5a808b732bd9 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 11:30:34 -0700 Subject: [PATCH] The session says whether legacy protocols are off for the account The urn:inbuxa:jmap capability on the signed-in principal's own account gains legacyProtocols: "enabled" or "disabled", the stricter of the server's switch and the account's tenant's (legacy-protocols spec, Interfaces). It is what the webmail needs to tell someone why their phone's mail app won't connect (LP-19), and it closes acceptance test 13. contract.md's C-1 gains the line. It is an optional field added, which C-3 says doesn't bump the contract version. tests/e2e/legacy_protocols.py reads it back from the session on a running server: enabled for the tenant's user while both switches are on, disabled once its tenant turns legacy protocols off while an account outside the tenant still reads enabled, disabled for everyone while the server switch is off, and enabled again at the end. All 67 checks pass. --- crates/common/src/network/legacy.rs | 17 +++++++++++++++++ crates/jmap-proto/src/request/capability.rs | 5 +++++ crates/jmap/src/api/session.rs | 11 ++++++++++- docs/spec/contract.md | 6 ++++++ tests/e2e/legacy_protocols.py | 21 ++++++++++++++++++++- 5 files changed, 58 insertions(+), 2 deletions(-) diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index d3e17b8..8992b5a 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -403,6 +403,23 @@ impl Server { Ok(()) } + /// Whether legacy protocols are off for this account: the stricter of the + /// server's switch and its tenant's. What the JMAP session tells the + /// account's apps (legacy-protocols spec, Interfaces), so the webmail can + /// say why a mail app won't connect (LP-19). + pub async fn legacy_protocols_off_for_account( + &self, + access_token: &AccessToken, + ) -> trc::Result { + if self.protocol_policy().await?.legacy_protocols.is_disabled() { + return Ok(true); + } + match access_token.tenant_id() { + Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await, + None => Ok(false), + } + } + /// Whether a tenant has turned legacy protocols off for itself (LP-10). pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result { Ok( diff --git a/crates/jmap-proto/src/request/capability.rs b/crates/jmap-proto/src/request/capability.rs index 7096b19..d9779d8 100644 --- a/crates/jmap-proto/src/request/capability.rs +++ b/crates/jmap-proto/src/request/capability.rs @@ -142,6 +142,11 @@ pub struct InbuxaAccountCapabilities { /// The logo that applies to the principal (MT-22): a URL or a data URL. #[serde(rename(serialize = "logo"))] pub logo: Option, + /// Whether legacy mail protocols are `enabled` or `disabled` for the + /// principal: the stricter of the server's switch and its tenant's + /// (legacy-protocols spec, Interfaces; LP-19). + #[serde(rename(serialize = "legacyProtocols"))] + pub legacy_protocols: &'static str, } #[derive(Debug, Clone, serde::Serialize)] diff --git a/crates/jmap/src/api/session.rs b/crates/jmap/src/api/session.rs index 95f456a..7df9bc4 100644 --- a/crates/jmap/src/api/session.rs +++ b/crates/jmap/src/api/session.rs @@ -66,9 +66,18 @@ impl SessionHandler for Server { Capability::Inbuxa, Capabilities::Empty(EmptyCapabilities::default()), ); + // inbuxa: legacy-protocols, Interfaces: whichever switch is stricter + let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? { + "disabled" + } else { + "enabled" + }; account.account_capabilities.append( Capability::Inbuxa, - Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }), + Capabilities::Inbuxa(InbuxaAccountCapabilities { + logo, + legacy_protocols, + }), ); // inbuxa: Fastmail's Masked Email API, for accounts that may hold masks if access_token.has_permission(Permission::SysMaskedEmailGet) { diff --git a/docs/spec/contract.md b/docs/spec/contract.md index 6fca363..b911cc6 100644 --- a/docs/spec/contract.md +++ b/docs/spec/contract.md @@ -68,6 +68,12 @@ Each has an ID, and tests name the IDs they check. In `accountCapabilities`, the signed-in principal's own account carries `urn:inbuxa:jmap` with `logo`: the logo that applies to it (multi-tenancy MT-22), a string (URL or data URL) or `null`. Added 2026-09-18. + + It also carries `legacyProtocols`: `enabled` or `disabled`, whether IMAP, + POP3, ManageSieve and SMTP submission are off for the principal -- the + stricter of the server's switch and its tenant's (legacy-protocols spec, + Interfaces). A front end uses it to say why a mail app can't connect + (LP-19). Added 2026-09-21. - **C-2.** Each front end states the contract versions it supports and checks `contract` after signing in. Outside its range it stops, with a message naming both versions. For ihasmail-inbuxa this replaces public ihasmail's diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index 26ab77e..00a8c01 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -29,7 +29,8 @@ address or made-up, right password or wrong -- in the organization's words, leaves every other domain alone, and stops client configuration offering legacy servers for those domains. It reaches only its own tenant's switch, and can't turn it back on while the server has legacy protocols off -(acceptance tests 6 to 10, 14). +(acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each +account which way its switches point (test 13). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -289,6 +290,9 @@ def tenant_checks(admin, admin_pw, account): tset = lambda value: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set", {"accountId": tacct, "update": {t: {"legacyProtocols": value}}}) + check(session_flag(tu, user_pw) == "enabled", + "the session says enabled for the tenant's user while both switches are on (test 13)") + # Before: the tenant's user signs in, and its domain is offered IMAP. check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), "a tenant's user signs in over IMAP with the tenant's switch on") @@ -313,6 +317,11 @@ def tenant_checks(admin, admin_pw, account): 'value = "disabled"'), "and it is an event, scope tenant (LP-14, test 14)") + check(session_flag(tu, user_pw) == "disabled", + "the session says disabled for the tenant's user once its tenant turns it off (test 13)") + check(session_flag(admin, admin_pw) == "enabled", + "and still enabled for an account outside the tenant (test 13)") + # Refused on the tenant's domain, every way in the same words (tests 6-8). imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. " "This mail app can't sign in.") @@ -343,6 +352,8 @@ def tenant_checks(admin, admin_pw, account): # Server off means off for everyone: the tenant can't turn it back on (test 9). one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", {"accountId": account, "update": {"singleton": {"legacyProtocols": "disabled"}}}) + check(session_flag(admin, admin_pw) == "disabled", + "with the server off, the session says disabled for everyone (test 13)") res = tset("enabled") refused = (res[1].get("notUpdated") or {}).get(t) or {} check(refused.get("type") == "forbidden" @@ -357,6 +368,14 @@ def tenant_checks(admin, admin_pw, account): check(t in (res[1].get("updated") or {}), "with the server on, the tenant turns them back on") check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"), "and its user signs in over IMAP again") + check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)") + + +def session_flag(user, password): + """legacyProtocols from the account's urn:inbuxa:jmap capability.""" + sess = session(user, password) + acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0] + return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols") def events_matching(name, *parts):