Say which legacy protocols are off when only some are #28

Merged
jcoffey-dev merged 1 commits from feature/per-protocol-legacy-note into main 2026-09-28 06:27:12 +00:00
17 changed files with 135 additions and 10 deletions

No files matched your search

@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { INBUXA_CAP, legacyProtocolsPartlyOff } from "../client";
import { parseTenantLegacy } from "@/lib/admin/adminLegacyProtocols";
import type { JmapSession } from "../types";
/**
* INBUXA can switch IMAP, POP3 and ManageSieve off one at a time. The session
* lists what is still allowed (`legacyAllowed`); the webmail names what isn't,
* only while some but not all are off, and never from a server that doesn't
* say.
*/
describe("legacyProtocolsPartlyOff", () => {
const session = (cap: Record<string, unknown> | undefined) =>
({
accounts: { a: { name: "[email protected]", accountCapabilities: cap ? { [INBUXA_CAP]: cap } : {} } },
}) as unknown as JmapSession;
it("names what is off when only some are", () => {
const s = session({ legacyProtocols: "enabled", legacyAllowed: ["imap", "manageSieve", "submission"] });
expect(legacyProtocolsPartlyOff(s, "a")).toEqual(["POP3"]);
const two = session({ legacyProtocols: "enabled", legacyAllowed: ["pop3", "submission"] });
expect(legacyProtocolsPartlyOff(two, "a")).toEqual(["IMAP", "ManageSieve"]);
});
it("is empty with none off, all off, or an older server", () => {
const all = ["imap", "pop3", "manageSieve", "submission"];
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled", legacyAllowed: all }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "disabled", legacyAllowed: [] }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(session({ legacyProtocols: "enabled" }), "a")).toEqual([]);
expect(legacyProtocolsPartlyOff(null, "a")).toEqual([]);
});
});
describe("parseTenantLegacy", () => {
it("reads a tenant with only some off, and an older server's one switch", () => {
expect(parseTenantLegacy({ legacyProtocols: "enabled", pop3: "disabled" }).partlyOff).toEqual(["POP3"]);
const all = parseTenantLegacy({ legacyProtocols: "disabled", imap: "disabled", pop3: "disabled" });
expect(all.off).toBe(true);
expect(all.partlyOff).toEqual([]);
expect(parseTenantLegacy({ legacyProtocols: "enabled" }).partlyOff).toEqual([]);
});
});
+18
View File
@@ -37,6 +37,24 @@ export function legacyProtocolsOff(session: JmapSession | null, accountId: Id |
return cap?.legacyProtocols === "disabled";
}
/** The protocols the server can switch off one at a time, as it names them. */
const SWITCHED = ["imap", "pop3", "manageSieve"] as const;
const PROTOCOL_NAMES: Record<string, string> = { imap: "IMAP", pop3: "POP3", manageSieve: "ManageSieve" };
/**
* Which of IMAP, POP3 and ManageSieve are off for this account, by name, when
* only some are (INBUXA legacy-protocols, one switch per protocol). Empty when
* none are, when all are (see `legacyProtocolsOff`), and from a server that
* doesn't say which (`legacyAllowed`).
*/
export function legacyProtocolsPartlyOff(session: JmapSession | null, accountId: Id | null): string[] {
if (!session || !accountId || legacyProtocolsOff(session, accountId)) return [];
const cap = session.accounts[accountId]?.accountCapabilities?.[INBUXA_CAP] as { legacyAllowed?: unknown } | undefined;
if (!Array.isArray(cap?.legacyAllowed)) return [];
const allowed = cap.legacyAllowed;
return SWITCHED.filter((p) => !allowed.includes(p)).map((p) => PROTOCOL_NAMES[p] ?? p);
}
export class JmapMethodError extends Error {
constructor(
public readonly method: string,
@@ -3,8 +3,8 @@ import { CONFIRM_PHRASE, impactEntries, parseTenantLegacy, phraseMatches } from
describe("a tenant's legacy mail protocols switch, as the server sends it", () => {
it("reads the switch, and tells an older server from nobody", () => {
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, recent: [] });
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, recent: null });
expect(parseTenantLegacy({ legacyProtocols: "disabled", recentLegacyUse: [] })).toEqual({ off: true, partlyOff: [], recent: [] });
expect(parseTenantLegacy({ legacyProtocols: "enabled" })).toEqual({ off: false, partlyOff: [], recent: null });
});
it("puts each account on the panel once, with every protocol and its latest use", () => {
+9 -1
View File
@@ -34,7 +34,10 @@ export interface RecentUse {
}
export interface TenantLegacy {
/** All of IMAP, POP3 and ManageSieve off: the kill-all's state. */
off: boolean;
/** When only some are off, which, by name; set one at a time in the console. */
partlyOff: string[];
/** Null from a server too old to say who uses legacy apps -- not the same as nobody. */
recent: RecentUse[] | null;
}
@@ -49,8 +52,13 @@ function parseRecent(raw: unknown): RecentUse[] | null {
});
}
const PROTOCOL_NAMES: [string, string][] = [["imap", "IMAP"], ["pop3", "POP3"], ["manageSieve", "ManageSieve"]];
export function parseTenantLegacy(raw: Record<string, unknown>): TenantLegacy {
return { off: raw.legacyProtocols === "disabled", recent: parseRecent(raw.recentLegacyUse) };
const off = raw.legacyProtocols === "disabled";
// An older server sends only legacyProtocols, which stands for all three.
const partlyOff = off ? [] : PROTOCOL_NAMES.filter(([key]) => raw[key] === "disabled").map(([, name]) => name);
return { off, partlyOff, recent: parseRecent(raw.recentLegacyUse) };
}
/** The tenant's switch, or null where the server has none (not INBUXA, or too old). */
+4
View File
@@ -1754,6 +1754,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Einschätzung des Sprachmodells",
"One of several signals the spam filter weighed": "Eines von mehreren Signalen, die der Spamfilter berücksichtigt hat",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ihre Organisation hat {protocols} für Mail-Apps ausgeschaltet. Mail-Apps, die das nutzen, können sich nicht mit diesem Konto verbinden; andere schon.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Einige ältere Mailprotokolle sind für Ihre Organisation ausgeschaltet: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Einige sind für {tenant} aus: {protocols}. In der Verwaltungskonsole lassen sie sich einzeln ein- und ausschalten.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Löschen, Sperren und rechtliche Aufbewahrungspflichten werden in der Verwaltungskonsole erledigt, die den Grund festhält und bewahrt, was eine Aufbewahrungspflicht umfasst.",
"Open in the console": "In der Konsole öffnen",
+4
View File
@@ -1727,6 +1727,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinión del modelo de lenguaje",
"One of several signals the spam filter weighed": "Una de varias señales que el filtro de spam ha tenido en cuenta",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Su organización ha desactivado {protocols} para las aplicaciones de correo. Las que lo usan no pueden conectarse a esta cuenta; las demás sí.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Algunos protocolos de correo heredados están desactivados para su organización: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Algunos están desactivados para {tenant}: {protocols}. Actívelos o desactívelos uno a uno en la consola de administración.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Eliminar, bloquear y las retenciones legales se gestionan en la consola de administración, que registra el motivo y conserva lo que cubre una retención.",
"Open in the console": "Abrir en la consola",
+4
View File
@@ -1732,6 +1732,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Avis du modèle de langage",
"One of several signals the spam filter weighed": "Un signal parmi d'autres pris en compte par le filtre antispam",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Votre organisation a désactivé {protocols} pour les applications de messagerie. Celles qui l’utilisent ne peuvent pas se connecter à ce compte ; les autres le peuvent toujours.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Certains protocoles de messagerie historiques sont désactivés pour votre organisation : {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Certains sont désactivés pour {tenant} : {protocols}. Activez-les ou désactivez-les un par un dans la console d’administration.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "La suppression, le verrouillage et les conservations légales se font dans la console d’administration, qui enregistre le motif et conserve ce qu’une conservation couvre.",
"Open in the console": "Ouvrir dans la console",
+4
View File
@@ -1735,6 +1735,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "言語モデルの見解",
"One of several signals the spam filter weighed": "迷惑メールフィルターが考慮した複数の判断材料のひとつ",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "組織ではメールアプリ向けの {protocols} がオフになっています。これを使うメールアプリはこのアカウントに接続できませんが、ほかのアプリは接続できます。",
"Some legacy mail protocols are off for your organization: {protocols}.": "組織では一部の従来のメールプロトコルがオフになっています: {protocols}。",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} では一部がオフです: {protocols}。管理コンソールで 1 つずつ切り替えてください。",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "削除、ロック、訴訟ホールドは管理コンソールで行います。コンソールでは理由が記録され、ホールドの対象は保持されます。",
"Open in the console": "コンソールで開く",
+4
View File
@@ -1727,6 +1727,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Oordeel van het taalmodel",
"One of several signals the spam filter weighed": "Een van meerdere signalen die het spamfilter heeft meegewogen",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Uw organisatie heeft {protocols} uitgeschakeld voor mail-apps. Mail-apps die dat gebruiken, kunnen geen verbinding maken met dit account; andere nog wel.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Sommige verouderde mailprotocollen zijn uitgeschakeld voor uw organisatie: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Sommige zijn uit voor {tenant}: {protocols}. In de beheerconsole zijn ze één voor één aan en uit te zetten.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Verwijderen, vergrendelen en juridische bewaarplichten gebeuren in de beheerconsole, die de reden vastlegt en bewaart wat onder een bewaarplicht valt.",
"Open in the console": "Openen in de console",
+4
View File
@@ -1730,6 +1730,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Opinião do modelo de linguagem",
"One of several signals the spam filter weighed": "Um dos vários sinais considerados pelo filtro de spam",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Sua organização desativou {protocols} para aplicativos de e-mail. Os que usam isso não conseguem se conectar a esta conta; os outros ainda conseguem.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Alguns protocolos de e-mail legados estão desativados para sua organização: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Alguns estão desativados para {tenant}: {protocols}. Ative ou desative um de cada vez no console de administração.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Excluir, bloquear e retenções legais são feitos no console de administração, que registra o motivo e preserva o que uma retenção abrange.",
"Open in the console": "Abrir no console",
+4
View File
@@ -1729,6 +1729,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Мнение языковой модели",
"One of several signals the spam filter weighed": "Один из нескольких признаков, которые учёл спам-фильтр",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша организация отключила {protocols} для почтовых приложений. Приложения, которые его используют, не могут подключиться к этому аккаунту; остальные могут.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Некоторые устаревшие почтовые протоколы отключены для вашей организации: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} отключены некоторые: {protocols}. Включайте и отключайте их по одному в консоли администрирования.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Удаление, блокировка и юридическое удержание выполняются в консоли администрирования: она записывает причину и сохраняет всё, что охватывает удержание.",
"Open in the console": "Открыть в консоли",
+4
View File
@@ -1723,6 +1723,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "Думка мовної моделі",
"One of several signals the spam filter weighed": "Одна з кількох ознак, які врахував спам-фільтр",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "Ваша організація вимкнула {protocols} для поштових програм. Програми, які його використовують, не можуть підключитися до цього облікового запису; інші можуть.",
"Some legacy mail protocols are off for your organization: {protocols}.": "Деякі застарілі поштові протоколи вимкнено для вашої організації: {protocols}.",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "Для {tenant} вимкнено деякі: {protocols}. Вмикайте й вимикайте їх по одному в консолі адміністрування.",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "Видалення, блокування та юридичне утримання виконуються в консолі адміністрування: вона записує причину й зберігає все, що охоплює утримання.",
"Open in the console": "Відкрити в консолі",
+4
View File
@@ -1734,6 +1734,10 @@ export const catalog: Catalog = {
// ── Spam filter: the language model's opinion (inbuxa) ──────────
"Language model's opinion": "语言模型的判断",
"One of several signals the spam filter weighed": "垃圾邮件过滤考虑的多个信号之一",
// inbuxa: legacy mail protocols, one switch per protocol
"Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.": "您的组织已为邮件应用关闭 {protocols}。使用它的邮件应用无法连接到此账户;其他应用仍可连接。",
"Some legacy mail protocols are off for your organization: {protocols}.": "您的组织已关闭部分传统邮件协议:{protocols}。",
"Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.": "{tenant} 已关闭部分协议:{protocols}。请在管理控制台中逐个开启或关闭。",
// inbuxa: deleting a person is the console's (audit-hold-lock spec)
"Deleting, locking and legal holds are done in the administration console, which records why and keeps what a hold covers.": "删除、锁定和法律保留均在管理控制台中进行,控制台会记录原因,并保留保留范围内的内容。",
"Open in the console": "在控制台中打开",
+9 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { Link } from "wouter";
import { ArrowDownToLine, ArrowUpFromLine, ExternalLink, Globe, Hourglass, LayoutDashboard, MemoryStick, RefreshCw, ShieldCheck, Users } from "lucide-react";
import { legacyProtocolsOff } from "@/jmap/client";
import { legacyProtocolsOff, legacyProtocolsPartlyOff } from "@/jmap/client";
import { useAppName } from "@/lib/brand";
import { adminSections, dashboardCards, type DashboardCard } from "@/lib/admin/adminAccess";
import { balancedColumns, countObjects, DASHBOARD_WINDOW_MS, isRefused, loadMetrics, summarizeMetrics, type MessageStats } from "@/lib/admin/adminDashboard";
@@ -38,6 +38,7 @@ export function AdminDashboard() {
const perms = usePermissions();
const adminUrl = useSession((s) => s.session?.ihasmail?.server?.adminUrl ?? null);
const legacyOff = useSession((s) => legacyProtocolsOff(s.session, s.accountId));
const legacyPartlyOff = useSession((s) => legacyProtocolsPartlyOff(s.session, s.accountId).join(", "));
const app = useAppName();
const cards = dashboardCards(perms);
const sections = adminSections(perms);
@@ -113,6 +114,13 @@ export function AdminDashboard() {
<span>{t("Legacy mail protocols are off for your organization. Only {app} and JMAP apps can sign in.", { app })}</span>
</p>
)}
{legacyPartlyOff && (
// INBUXA: one switch per protocol, some of them off
<p className="admin-notice">
<ShieldCheck size={16} />
<span>{t("Some legacy mail protocols are off for your organization: {protocols}.", { protocols: legacyPartlyOff })}</span>
</p>
)}
{shown.length ? (
<div className="admin-cards-wrap">
<div className="admin-cards">
@@ -68,10 +68,15 @@ export function TenantLegacyProtocols({ tenantId, tenantName }: { tenantId: stri
<p>
{state.off
? t("Off for {tenant}. Only {app} and JMAP apps can sign in to its domains.", { tenant: tenantName, app })
: t("On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", { tenant: tenantName })}
: state.partlyOff.length > 0
? t("Some are off for {tenant}: {protocols}. Switch them one at a time in the administration console.", {
tenant: tenantName,
protocols: state.partlyOff.join(", "),
})
: t("On for {tenant}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.", { tenant: tenantName })}
</p>
{canChange && state.off && (
{canChange && (state.off || state.partlyOff.length > 0) && (
<button className="btn" disabled={busy} onClick={() => void turn(false)}>
{t("Turn legacy protocols back on")}
</button>
@@ -62,7 +62,7 @@ describe("a tenant's legacy mail protocols switch", () => {
});
it("shows who would notice and what it means, then asks for the exact phrase", async () => {
api.state = { off: false, recent: [{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: Date.now() - 2 * 86400_000 }] };
api.state = { off: false, partlyOff: [], recent: [{ accountId: "a", name: "[email protected]", protocol: "imap", lastUsedAt: Date.now() - 2 * 86400_000 }] };
signIn(["sysDomainGet", "sysDomainUpdate"]);
await render();
await act(async () => button(host, "Turn off legacy protocols…")!.click());
@@ -84,7 +84,7 @@ describe("a tenant's legacy mail protocols switch", () => {
});
it("turns it back on with one click", async () => {
api.state = { off: true, recent: [] };
api.state = { off: true, partlyOff: [], recent: [] };
signIn(["sysDomainGet", "sysDomainUpdate"]);
await render();
await act(async () => button(host, "Turn legacy protocols back on")!.click());
@@ -92,7 +92,7 @@ describe("a tenant's legacy mail protocols switch", () => {
});
it("shows the state but no switch to someone who can't change domains", async () => {
api.state = { off: true, recent: null };
api.state = { off: true, partlyOff: [], recent: null };
signIn(["sysDomainGet"]);
await render();
expect(host.textContent).toContain("Off for Acme Corp");
+9 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useAppName } from "@/lib/brand";
import { Copy, KeyRound, ShieldCheck, Smartphone } from "lucide-react";
import { apiFetch, ApiError, legacyProtocolsOff } from "@/jmap/client";
import { apiFetch, ApiError, legacyProtocolsOff, legacyProtocolsPartlyOff } from "@/jmap/client";
import { useSession } from "@/store/session";
import { formatFullDate } from "@/lib/format";
import { toast } from "@/ui/toast";
@@ -89,6 +89,14 @@ export function SecuritySettings() {
{t("Your organization allows only {app} and JMAP apps, so phone and desktop mail apps can't connect to this account.", { app: appName })}
</p>
)}
{legacyProtocolsPartlyOff(session, accountId).length > 0 && (
// INBUXA: one switch per protocol; mail apps using the rest still work
<p className="info-box mb-16">
{t("Your organization has turned off {protocols} for mail apps. Mail apps that use it can't connect to this account; others still can.", {
protocols: legacyProtocolsPartlyOff(session, accountId).join(", "),
})}
</p>
)}
{unsupported ? (
<p className="hint">{t("App passwords are managed by your mail administrator.")}</p>
) : (