);
diff --git a/src/features/sharing/SharingSwitches.tsx b/src/features/sharing/SharingSwitches.tsx
new file mode 100644
index 0000000..160f5f6
--- /dev/null
+++ b/src/features/sharing/SharingSwitches.tsx
@@ -0,0 +1,136 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: who may share mail (multi-account spec, MA-C, MA-13), on Settings ›
+ * Sharing for the server and on a tenant's page for that tenant.
+ *
+ * Off refuses new mail shares and stops honoring the ones already made; they
+ * stay stored and come back when it's on again. Groups and shared mailboxes
+ * aren't people's shares and are never affected. A tenant can only be
+ * stricter: a switch the server has off shows off and can't be turned on.
+ */
+
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Share2 } from 'lucide-react';
+import { Switch } from '@/components/ui/switch';
+import { useAccountStore } from '@/stores/accountStore';
+import { toast } from '@/hooks/use-toast';
+import {
+ effectiveSwitch,
+ fetchSharingPolicies,
+ SERVER,
+ setSharingSwitch,
+ SharingPolicyUnavailable,
+ type SharingKey,
+ type SharingPolicy,
+} from './sharingPolicy';
+
+export function SharingSwitches({ tenantId }: { tenantId?: string }) {
+ const { t } = useTranslation();
+ const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get'));
+ const canUpdateTenant = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update'));
+ const canUpdateServer = useAccountStore((s) => s.hasPermission('sysSharingUpdate'));
+ const [policies, setPolicies] = useState<{ server: SharingPolicy; tenant?: SharingPolicy } | null>(null);
+ const [busy, setBusy] = useState(null);
+
+ const load = useCallback(
+ (signal?: AbortSignal) =>
+ fetchSharingPolicies(tenantId, signal)
+ .then((p) => {
+ if (!signal?.aborted) setPolicies(p);
+ })
+ .catch((e: unknown) => {
+ // An older server has no switches: show nothing rather than an error.
+ if (!signal?.aborted && !(e instanceof SharingPolicyUnavailable)) console.error(e);
+ }),
+ [tenantId],
+ );
+
+ useEffect(() => {
+ if (!canGet) return;
+ const controller = new AbortController();
+ void load(controller.signal);
+ return () => controller.abort();
+ }, [canGet, load]);
+
+ if (!policies || (tenantId && !policies.tenant)) return null;
+ const canUpdate = tenantId ? canUpdateTenant : canUpdateServer;
+
+ const turn = async (key: SharingKey, on: boolean) => {
+ setBusy(key);
+ try {
+ // On is the default: clearing the switch lets the server's apply
+ await setSharingSwitch(tenantId ?? SERVER, key, on ? null : 'disabled');
+ await load();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: t('sharingPolicy.failed', 'The switch did not change'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(null);
+ }
+ };
+
+ const rows: { key: SharingKey; label: string; hint: string }[] = [
+ {
+ key: 'mailSharing',
+ label: t('sharingPolicy.mailSharing', 'People may share their mail folders'),
+ hint: t(
+ 'sharingPolicy.mailSharingHint',
+ 'Off: nobody can share a mail folder with someone else, and what was shared stops working until this is on again. Groups and shared mailboxes keep working.',
+ ),
+ },
+ {
+ key: 'addAccounts',
+ label: t('sharingPolicy.addAccounts', 'People may add other accounts to the webmail'),
+ hint: t(
+ 'sharingPolicy.addAccountsHint',
+ 'Off: the webmail doesn’t offer to sign in to a second account beside the first.',
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
{t('sharingPolicy.title', 'Who may share')}
+
+ {tenantId
+ ? t('sharingPolicy.tenantScope', 'For everyone in this organization. It can be stricter than the server, never looser.')
+ : t('sharingPolicy.serverScope', 'For everyone on this server. Each organization can be stricter on its own page.')}
+
+
+
+ {rows.map(({ key, label, hint }) => {
+ const serverOff = policies.server[key] === 'disabled';
+ const on = (tenantId ? effectiveSwitch(policies.server, policies.tenant, key) : policies.server[key]) === 'enabled';
+ const locked = !!tenantId && serverOff;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/features/sharing/sharingPolicy.test.ts b/src/features/sharing/sharingPolicy.test.ts
new file mode 100644
index 0000000..6fc92aa
--- /dev/null
+++ b/src/features/sharing/sharingPolicy.test.ts
@@ -0,0 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, expect, it } from 'vitest';
+import { effectiveSwitch, type SharingPolicy } from './sharingPolicy';
+
+const p = (mailSharing: 'enabled' | 'disabled', addAccounts: 'enabled' | 'disabled' = 'enabled'): SharingPolicy => ({
+ id: 'x',
+ mailSharing,
+ addAccounts,
+});
+
+describe('effectiveSwitch (MA-C)', () => {
+ it('is off when either level has it off: a tenant is only ever stricter', () => {
+ expect(effectiveSwitch(p('enabled'), undefined, 'mailSharing')).toBe('enabled');
+ expect(effectiveSwitch(p('enabled'), p('disabled'), 'mailSharing')).toBe('disabled');
+ expect(effectiveSwitch(p('disabled'), p('enabled'), 'mailSharing')).toBe('disabled');
+ expect(effectiveSwitch(p('enabled', 'disabled'), p('enabled'), 'addAccounts')).toBe('disabled');
+ });
+});
diff --git a/src/features/sharing/sharingPolicy.ts b/src/features/sharing/sharingPolicy.ts
new file mode 100644
index 0000000..ce8c3a4
--- /dev/null
+++ b/src/features/sharing/sharingPolicy.ts
@@ -0,0 +1,82 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * INBUXA: `inbuxa:SharingPolicy` over JMAP (multi-account spec, MA-C):
+ * whether people may share their own mail folders, and add other accounts to
+ * the webmail. The server's policy has the id `singleton`; each tenant's has
+ * the tenant's id, and can only be stricter than the server's.
+ */
+
+import { getAccountId, jmapRequest } from '@/services/jmap/client';
+
+const INBUXA_CAPABILITY = 'urn:inbuxa:jmap';
+const OBJECT = 'inbuxa:SharingPolicy';
+export const SERVER = 'singleton';
+
+export type Switch = 'enabled' | 'disabled';
+
+export interface SharingPolicy {
+ id: string;
+ mailSharing: Switch;
+ addAccounts: Switch;
+}
+
+export type SharingKey = 'mailSharing' | 'addAccounts';
+
+/** The server doesn't have the switches (an older server). */
+export class SharingPolicyUnavailable extends Error {}
+
+function asSwitch(value: unknown): Switch {
+ return value === 'disabled' ? 'disabled' : 'enabled';
+}
+
+/** The server's policy and, with `tenantId`, that tenant's too. */
+export async function fetchSharingPolicies(
+ tenantId?: string,
+ signal?: AbortSignal,
+): Promise<{ server: SharingPolicy; tenant?: SharingPolicy }> {
+ const ids = tenantId ? [SERVER, tenantId] : [SERVER];
+ const responses = await jmapRequest(
+ [[`${OBJECT}/get`, { accountId: getAccountId('x:Account'), ids }, '0']],
+ signal,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/get`) {
+ const type = (result as { type?: string } | undefined)?.type;
+ if (type === 'unknownMethod' || type === 'unknownCapability') throw new SharingPolicyUnavailable(type);
+ throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed');
+ }
+ const list = ((result as { list?: Record[] }).list ?? []).map(
+ (raw): SharingPolicy => ({
+ id: String(raw.id ?? ''),
+ mailSharing: asSwitch(raw.mailSharing),
+ addAccounts: asSwitch(raw.addAccounts),
+ }),
+ );
+ const server = list.find((p) => p.id === SERVER);
+ if (!server) throw new SharingPolicyUnavailable('no server policy');
+ return { server, tenant: tenantId ? list.find((p) => p.id === tenantId) : undefined };
+}
+
+/** Turns one switch. `null` puts it back to the default, on as far as the server allows. */
+export async function setSharingSwitch(id: string, key: SharingKey, value: Switch | null): Promise {
+ const responses = await jmapRequest(
+ [[`${OBJECT}/set`, { accountId: getAccountId('x:Account'), update: { [id]: { [key]: value } } }, '0']],
+ undefined,
+ [INBUXA_CAPABILITY],
+ );
+ const [name, result] = responses[0] ?? [];
+ if (name !== `${OBJECT}/set`) throw new Error('Request failed');
+ const failed = (result as { notUpdated?: Record }).notUpdated?.[id];
+ if (failed) throw new Error(failed.description ?? failed.type ?? 'Not saved');
+}
+
+/** What applies inside a tenant: off if either level has it off. */
+export function effectiveSwitch(server: SharingPolicy, tenant: SharingPolicy | undefined, key: SharingKey): Switch {
+ return server[key] === 'disabled' || tenant?.[key] === 'disabled' ? 'disabled' : 'enabled';
+}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 59bab00..cdbf35a 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1998,5 +1998,16 @@
"editAction": "People…",
"stopAction": "Stop sharing…",
"noPeople": "Nobody has it yet."
+ },
+ "sharingPolicy": {
+ "failed": "The switch did not change",
+ "mailSharing": "People may share their mail folders",
+ "mailSharingHint": "Off: nobody can share a mail folder with someone else, and what was shared stops working until this is on again. Groups and shared mailboxes keep working.",
+ "addAccounts": "People may add other accounts to the webmail",
+ "addAccountsHint": "Off: the webmail doesn’t offer to sign in to a second account beside the first.",
+ "title": "Who may share",
+ "tenantScope": "For everyone in this organization. It can be stricter than the server, never looser.",
+ "serverScope": "For everyone on this server. Each organization can be stricter on its own page.",
+ "serverOff": "Off for the whole server."
}
}