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):