feat: manage shared/group account settings from Accounts page

This commit is contained in:
Linus Rath
2026-06-14 17:05:29 +02:00
parent 848ed9774d
commit 4c6c1aab60
16 changed files with 577 additions and 103 deletions
+26 -11
View File
@@ -1,5 +1,5 @@
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult } from '@/lib/jmap/types';
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, VacationResponse, Calendar, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, ScheduledEmail, SendEmailResult, SharedAccount } from '@/lib/jmap/types';
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
@@ -118,7 +118,7 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Mailboxes ─────────────────────────────────────────────────
async getMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
@@ -603,9 +603,9 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Vacation ──────────────────────────────────────────────────
async getVacationResponse(): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async getVacationResponse(_accountId?: string): Promise<VacationResponse> { return { ...this.data.vacationResponse }; }
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
async setVacationResponse(updates: Partial<VacationResponse>, _accountId?: string): Promise<void> {
Object.assign(this.data.vacationResponse, updates);
}
@@ -849,19 +849,34 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Sieve / Filters ──────────────────────────────────────────
getSharedAccounts(): SharedAccount[] {
return [{
id: 'demo-account',
name: 'Demo',
isPrimary: true,
capabilities: { mail: true, sieve: true, calendars: true, contacts: true, filenode: true },
}];
}
getSieveAccountId(): string { return 'demo-account'; }
getSieveCapabilities(): SieveCapabilities | null {
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
return this.getSharedAccounts()
.filter((a) => a.isPrimary || a.capabilities.sieve)
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
}
getSieveCapabilities(_accountId?: string): SieveCapabilities | null {
return { ...this.data.sieveCapabilities };
}
async getSieveScripts(): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScripts(_accountId?: string): Promise<SieveScript[]> { return [...this.data.sieveScripts]; }
async getSieveScriptContent(blobId: string): Promise<string> {
async getSieveScriptContent(blobId: string, _accountId?: string): Promise<string> {
return this.data.sieveContent[blobId] ?? '';
}
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
async createSieveScript(name: string, content: string, activate?: boolean, _accountId?: string): Promise<SieveScript> {
const blobId = generateDemoId('sieve-blob');
const script: SieveScript = { id: generateDemoId('sieve'), name, blobId, isActive: activate ?? false };
this.data.sieveScripts.push(script);
@@ -874,7 +889,7 @@ export class DemoJMAPClient implements IJMAPClient {
return script;
}
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
async updateSieveScript(scriptId: string, content: string, activate?: boolean, _accountId?: string): Promise<void> {
const script = this.data.sieveScripts.find(s => s.id === scriptId);
if (!script) return;
const blobId = generateDemoId('sieve-blob');
@@ -890,11 +905,11 @@ export class DemoJMAPClient implements IJMAPClient {
}
}
async deleteSieveScript(scriptId: string): Promise<void> {
async deleteSieveScript(scriptId: string, _accountId?: string): Promise<void> {
this.data.sieveScripts = this.data.sieveScripts.filter(s => s.id !== scriptId);
}
async validateSieveScript(): Promise<{ isValid: boolean; errors?: string[] }> {
async validateSieveScript(_content?: string, _accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
return { isValid: true };
}
+15 -11
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
/**
@@ -71,7 +71,7 @@ export interface IJMAPClient {
getQuota(): Promise<{ used: number; total: number } | null>;
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(): Promise<Mailbox[]>;
getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
@@ -239,8 +239,8 @@ export interface IJMAPClient {
deleteIdentity(identityId: string): Promise<void>;
// ── Vacation ──────────────────────────────────────────────────
getVacationResponse(): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>): Promise<void>;
getVacationResponse(accountId?: string): Promise<VacationResponse>;
setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void>;
// ── Contacts ──────────────────────────────────────────────────
getContactsAccountId(): string;
@@ -294,15 +294,19 @@ export interface IJMAPClient {
setAddressBookShare(addressBookId: string, principalId: string, rights: AddressBookRights | null, targetAccountId?: string): Promise<void>;
setFileNodeShare(fileNodeId: string, principalId: string, rights: FileNodeRights | null, targetAccountId?: string): Promise<void>;
// ── Accounts (primary + shared/group) ────────────────────────
getSharedAccounts(): SharedAccount[];
// ── Sieve / Filters ──────────────────────────────────────────
getSieveAccountId(): string;
getSieveCapabilities(): SieveCapabilities | null;
getSieveScripts(): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void>;
deleteSieveScript(scriptId: string): Promise<void>;
validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }>;
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[];
getSieveCapabilities(accountId?: string): SieveCapabilities | null;
getSieveScripts(accountId?: string): Promise<SieveScript[]>;
getSieveScriptContent(blobId: string, accountId?: string): Promise<string>;
createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript>;
updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void>;
deleteSieveScript(scriptId: string, accountId?: string): Promise<void>;
validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }>;
// ── Files (WebDAV / FileNode) ─────────────────────────────────
getFilesAccountId(): string;
+106 -45
View File
@@ -1,4 +1,4 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult } from "./types";
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeFilter, FileNodeRights, Principal, PushSubscription, EmailSubmission, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { IJMAPClient } from "./client-interface";
import { toWildcardQuery } from "./search-utils";
@@ -897,16 +897,17 @@ export class JMAPClient implements IJMAPClient {
}
}
async getMailboxes(): Promise<Mailbox[]> {
async getMailboxes(accountId?: string): Promise<Mailbox[]> {
const acctId = accountId || this.accountId;
try {
const response = await this.request([
["Mailbox/get", { accountId: this.accountId }, "0"]
["Mailbox/get", { accountId: acctId }, "0"]
]);
if (response.methodResponses?.[0]?.[0] === "Mailbox/get") {
const rawMailboxes = (response.methodResponses[0][1].list || []) as JMAPMailbox[];
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${this.accountId}`);
debug.log('jmap', `[JMAP Mailbox] getMailboxes returned ${rawMailboxes.length} mailboxes for account ${acctId}`);
// Warn if response might be truncated
const maxObjects = this.getMaxObjectsInGet();
@@ -940,9 +941,9 @@ export class JMAPClient implements IJMAPClient {
unreadThreads: mb.unreadThreads ?? 0,
myRights: mb.myRights || DEFAULT_MAILBOX_RIGHTS,
isSubscribed: mb.isSubscribed ?? true,
accountId: this.accountId,
accountName: this.accounts[this.accountId]?.name || this.username,
isShared: false,
accountId: acctId,
accountName: this.accounts[acctId]?.name || this.username,
isShared: acctId !== this.accountId,
}) as Mailbox);
}
@@ -2082,10 +2083,10 @@ export class JMAPClient implements IJMAPClient {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:mail", "urn:ietf:params:jmap:vacationresponse"];
}
async getVacationResponse(): Promise<VacationResponse> {
async getVacationResponse(accountId?: string): Promise<VacationResponse> {
const response = await this.request([
["VacationResponse/get", {
accountId: this.accountId,
accountId: accountId || this.accountId,
ids: ["singleton"],
}, "0"]
], this.vacationUsing());
@@ -2109,10 +2110,10 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to fetch vacation response: unexpected server response");
}
async setVacationResponse(updates: Partial<VacationResponse>): Promise<void> {
async setVacationResponse(updates: Partial<VacationResponse>, accountId?: string): Promise<void> {
const response = await this.request([
["VacationResponse/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
update: {
"singleton": updates,
},
@@ -3421,18 +3422,79 @@ export class JMAPClient implements IJMAPClient {
return ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:sieve"];
}
getSieveCapabilities(): SieveCapabilities | null {
const sieveAccountId = this.getSieveAccountId();
/**
* Accounts (primary + shared/group) visible in this session, each tagged with
* the capabilities it advertises. Unifies the per-feature enumeration that
* getCalendarCapableAccountIds()/getContactCapableAccountIds()/
* getFilesCapableAccountIds() do: a non-primary account is included when it
* advertises a capability OR is a non-personal (shared/group) account, since
* Stalwart doesn't always advertise capabilities on those even when they
* support the feature. Primary account first; name from the session account
* (primary falls back to the username). Drives the settings "Shared with me"
* list and the scoped-settings tab gating.
*/
getSharedAccounts(): SharedAccount[] {
const primaryId = this.getSieveAccountId();
const toEntry = (id: string, isPrimary: boolean): SharedAccount => {
const account = this.accounts[id];
const caps = account?.accountCapabilities;
const shared = !account?.isPersonal;
return {
id,
name: account?.name || (isPrimary ? (this.username || id) : id),
isPrimary,
capabilities: {
mail: !!caps?.["urn:ietf:params:jmap:mail"] || shared,
sieve: !!caps?.["urn:ietf:params:jmap:sieve"] || shared,
calendars: !!caps?.["urn:ietf:params:jmap:calendars"] || shared,
contacts: !!caps?.["urn:ietf:params:jmap:contacts"] || shared,
filenode: !!caps?.["urn:ietf:params:jmap:filenode"] || shared,
},
};
};
const result = [toEntry(primaryId, true)];
for (const id of Object.keys(this.accounts)) {
if (id === primaryId) continue;
const account = this.accounts[id];
// Mirror the per-feature helpers: include any non-personal account, plus
// accounts that advertise at least one editable capability.
const caps = account.accountCapabilities;
const advertisesAny =
!!caps?.["urn:ietf:params:jmap:mail"] ||
!!caps?.["urn:ietf:params:jmap:sieve"] ||
!!caps?.["urn:ietf:params:jmap:calendars"] ||
!!caps?.["urn:ietf:params:jmap:contacts"] ||
!!caps?.["urn:ietf:params:jmap:filenode"];
if (!account.isPersonal || advertisesAny) {
result.push(toEntry(id, false));
}
}
return result;
}
/**
* Sieve-capable accounts (primary + shared/group), for the filters UI.
* Thin wrapper over getSharedAccounts() filtered to the sieve capability.
*/
getSieveAccounts(): { id: string; name: string; isPrimary: boolean }[] {
return this.getSharedAccounts()
.filter((a) => a.isPrimary || a.capabilities.sieve)
.map(({ id, name, isPrimary }) => ({ id, name, isPrimary }));
}
getSieveCapabilities(accountId?: string): SieveCapabilities | null {
const sieveAccountId = accountId || this.getSieveAccountId();
const accountInfo = this.accounts[sieveAccountId];
if (!accountInfo?.accountCapabilities) return null;
const caps = accountInfo.accountCapabilities["urn:ietf:params:jmap:sieve"];
return (caps as SieveCapabilities) || null;
}
async getSieveScripts(): Promise<SieveScript[]> {
async getSieveScripts(accountId?: string): Promise<SieveScript[]> {
const response = await this.request([
["SieveScript/get", {
accountId: this.getSieveAccountId(),
accountId: accountId || this.getSieveAccountId(),
}, "0"]
], this.sieveUsing());
@@ -3442,21 +3504,26 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Failed to fetch Sieve scripts');
}
async getSieveScriptContent(blobId: string): Promise<string> {
const url = this.getBlobDownloadUrl(blobId, 'script.sieve', 'application/sieve');
async getSieveScriptContent(blobId: string, accountId?: string): Promise<string> {
// Blobs are scoped per account, so a shared/group account's script must be
// downloaded against that owner's accountId, not the primary one.
const url = this.getBlobDownloadUrl(
blobId, 'script.sieve', 'application/sieve', accountId || this.getSieveAccountId(),
);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) throw new Error(`Failed to download script: ${response.status}`);
return response.text();
}
private async uploadSieveBlob(content: string): Promise<string> {
private async uploadSieveBlob(content: string, accountId?: string): Promise<string> {
if (!this.session?.uploadUrl) {
throw new Error('Upload URL not available');
}
const targetAccountId = accountId || this.getSieveAccountId();
const uploadUrl = this.session.uploadUrl.replace(
'{accountId}',
encodeURIComponent(this.getSieveAccountId())
encodeURIComponent(targetAccountId)
);
const response = await this.authenticatedFetch(uploadUrl, {
@@ -3474,17 +3541,17 @@ export class JMAPClient implements IJMAPClient {
const result = await response.json();
if (result.blobId) return result.blobId;
const blobInfo = result[this.getSieveAccountId()];
const blobInfo = result[targetAccountId];
if (blobInfo?.blobId) return blobInfo.blobId;
throw new Error('Invalid upload response: blobId not found');
}
async createSieveScript(name: string, content: string, activate?: boolean): Promise<SieveScript> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
async createSieveScript(name: string, content: string, activate?: boolean, accountId?: string): Promise<SieveScript> {
const targetAccountId = accountId || this.getSieveAccountId();
const blobId = await this.uploadSieveBlob(content, targetAccountId);
const setArgs: Record<string, unknown> = {
accountId,
accountId: targetAccountId,
create: {
"new-script": { name, blobId }
},
@@ -3505,7 +3572,7 @@ export class JMAPClient implements IJMAPClient {
}
const createdId = result.created?.["new-script"]?.id;
if (createdId) {
const scripts = await this.getSieveScripts();
const scripts = await this.getSieveScripts(targetAccountId);
const script = scripts.find(s => s.id === createdId);
if (script) return script;
}
@@ -3513,12 +3580,12 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to create sieve script");
}
async updateSieveScript(scriptId: string, content: string, activate?: boolean): Promise<void> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
async updateSieveScript(scriptId: string, content: string, activate?: boolean, accountId?: string): Promise<void> {
const targetAccountId = accountId || this.getSieveAccountId();
const blobId = await this.uploadSieveBlob(content, targetAccountId);
const setArgs: Record<string, unknown> = {
accountId,
accountId: targetAccountId,
update: {
[scriptId]: { blobId }
},
@@ -3542,12 +3609,10 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to update sieve script");
}
async deleteSieveScript(scriptId: string): Promise<void> {
const accountId = this.getSieveAccountId();
async deleteSieveScript(scriptId: string, accountId?: string): Promise<void> {
const response = await this.request([
["SieveScript/set", {
accountId,
accountId: accountId || this.getSieveAccountId(),
destroy: [scriptId]
}, "0"]
], this.sieveUsing());
@@ -3563,12 +3628,10 @@ export class JMAPClient implements IJMAPClient {
throw new Error("Failed to delete sieve script");
}
async activateSieveScript(scriptId: string): Promise<void> {
const accountId = this.getSieveAccountId();
async activateSieveScript(scriptId: string, accountId?: string): Promise<void> {
const response = await this.request([
["SieveScript/set", {
accountId,
accountId: accountId || this.getSieveAccountId(),
onSuccessActivateScript: scriptId,
}, "0"]
], this.sieveUsing());
@@ -3582,12 +3645,10 @@ export class JMAPClient implements IJMAPClient {
}
}
async deactivateSieveScript(): Promise<void> {
const accountId = this.getSieveAccountId();
async deactivateSieveScript(accountId?: string): Promise<void> {
const response = await this.request([
["SieveScript/set", {
accountId,
accountId: accountId || this.getSieveAccountId(),
onSuccessActivateScript: null,
}, "0"]
], this.sieveUsing());
@@ -3601,13 +3662,13 @@ export class JMAPClient implements IJMAPClient {
}
}
async validateSieveScript(content: string): Promise<{ isValid: boolean; errors?: string[] }> {
const blobId = await this.uploadSieveBlob(content);
const accountId = this.getSieveAccountId();
async validateSieveScript(content: string, accountId?: string): Promise<{ isValid: boolean; errors?: string[] }> {
const targetAccountId = accountId || this.getSieveAccountId();
const blobId = await this.uploadSieveBlob(content, targetAccountId);
const response = await this.request([
["SieveScript/validate", {
accountId,
accountId: targetAccountId,
blobId,
}, "0"]
], this.sieveUsing());
+19
View File
@@ -3,6 +3,25 @@ export interface EmailHeader {
value: string;
}
/**
* A session-visible account (the user's own primary account plus any
* shared/group accounts delegated to them), tagged with the capabilities it
* advertises. Produced by client.getSharedAccounts(); drives the settings
* "Shared with me" list and the scoped-settings tab gating.
*/
export interface SharedAccount {
id: string;
name: string;
isPrimary: boolean;
capabilities: {
mail: boolean;
sieve: boolean;
calendars: boolean;
contacts: boolean;
filenode: boolean;
};
}
export interface Email {
id: string;
threadId: string;