Author SHA1 Message Date
jcoffey-dev d2f41bce26 Merge branch 'feat/tenant-legacy-switch' into feat/session-legacy-flag 2026-09-21 13:48:31 -07:00
jcoffey-dev cd99037ca4 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.
2026-09-21 11:30:34 -07:00
5 changed files with 58 additions and 2 deletions
+17
View File
@@ -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<bool> {
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<bool> {
Ok(
@@ -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<String>,
/// 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)]
+10 -1
View File
@@ -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) {
+6
View File
@@ -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
+20 -1
View File
@@ -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):