diff --git a/src/api/__tests__/email.test.ts b/src/api/__tests__/email.test.ts index 7d696335..684b544f 100644 --- a/src/api/__tests__/email.test.ts +++ b/src/api/__tests__/email.test.ts @@ -5,12 +5,16 @@ vi.mock('../jmap-client', () => ({ jmapClient: { accountId: 'acc-1', request: vi.fn(), + getAccountName: vi.fn(() => 'me@example.com'), + getSharedMailAccounts: vi.fn(() => []), + getMaxCallsInRequest: vi.fn(() => 16), }, })); import { jmapClient } from '../jmap-client'; import { getMailboxes, + getSharedMailboxes, queryEmails, getEmails, getFullEmail, @@ -41,13 +45,107 @@ describe('email operations', () => { const result = await getMailboxes(); - expect(result).toEqual(mailboxes); + // Own folders keep their raw ids and pick up ownership metadata. + expect(result).toEqual(mailboxes.map((m) => ({ + ...m, + accountId: 'acc-1', + accountName: 'me@example.com', + isShared: false, + }))); expect(mockRequest).toHaveBeenCalledWith( [['Mailbox/get', { accountId: 'acc-1' }, '0']], ); }); }); + describe('getSharedMailboxes', () => { + const mockShared = jmapClient.getSharedMailAccounts as ReturnType; + + it('returns nothing when the session has no shared accounts', async () => { + mockShared.mockReturnValue([]); + + expect(await getSharedMailboxes()).toEqual([]); + expect(mockRequest).not.toHaveBeenCalled(); + }); + + it('prefixes ids and parent links per owning account', async () => { + mockShared.mockReturnValue([{ id: 'grp-1', name: 'Support' }]); + mockRequest.mockResolvedValue({ + methodResponses: [['Mailbox/get', { + list: [ + { id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 4, unreadEmails: 2 }, + { id: 'mb-2', name: 'Escalations', parentId: 'mb-1', totalEmails: 1, unreadEmails: 0 }, + ], + }, '0']], + }); + + const result = await getSharedMailboxes(); + + expect(mockRequest).toHaveBeenCalledWith( + [['Mailbox/get', { accountId: 'grp-1' }, '0']], + ); + expect(result).toEqual([ + { + id: 'grp-1:mb-1', + originalId: 'mb-1', + name: 'Inbox', + role: 'inbox', + parentId: undefined, + totalEmails: 4, + unreadEmails: 2, + accountId: 'grp-1', + accountName: 'Support', + isShared: true, + }, + { + id: 'grp-1:mb-2', + originalId: 'mb-2', + name: 'Escalations', + parentId: 'grp-1:mb-1', + totalEmails: 1, + unreadEmails: 0, + accountId: 'grp-1', + accountName: 'Support', + isShared: true, + }, + ]); + }); + + it('maps each response back to its account and skips the ones that errored', async () => { + mockShared.mockReturnValue([ + { id: 'grp-1', name: 'Support' }, + { id: 'grp-2', name: 'Sales' }, + ]); + mockRequest.mockResolvedValue({ + methodResponses: [ + ['error', { type: 'accountNotFound' }, '0'], + ['Mailbox/get', { list: [{ id: 'mb-9', name: 'Inbox', role: 'inbox' }] }, '1'], + ], + }); + + const result = await getSharedMailboxes(); + + expect(result.map((m) => m.id)).toEqual(['grp-2:mb-9']); + expect(result[0].accountName).toBe('Sales'); + }); + + it('chunks requests to maxCallsInRequest', async () => { + mockShared.mockReturnValue([ + { id: 'grp-1', name: 'Support' }, + { id: 'grp-2', name: 'Sales' }, + { id: 'grp-3', name: 'Billing' }, + ]); + (jmapClient.getMaxCallsInRequest as ReturnType).mockReturnValue(2); + mockRequest.mockResolvedValue({ methodResponses: [] }); + + await getSharedMailboxes(); + + expect(mockRequest).toHaveBeenCalledTimes(2); + expect(mockRequest.mock.calls[0][0]).toHaveLength(2); + expect(mockRequest.mock.calls[1][0]).toHaveLength(1); + }); + }); + describe('queryEmails', () => { it('should query emails with default options', async () => { mockRequest.mockResolvedValue({ diff --git a/src/api/blob.ts b/src/api/blob.ts index e0f8fa4b..c9c6b648 100644 --- a/src/api/blob.ts +++ b/src/api/blob.ts @@ -67,13 +67,17 @@ export async function uploadBlob( export async function uploadBytes( bytes: Uint8Array, type: string, + // Blobs are account-scoped: a message imported into a shared/group account's + // folder has to be uploaded to that account, not the user's own. + accountId?: string, ): Promise<{ blobId: string; size: number; type: string }> { const session = jmapClient.currentSession; if (!session) throw new Error('Not connected'); + const targetAccountId = accountId ?? jmapClient.accountId; const uploadUrl = session.uploadUrl.replace( '{accountId}', - encodeURIComponent(jmapClient.accountId), + encodeURIComponent(targetAccountId), ); const response = await secureFetch(uploadUrl, { @@ -99,7 +103,7 @@ export async function uploadBytes( type: typeof direct.type === 'string' && direct.type ? direct.type : type, }; } - const nested = raw[jmapClient.accountId] as + const nested = raw[targetAccountId] as | { blobId?: string; type?: string; size?: number } | undefined; if (nested?.blobId) { diff --git a/src/api/email.ts b/src/api/email.ts index b24be026..4333d9ff 100644 --- a/src/api/email.ts +++ b/src/api/email.ts @@ -14,33 +14,112 @@ const EMAIL_FULL_PROPERTIES = [ 'attachments', 'blobId', 'bcc', 'replyTo', 'sentAt', ]; -export async function getMailboxes(): Promise { - const accountId = jmapClient.accountId; - const res = await jmapClient.request( - [['Mailbox/get', { accountId }, '0']], - ); - return res.methodResponses[0][1].list; +// Prefix a shared account's folder ids so they can't collide with the user's +// own. Matches the webmail's `${accountId}:${mailboxId}` scheme. +function sharedMailboxId(accountId: string, rawId: string): string { + return `${accountId}:${rawId}`; +} + +/** Inverse of {@link sharedMailboxId}; a no-op for the user's own folders. */ +export function unprefixMailboxId(id: string, accountId?: string): string { + if (!accountId) return id; + const prefix = `${accountId}:`; + return id.startsWith(prefix) ? id.slice(prefix.length) : id; +} + +// Stamp ownership onto a raw Mailbox/get result. Own folders keep their raw +// ids; a shared account's are prefixed and remember their `originalId` so +// server calls can still address them. +function tagMailbox( + mailbox: Mailbox, + accountId: string, + accountName: string | undefined, + isShared: boolean, +): Mailbox { + if (!isShared) { + return { ...mailbox, accountId, accountName, isShared: false }; + } + return { + ...mailbox, + id: sharedMailboxId(accountId, mailbox.id), + originalId: mailbox.id, + parentId: mailbox.parentId ? sharedMailboxId(accountId, mailbox.parentId) : mailbox.parentId, + accountId, + accountName, + isShared: true, + }; +} + +export async function getMailboxes(accountId?: string): Promise { + return (await getMailboxesWithState(accountId)).list; } // Variant that also returns the JMAP `state` token so callers can later issue // Mailbox/changes(sinceState=…) to fetch only what changed. -export async function getMailboxesWithState(): Promise<{ list: Mailbox[]; state: string }> { - const accountId = jmapClient.accountId; +export async function getMailboxesWithState( + accountIdOverride?: string, +): Promise<{ list: Mailbox[]; state: string }> { + const accountId = accountIdOverride ?? jmapClient.accountId; + const isShared = accountId !== jmapClient.accountId; const res = await jmapClient.request( [['Mailbox/get', { accountId }, '0']], ); const body = res.methodResponses[0][1]; - return { list: body.list as Mailbox[], state: body.state as string }; + const list = ((body.list as Mailbox[]) ?? []).map((m) => + tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared), + ); + return { list, state: body.state as string }; } -export async function getMailboxesByIds(ids: string[]): Promise<{ list: Mailbox[]; state: string }> { +/** + * Every folder of every shared/group account in this session, ready to merge + * into the sidebar next to the user's own. One request carrying a Mailbox/get + * per account, chunked to the server's maxCallsInRequest. An account that + * errors (revoked access, server hiccup) is skipped rather than sinking the + * whole list. + */ +export async function getSharedMailboxes(): Promise { + const accounts = jmapClient.getSharedMailAccounts(); + if (accounts.length === 0) return []; + + const perRequest = Math.max(1, jmapClient.getMaxCallsInRequest()); + const out: Mailbox[] = []; + for (let i = 0; i < accounts.length; i += perRequest) { + const chunk = accounts.slice(i, i + perRequest); + const res = await jmapClient.request( + chunk.map((acc, idx): JMAPMethodCall => [ + 'Mailbox/get', + { accountId: acc.id }, + String(idx), + ]), + ); + for (const [name, body, callId] of res.methodResponses) { + if (name !== 'Mailbox/get') continue; + const acc = chunk[Number(callId)]; + if (!acc) continue; + for (const m of (body.list as Mailbox[]) ?? []) { + out.push(tagMailbox(m, acc.id, acc.name, true)); + } + } + } + return out; +} + +export async function getMailboxesByIds( + ids: string[], + accountIdOverride?: string, +): Promise<{ list: Mailbox[]; state: string }> { if (ids.length === 0) return { list: [], state: '' }; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; + const isShared = accountId !== jmapClient.accountId; const res = await jmapClient.request( [['Mailbox/get', { accountId, ids }, '0']], ); const body = res.methodResponses[0][1]; - return { list: body.list as Mailbox[], state: body.state as string }; + const list = ((body.list as Mailbox[]) ?? []).map((m) => + tagMailbox(m, accountId, jmapClient.getAccountName(accountId), isShared), + ); + return { list, state: body.state as string }; } export interface MailboxChangesResult { @@ -54,8 +133,11 @@ export interface MailboxChangesResult { // Returns null when the server can't compute the diff (typically // `cannotCalculateChanges`); callers should fall back to a full Mailbox/get. -export async function getMailboxChanges(sinceState: string): Promise { - const accountId = jmapClient.accountId; +export async function getMailboxChanges( + sinceState: string, + accountIdOverride?: string, +): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['Mailbox/changes', { accountId, sinceState }, '0']], ); @@ -73,8 +155,9 @@ export async function getMailboxChanges(sinceState: string): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const cid = 'new-mailbox'; const res = await jmapClient.request([ ['Mailbox/set', { @@ -102,8 +185,9 @@ export async function createMailbox( export async function updateMailbox( id: string, changes: { name?: string; parentId?: string | null }, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Mailbox/set', { accountId, update: { [id]: changes } }, '0'], ]); @@ -117,8 +201,8 @@ export async function updateMailbox( } } -export async function deleteMailbox(id: string): Promise { - const accountId = jmapClient.accountId; +export async function deleteMailbox(id: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Mailbox/set', { accountId, destroy: [id] }, '0'], ]); @@ -155,9 +239,11 @@ export async function queryEmails( limit?: number; sort?: Array<{ property: string; isAscending: boolean }>; filter?: Record; + /** Owning JMAP account when the mailbox belongs to a shared account. */ + accountId?: string; }, ): Promise<{ ids: string[]; total: number; queryState?: string }> { - const accountId = jmapClient.accountId; + const accountId = options?.accountId ?? jmapClient.accountId; const filter = buildMailboxQueryFilter(mailboxId, options?.filter); const res = await jmapClient.request([ ['Email/query', { @@ -196,9 +282,11 @@ export async function getEmailQueryChanges( filter?: Record; upToId?: string; maxChanges?: number; + /** Owning JMAP account when the mailbox belongs to a shared account. */ + accountId?: string; }, ): Promise { - const accountId = jmapClient.accountId; + const accountId = options?.accountId ?? jmapClient.accountId; const filter = buildMailboxQueryFilter(mailboxId, options?.filter); const args: Record = { accountId, @@ -222,8 +310,8 @@ export async function getEmailQueryChanges( }; } -export async function getEmails(ids: string[]): Promise { - const accountId = jmapClient.accountId; +export async function getEmails(ids: string[], accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'], ]); @@ -233,18 +321,21 @@ export async function getEmails(ids: string[]): Promise { // Returns the Email/get response with the JMAP `state` token. Used by the // store so we can later issue Email/changes(sinceState=…) for incremental // updates instead of re-fetching the full list. -export async function getEmailsWithState(ids: string[]): Promise<{ list: Email[]; state: string }> { +export async function getEmailsWithState( + ids: string[], + accountIdOverride?: string, +): Promise<{ list: Email[]; state: string }> { if (ids.length === 0) { // Email/get with an empty id list still returns a state token; useful for // priming the store after an empty mailbox query. - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/get', { accountId, ids: [], properties: EMAIL_LIST_PROPERTIES }, '0'], ]); const body = res.methodResponses[0][1]; return { list: [], state: body.state as string }; } - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/get', { accountId, ids, properties: EMAIL_LIST_PROPERTIES }, '0'], ]); @@ -266,8 +357,9 @@ export interface EmailChangesResult { export async function getEmailChanges( sinceState: string, maxChanges?: number, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const args: Record = { accountId, sinceState }; if (maxChanges) args.maxChanges = maxChanges; const res = await jmapClient.request([['Email/changes', args, '0']]); @@ -306,9 +398,9 @@ export async function getFullEmail(id: string, accountIdOverride?: string): Prom // Batch variant for offline sync. JMAP servers cap how many objects can be // returned in a single Email/get; the caller should chunk to that ceiling // (the client exposes maxObjectsInGet via getMaxObjectsInGet()). -export async function getFullEmails(ids: string[]): Promise { +export async function getFullEmails(ids: string[], accountIdOverride?: string): Promise { if (ids.length === 0) return []; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/get', { accountId, @@ -332,8 +424,9 @@ export async function importEmailBlob( blobId: string, mailboxId: string, keywords: Record = { $seen: true }, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request([ ['Email/import', { accountId, @@ -352,8 +445,8 @@ export async function importEmailBlob( return id; } -export async function getThread(threadId: string): Promise { - const accountId = jmapClient.accountId; +export async function getThread(threadId: string, accountIdOverride?: string): Promise { + const accountId = accountIdOverride ?? jmapClient.accountId; const res = await jmapClient.request( [['Thread/get', { accountId, ids: [threadId] }, '0']], ); @@ -375,8 +468,9 @@ export async function moveEmail( emailId: string, fromMailboxId: string, toMailboxId: string, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; await jmapClient.request([ ['Email/set', { accountId, @@ -395,9 +489,10 @@ export async function moveEmails( ids: string[], fromMailboxId: string, toMailboxId: string, + accountIdOverride?: string, ): Promise { if (ids.length === 0) return; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record> = {}; for (const id of ids) { update[id] = { @@ -413,22 +508,24 @@ export async function deleteEmails( ids: string[], trashMailboxId: string, currentMailboxId: string, + accountIdOverride?: string, ): Promise { if (ids.length === 0) return; if (currentMailboxId === trashMailboxId) { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]); } else { - await moveEmails(ids, currentMailboxId, trashMailboxId); + await moveEmails(ids, currentMailboxId, trashMailboxId, accountIdOverride); } } // Apply keyword maps to several emails in one round-trip. export async function setKeywordsForEmails( updates: Array<{ id: string; keywords: Record }>, + accountIdOverride?: string, ): Promise { if (updates.length === 0) return; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record }> = {}; for (const u of updates) update[u.id] = { keywords: u.keywords }; await jmapClient.request([['Email/set', { accountId, update }, '0']]); @@ -439,9 +536,10 @@ export async function setKeywordsForEmails( // the entire map, so we don't need to compute a diff against the current state. export async function restoreEmailMailboxes( items: Array<{ id: string; mailboxIds: Record }>, + accountIdOverride?: string, ): Promise { if (items.length === 0) return; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const update: Record }> = {}; for (const item of items) { const onlyTrue: Record = {}; @@ -462,15 +560,16 @@ export async function restoreEmailMailboxes( export async function setEmailMailboxes( emailId: string, mailboxIds: Record, + accountIdOverride?: string, ): Promise { - await restoreEmailMailboxes([{ id: emailId, mailboxIds }]); + await restoreEmailMailboxes([{ id: emailId, mailboxIds }], accountIdOverride); } // Permanently destroy emails (no move-to-trash). Idempotent: destroying an // already-gone id is a no-op on replay. -export async function destroyEmails(ids: string[]): Promise { +export async function destroyEmails(ids: string[], accountIdOverride?: string): Promise { if (ids.length === 0) return; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; await jmapClient.request([['Email/set', { accountId, destroy: ids }, '0']]); } @@ -482,9 +581,10 @@ export async function archiveEmails( archiveMailboxId: string, mode: 'single' | 'year' | 'month', existingMailboxes: Mailbox[], + accountIdOverride?: string, ): Promise { if (emails.length === 0) return; - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; if (mode === 'single') { const updates = Object.fromEntries( @@ -595,14 +695,15 @@ export async function deleteEmail( emailId: string, trashMailboxId: string, currentMailboxId: string, + accountIdOverride?: string, ): Promise { if (currentMailboxId === trashMailboxId) { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; await jmapClient.request([ ['Email/set', { accountId, destroy: [emailId] }, '0'], ]); } else { - await moveEmail(emailId, currentMailboxId, trashMailboxId); + await moveEmail(emailId, currentMailboxId, trashMailboxId, accountIdOverride); } } @@ -610,8 +711,9 @@ export async function searchEmails( query: string, mailboxId?: string, limit = 30, + accountIdOverride?: string, ): Promise { - const accountId = jmapClient.accountId; + const accountId = accountIdOverride ?? jmapClient.accountId; const filter: Record = { text: toWildcardQuery(query) }; if (mailboxId) filter.inMailbox = mailboxId; diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index c70269d5..8ee01333 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -459,6 +459,43 @@ export class JMAPClient { return core?.maxObjectsInGet || 500; } + getMaxCallsInRequest(): number { + const core = this.session?.capabilities?.[CAPABILITIES.CORE] as + | { maxCallsInRequest?: number } + | undefined; + return core?.maxCallsInRequest || 16; + } + + // ── Shared / group accounts ─────────────────────────── + // A JMAP session lists every account the credentials can reach: the user's + // own, plus any shared/group accounts (Stalwart "group accounts") they are a + // member of. Non-personal accounts are the shared mailboxes. + + /** + * Mail-capable accounts other than the primary one. Stalwart doesn't always + * populate `accountCapabilities` on shared accounts, so an account counts as + * mail-capable when it advertises the mail capability OR is non-personal — + * the same rule the webmail's getSharedAccounts() applies. + */ + getSharedMailAccounts(): { id: string; name: string }[] { + if (!this.session || !this._accountId) return []; + const primaryId = this._accountId; + const out: { id: string; name: string }[] = []; + for (const [id, info] of Object.entries(this.session.accounts ?? {})) { + if (id === primaryId) continue; + const advertisesMail = info.accountCapabilities + ? CAPABILITIES.MAIL in info.accountCapabilities + : false; + if (!advertisesMail && info.isPersonal) continue; + out.push({ id, name: info.name || id }); + } + return out; + } + + getAccountName(accountId: string): string | undefined { + return this.session?.accounts?.[accountId]?.name; + } + // ── Scheduled send (FUTURERELEASE) ──────────────────── // The JMAP submission capability advertises `maxDelayedSend` (max hold in // seconds) and a `submissionExtensions` map; FUTURERELEASE support is what @@ -515,19 +552,26 @@ export class JMAPClient { // ── Blob Download ───────────────────────────────────── // Expand the RFC 6570 level-1 template the JMAP server advertises. - getBlobDownloadUrl(blobId: string, name?: string, type?: string): string { + // `accountId` targets a shared/group account, whose blobs aren't reachable + // through the user's own account id. + getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string { if (!this.session?.downloadUrl) { throw new Error('Download URL not available - not connected'); } return this.session.downloadUrl - .replace('{accountId}', encodeURIComponent(this.accountId)) + .replace('{accountId}', encodeURIComponent(accountId ?? this.accountId)) .replace('{blobId}', encodeURIComponent(blobId)) .replace('{name}', encodeURIComponent(name || 'download')) .replace('{type}', encodeURIComponent(type || 'application/octet-stream')); } - async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise { - const url = this.getBlobDownloadUrl(blobId, name, type); + async fetchBlobArrayBuffer( + blobId: string, + name?: string, + type?: string, + accountId?: string, + ): Promise { + const url = this.getBlobDownloadUrl(blobId, name, type, accountId); await this.ensureFreshToken(); const doFetch = () => secureFetch(url, { headers: { Authorization: this.authHeader } }); let response = await doFetch(); diff --git a/src/api/types.ts b/src/api/types.ts index a686639d..a5042c6d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -99,7 +99,15 @@ export interface MailboxRights { } export interface Mailbox { + /** + * Unique within the app. For the user's own folders this is the raw JMAP id; + * for a shared (group) account's folders it's `:` so ids + * from different accounts can't collide. Use `originalId` when talking to + * the server. Mirrors the webmail's scheme in [lib/jmap/client.ts]. + */ id: string; + /** Raw JMAP id — set only on shared folders, where `id` carries a prefix. */ + originalId?: string; name: string; parentId?: string | null; role?: string | null; @@ -110,6 +118,12 @@ export interface Mailbox { unreadThreads: number; myRights: MailboxRights; isSubscribed?: boolean; + /** JMAP account the folder lives under (primary or a shared/group account). */ + accountId?: string; + /** Display name of the owning account, used as the sidebar group header. */ + accountName?: string; + /** True when the folder belongs to a shared/group account, not the user. */ + isShared?: boolean; } export interface Thread { diff --git a/src/components/EmailBodyView.tsx b/src/components/EmailBodyView.tsx index a9e5dc91..93d27e29 100644 --- a/src/components/EmailBodyView.tsx +++ b/src/components/EmailBodyView.tsx @@ -27,6 +27,8 @@ function arrayBufferToBase64(buffer: ArrayBuffer): string { interface EmailBodyViewProps { email: Email; senderEmail?: string; + /** Owning account when the message lives in a shared/group mailbox. */ + jmapAccountId?: string; // The body is a native WebView, which on Android swallows horizontal touches // before the surrounding pager can see them. We detect a clear horizontal // swipe inside the page and report it here so the pager can change messages. @@ -457,7 +459,9 @@ const PINCH_ZOOM = ` })(); `; -export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChange }: EmailBodyViewProps) { +export default function EmailBodyView({ + email, senderEmail, jmapAccountId, onSwipe, onZoomChange, +}: EmailBodyViewProps) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); const externalContentPolicy = useSettingsStore((s) => s.externalContentPolicy); @@ -561,7 +565,9 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang await Promise.all( toFetch.map(async ({ ref, att }) => { try { - const buf = await jmapClient.fetchBlobArrayBuffer(att.blobId, att.name, att.type); + const buf = await jmapClient.fetchBlobArrayBuffer( + att.blobId, att.name, att.type, jmapAccountId, + ); if (cancelled) return; const b64 = arrayBufferToBase64(buf); next[ref] = `data:${att.type || 'application/octet-stream'};base64,${b64}`; @@ -575,7 +581,7 @@ export default function EmailBodyView({ email, senderEmail, onSwipe, onZoomChang } })(); return () => { cancelled = true; }; - }, [rawHtml, email.attachments]); + }, [rawHtml, email.attachments, jmapAccountId]); const source = React.useMemo(() => { if (rawHtml) { diff --git a/src/components/MoveSheet.tsx b/src/components/MoveSheet.tsx index 5381389f..3c685093 100644 --- a/src/components/MoveSheet.tsx +++ b/src/components/MoveSheet.tsx @@ -98,7 +98,9 @@ export function MoveSheet({ {visibleNodes.map((node) => { const Icon = moveTargetIcon(node.role, node.name); const isCurrent = node.id === currentMailboxId; - const canTarget = node.myRights?.mayAddItems !== false && !isCurrent; + // A shared account's header is a grouping row, not a folder. + const canTarget = + !node.isAccountNode && node.myRights?.mayAddItems !== false && !isCurrent; return ( {icon} {label} @@ -467,20 +473,34 @@ export default function SidebarDrawer({ visible, onClose }: SidebarDrawerProps) visibleNodes.map((node) => { const hasChildren = node.children.length > 0; const isExpanded = expandedFolders.has(node.id); - const Icon = iconFor(node.role, node.name, hasChildren, isExpanded); - const isSelected = node.id === currentMailboxId; + // A shared/group account's header has no mailbox behind it — + // tapping it only opens or closes that account's folders. + const Icon = node.isAccountNode + ? Users + : iconFor(node.role, node.name, hasChildren, isExpanded); + const isSelected = !node.isAccountNode && node.id === currentMailboxId; return ( } + icon={ + + } label={node.name} depth={node.depth} isSelected={isSelected} + isAccountHeader={node.isAccountNode} unread={node.unreadEmails} total={node.totalEmails} hasChildren={hasChildren} isExpanded={isExpanded} - onPress={() => handleSelect(node.id)} + onPress={ + node.isAccountNode + ? () => toggleExpand(node.id) + : () => handleSelect(node.id) + } onToggleExpand={() => toggleExpand(node.id)} /> ); @@ -741,6 +761,13 @@ function makeStyles(c: ThemePalette) { ...typography.bodySemibold, color: c.text, }, + rowLabelAccount: { + ...typography.caption, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + color: c.textSecondary, + }, counts: { flexDirection: 'row', alignItems: 'baseline', diff --git a/src/components/settings/FilterSettings.tsx b/src/components/settings/FilterSettings.tsx index c2cc8af8..a583ede0 100644 --- a/src/components/settings/FilterSettings.tsx +++ b/src/components/settings/FilterSettings.tsx @@ -12,6 +12,7 @@ import { useSettingsStore } from '../../stores/settings-store'; import { useFilterStore } from '../../stores/filter-store'; import { useVacationStore } from '../../stores/vacation-store'; import { useEmailStore } from '../../stores/email-store'; +import { ownMailboxes } from '../../lib/mailbox-tree'; import { useLocaleStore } from '../../stores/locale-store'; import { FilterRuleModal } from '../filters/FilterRuleModal'; import { SieveEditorSheet } from '../filters/SieveEditorSheet'; @@ -48,7 +49,10 @@ export function FilterSettings() { const expandedView = useSettingsStore((s) => s.filtersExpandedView); const updateSetting = useSettingsStore((s) => s.updateSetting); - const mailboxes = useEmailStore((s) => s.mailboxes); + // Sieve rules run on the user's own account, so only own folders are + // valid "file into" targets. + const allMailboxes = useEmailStore((s) => s.mailboxes); + const mailboxes = useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]); const vacationEnabled = useVacationStore((s) => s.isEnabled); const { diff --git a/src/components/settings/FolderSettings.tsx b/src/components/settings/FolderSettings.tsx index 9f8f4b28..cefd9a0c 100644 --- a/src/components/settings/FolderSettings.tsx +++ b/src/components/settings/FolderSettings.tsx @@ -18,6 +18,7 @@ import { SettingsSection } from './settings-section'; import Button from '../Button'; import { spacing, radius, typography, type ThemePalette } from '../../theme/tokens'; import { useColors } from '../../theme/colors'; +import { ownMailboxes } from '../../lib/mailbox-tree'; import { useEmailStore } from '../../stores/email-store'; import { createMailbox, updateMailbox, deleteMailbox } from '../../api/email'; import type { Mailbox } from '../../api/types'; @@ -43,7 +44,10 @@ type Editor = export function FolderSettings() { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const mailboxes = useEmailStore((s) => s.mailboxes); + // Folder management (create/rename/delete) targets the user's own account; + // shared (group account) folders are read-only here. + const allMailboxes = useEmailStore((s) => s.mailboxes); + const mailboxes = React.useMemo(() => ownMailboxes(allMailboxes), [allMailboxes]); const fetchMailboxes = useEmailStore((s) => s.fetchMailboxes); const [editor, setEditor] = useState(null); diff --git a/src/components/settings/ReadingSettings.tsx b/src/components/settings/ReadingSettings.tsx index bfecd072..8e850e42 100644 --- a/src/components/settings/ReadingSettings.tsx +++ b/src/components/settings/ReadingSettings.tsx @@ -14,6 +14,7 @@ import { } from '../../stores/settings-store'; import { useEmailStore } from '../../stores/email-store'; import { archiveEmails, queryEmails, getEmails } from '../../api/email'; +import { ownMailboxes } from '../../lib/mailbox-tree'; function MailLayoutPreview({ value }: { value: MailLayout }) { const c = useColors(); @@ -103,7 +104,8 @@ export function ReadingSettings() { const handleReorganizeArchive = async () => { const { mailboxes, fetchMailboxes } = useEmailStore.getState(); - const archiveMailbox = mailboxes.find( + // Reorganising runs against the user's own archive only. + const archiveMailbox = ownMailboxes(mailboxes).find( (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive', ); if (!archiveMailbox) { @@ -130,7 +132,7 @@ export function ReadingSettings() { if (ids.length === 0) break; const list = await getEmails(ids); - const refreshed = useEmailStore.getState().mailboxes; + const refreshed = ownMailboxes(useEmailStore.getState().mailboxes); await archiveEmails( list.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), archiveMailbox.id, diff --git a/src/lib/__tests__/mailbox-tree.test.ts b/src/lib/__tests__/mailbox-tree.test.ts new file mode 100644 index 00000000..cb2e2031 --- /dev/null +++ b/src/lib/__tests__/mailbox-tree.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { + buildMailboxTree, + flattenVisible, + mailboxesForSiblingOf, + ownMailboxes, + SHARED_ACCOUNT_NODE_PREFIX, +} from '../mailbox-tree'; +import type { Mailbox } from '../../api/types'; + +const RIGHTS: Mailbox['myRights'] = { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, +}; + +function own(id: string, name: string, extra: Partial = {}): Mailbox { + return { + id, + name, + totalEmails: 0, + unreadEmails: 0, + totalThreads: 0, + unreadThreads: 0, + myRights: RIGHTS, + accountId: 'acc-1', + isShared: false, + ...extra, + }; +} + +function shared( + accountId: string, + accountName: string, + rawId: string, + name: string, + extra: Partial = {}, +): Mailbox { + return { + ...own(`${accountId}:${rawId}`, name, extra), + originalId: rawId, + accountId, + accountName, + isShared: true, + }; +} + +describe('buildMailboxTree with shared accounts', () => { + it('keeps own folders at the root and groups each shared account under its own node', () => { + const tree = buildMailboxTree([ + own('inbox', 'Inbox', { role: 'inbox' }), + own('sent', 'Sent', { role: 'sent' }), + shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox', unreadEmails: 3 }), + shared('grp-1', 'Support', 'arch', 'Archive', { role: 'archive', unreadEmails: 1 }), + ]); + + expect(tree.map((n) => n.id)).toEqual([ + 'inbox', + 'sent', + `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`, + ]); + + const accountNode = tree[2]; + expect(accountNode.isAccountNode).toBe(true); + expect(accountNode.name).toBe('Support'); + // The header rolls up its account's unread so a collapsed section still + // signals waiting mail. + expect(accountNode.unreadEmails).toBe(4); + expect(accountNode.children.map((n) => n.id)).toEqual(['grp-1:inbox', 'grp-1:arch']); + expect(accountNode.children.every((n) => n.depth === 1)).toBe(true); + }); + + it('nests a shared account\'s subfolders under their own parent, not the user\'s', () => { + const tree = buildMailboxTree([ + own('inbox', 'Inbox', { role: 'inbox' }), + shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }), + shared('grp-1', 'Support', 'sub', 'Escalations', { parentId: 'grp-1:inbox' }), + ]); + + const accountNode = tree[1]; + expect(accountNode.children).toHaveLength(1); + expect(accountNode.children[0].children.map((n) => n.name)).toEqual(['Escalations']); + expect(accountNode.children[0].children[0].depth).toBe(2); + }); + + it('gives each shared account its own node', () => { + const tree = buildMailboxTree([ + shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }), + shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }), + ]); + + expect(tree.map((n) => n.name)).toEqual(['Sales', 'Support']); + }); + + it('hides a collapsed shared account\'s folders', () => { + const tree = buildMailboxTree([ + own('inbox', 'Inbox', { role: 'inbox' }), + shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }), + ]); + + const collapsed = flattenVisible(tree, new Set()); + expect(collapsed.map((n) => n.id)).toEqual(['inbox', `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`]); + + const expanded = flattenVisible(tree, new Set([`${SHARED_ACCOUNT_NODE_PREFIX}grp-1`])); + expect(expanded.map((n) => n.id)).toEqual([ + 'inbox', + `${SHARED_ACCOUNT_NODE_PREFIX}grp-1`, + 'grp-1:inbox', + ]); + }); +}); + +describe('account scoping helpers', () => { + const all = [ + own('inbox', 'Inbox', { role: 'inbox' }), + own('trash', 'Trash', { role: 'trash' }), + shared('grp-1', 'Support', 'inbox', 'Inbox', { role: 'inbox' }), + shared('grp-1', 'Support', 'trash', 'Trash', { role: 'trash' }), + shared('grp-2', 'Sales', 'inbox', 'Inbox', { role: 'inbox' }), + ]; + + it('ownMailboxes drops every shared folder', () => { + expect(ownMailboxes(all).map((m) => m.id)).toEqual(['inbox', 'trash']); + }); + + it('scopes to the owning shared account when the current folder is shared', () => { + expect(mailboxesForSiblingOf(all, 'grp-1:inbox').map((m) => m.id)) + .toEqual(['grp-1:inbox', 'grp-1:trash']); + }); + + it('scopes to the user\'s own folders for an own or unknown folder', () => { + expect(mailboxesForSiblingOf(all, 'inbox').map((m) => m.id)).toEqual(['inbox', 'trash']); + expect(mailboxesForSiblingOf(all, 'gone').map((m) => m.id)).toEqual(['inbox', 'trash']); + expect(mailboxesForSiblingOf(all, null).map((m) => m.id)).toEqual(['inbox', 'trash']); + }); +}); diff --git a/src/lib/email-export.ts b/src/lib/email-export.ts index 890b22f4..25f7499c 100644 --- a/src/lib/email-export.ts +++ b/src/lib/email-export.ts @@ -170,8 +170,8 @@ export async function downloadAttachment( }); } -export async function fetchRawEmail(blobId: string): Promise { - const url = getDownloadUrl(blobId, 'email.eml', RFC822); +export async function fetchRawEmail(blobId: string, accountId?: string): Promise { + const url = getDownloadUrl(blobId, 'email.eml', RFC822, accountId); const r = await secureFetch(url, { headers: { Authorization: authHeader() } }); if (!r.ok) throw new Error(`Download failed: ${r.status}`); return r.text(); @@ -186,12 +186,14 @@ export async function shareEmailEml( blobId: string, email?: Email | null, subjectFallback?: string, + // Owning account when the message lives in a shared/group mailbox. + accountId?: string, ): Promise { const filename = email ? emailExportFilename(email, emailFileOptions()) : safeFilename(subjectFallback); const dest = new File(Paths.cache, filename); - const url = getDownloadUrl(blobId, dest.name, RFC822); + const url = getDownloadUrl(blobId, dest.name, RFC822, accountId); const downloaded = await downloadInto(url, dest, Paths.cache); if (!(await Sharing.isAvailableAsync())) { throw new Error('Sharing is not available on this device'); diff --git a/src/lib/mailbox-tree.ts b/src/lib/mailbox-tree.ts index 1d307d11..d7ea5800 100644 --- a/src/lib/mailbox-tree.ts +++ b/src/lib/mailbox-tree.ts @@ -3,6 +3,33 @@ import type { Mailbox } from '../api/types'; export interface MailboxNode extends Mailbox { children: MailboxNode[]; depth: number; + /** + * True for the virtual node that wraps a shared/group account's folders. + * It has no server-side mailbox behind it — it can be expanded but never + * selected or used as a move target. + */ + isAccountNode?: boolean; +} + +// Id prefix for the virtual per-shared-account node. Matches the webmail's +// `shared-account-` convention in [lib/utils.ts]. +export const SHARED_ACCOUNT_NODE_PREFIX = 'shared-account-'; + +/** The user's own folders — everything not owned by a shared/group account. */ +export function ownMailboxes(mailboxes: Mailbox[]): Mailbox[] { + return mailboxes.filter((m) => !m.isShared); +} + +/** + * Folders belonging to the same account as `mailboxId`. Role lookups (trash, + * archive, junk…) must run inside one account: moving a message out of a + * shared folder into the user's own Trash isn't a thing JMAP can express. + * Falls back to the user's own folders when the id isn't known. + */ +export function mailboxesForSiblingOf(mailboxes: Mailbox[], mailboxId: string | null): Mailbox[] { + const current = mailboxId ? mailboxes.find((m) => m.id === mailboxId) : undefined; + if (!current?.isShared) return ownMailboxes(mailboxes); + return mailboxes.filter((m) => m.isShared && m.accountId === current.accountId); } // Matches `ROLE_PRIORITY` from [lib/utils.ts] in the webmail. @@ -53,7 +80,16 @@ function sortNodes(nodes: MailboxNode[]): void { for (const node of nodes) sortNodes(node.children); } -export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { +function recalcDepths(nodes: MailboxNode[], base: number): void { + for (const n of nodes) { + n.depth = base; + if (n.children.length > 0) recalcDepths(n.children, base + 1); + } +} + +// Build the root nodes for one account's folders (parent links only resolve +// within an account — a shared folder can't nest under an own folder). +function buildRoots(mailboxes: Mailbox[]): MailboxNode[] { const deduped = deduplicate(mailboxes); const map = new Map(); const roots: MailboxNode[] = []; @@ -69,16 +105,76 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { roots.push(node); } } + return roots; +} - const recalcDepths = (nodes: MailboxNode[], base: number) => { - for (const n of nodes) { - n.depth = base; - if (n.children.length > 0) recalcDepths(n.children, base + 1); - } - }; +const ACCOUNT_NODE_RIGHTS: Mailbox['myRights'] = { + mayReadItems: true, + mayAddItems: false, + mayRemoveItems: false, + maySetSeen: false, + maySetKeywords: false, + mayCreateChild: false, + mayRename: false, + mayDelete: false, + maySubmit: false, +}; + +/** + * Own folders first, then one collapsible node per shared/group account + * holding that account's folders — the same shape the webmail sidebar uses + * (GitHub #151), so a Stalwart group mailbox shows up as its own section + * rather than being mixed into the user's folder list. + */ +export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] { + const own = mailboxes.filter((m) => !m.isShared); + const shared = mailboxes.filter((m) => m.isShared); + + const roots = buildRoots(own); recalcDepths(roots, 0); sortNodes(roots); - return roots; + + if (shared.length === 0) return roots; + + const byAccount = new Map(); + for (const m of shared) { + const accountId = m.accountId ?? 'unknown'; + const list = byAccount.get(accountId); + if (list) list.push(m); + else byAccount.set(accountId, [m]); + } + + const accountNodes: MailboxNode[] = []; + for (const [accountId, accountMailboxes] of byAccount) { + const accountRoots = buildRoots(accountMailboxes); + // Children sit at depth 1 so the account node reads as their header. + recalcDepths(accountRoots, 1); + sortNodes(accountRoots); + + const accountName = accountMailboxes[0]?.accountName || accountId; + accountNodes.push({ + id: `${SHARED_ACCOUNT_NODE_PREFIX}${accountId}`, + name: accountName, + sortOrder: 1000, + totalEmails: 0, + // Roll the account's unread up to the header so a collapsed section + // still shows there's something waiting. + unreadEmails: accountMailboxes.reduce((sum, m) => sum + (m.unreadEmails ?? 0), 0), + totalThreads: 0, + unreadThreads: 0, + myRights: ACCOUNT_NODE_RIGHTS, + isSubscribed: true, + accountId, + accountName, + isShared: true, + isAccountNode: true, + children: accountRoots, + depth: 0, + }); + } + accountNodes.sort((a, b) => a.name.localeCompare(b.name)); + + return [...roots, ...accountNodes]; } // Flatten the tree in traversal order, skipping children of collapsed nodes. diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 49304daa..30f9e964 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -3,7 +3,7 @@ import type { EmailAddress } from '../api/types'; export type RootStackParamList = { MainTabs: undefined; EmailThread: { emailId: string; threadId: string; subject?: string; jmapAccountId?: string }; - EmailSource: { emailId: string; blobId: string; subject?: string }; + EmailSource: { emailId: string; blobId: string; subject?: string; jmapAccountId?: string }; Compose: | { mode?: 'reply' | 'replyAll' | 'forward'; diff --git a/src/screens/ComposeScreen.tsx b/src/screens/ComposeScreen.tsx index af89659d..d1401393 100644 --- a/src/screens/ComposeScreen.tsx +++ b/src/screens/ComposeScreen.tsx @@ -23,6 +23,7 @@ import RichTextEditor, { type RichTextSelectionState, } from '../components/RichTextEditor'; import { useEmailStore } from '../stores/email-store'; +import { ownMailboxes } from '../lib/mailbox-tree'; import { useContactsStore } from '../stores/contacts-store'; import { useLocaleStore } from '../stores/locale-store'; import { useSettingsStore } from '../stores/settings-store'; @@ -263,8 +264,10 @@ export default function ComposeScreen({ route, navigation }: Props) { const mode = route.params?.mode ?? 'compose'; const prefillTo = route.params?.prefillTo; const mailboxes = useEmailStore((s) => s.mailboxes); + // Always the user's own Sent — composing on behalf of a shared account + // isn't supported, so a group account's Sent must never be picked up here. const sentMailbox = React.useMemo( - () => mailboxes.find((m) => m.role === 'sent'), + () => ownMailboxes(mailboxes).find((m) => m.role === 'sent'), [mailboxes], ); diff --git a/src/screens/EmailListScreen.tsx b/src/screens/EmailListScreen.tsx index 68de068d..060c1e65 100644 --- a/src/screens/EmailListScreen.tsx +++ b/src/screens/EmailListScreen.tsx @@ -24,7 +24,9 @@ import { useSettingsStore, type SwipeAction } from '../stores/settings-store'; import { useKeywordsStore } from '../stores/keywords-store'; import { useLocaleStore } from '../stores/locale-store'; import { formatListDate } from '../lib/date-format'; -import { findTrashMailbox, findArchiveMailbox, findJunkMailbox } from '../lib/mailbox-tree'; +import { + findTrashMailbox, findArchiveMailbox, findJunkMailbox, mailboxesForSiblingOf, ownMailboxes, +} from '../lib/mailbox-tree'; import type { Email } from '../api/types'; function getSenderName(email: Email): string { return email.from?.[0]?.name || email.from?.[0]?.email || 'Unknown'; @@ -243,24 +245,31 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL return counts; }, [emails, disableThreading]); + // Role folders must come from the account the open folder belongs to: a + // shared (group account) message can only be filed into that account's + // Archive/Trash/Junk, never the user's own. + const scopedMailboxes = React.useMemo( + () => mailboxesForSiblingOf(mailboxes, currentMailboxId), + [mailboxes, currentMailboxId], + ); const archiveMailboxId = React.useMemo( - () => findArchiveMailbox(mailboxes)?.id ?? null, - [mailboxes], + () => findArchiveMailbox(scopedMailboxes)?.id ?? null, + [scopedMailboxes], ); const trashMailboxId = React.useMemo( - () => findTrashMailbox(mailboxes)?.id ?? null, - [mailboxes], + () => findTrashMailbox(scopedMailboxes)?.id ?? null, + [scopedMailboxes], ); const junkMailboxId = React.useMemo( - () => findJunkMailbox(mailboxes)?.id ?? null, - [mailboxes], + () => findJunkMailbox(scopedMailboxes)?.id ?? null, + [scopedMailboxes], ); // Import .eml / .zip files into the current mailbox (falls back to Inbox). const [importing, setImporting] = React.useState(false); const handleImport = React.useCallback(async () => { const targetMailboxId = - currentMailboxId ?? mailboxes.find((m) => m.role === 'inbox')?.id ?? null; + currentMailboxId ?? ownMailboxes(mailboxes).find((m) => m.role === 'inbox')?.id ?? null; if (!targetMailboxId || importing) return; let result: DocumentPicker.DocumentPickerResult; try { @@ -460,7 +469,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL }; const handleBulkDelete = async () => { - const trash = findTrashMailbox(mailboxes); + const trash = findTrashMailbox(scopedMailboxes); if (!trash || !currentMailboxId) { clearSelection(); return; @@ -548,7 +557,9 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL React.useEffect(() => { if (mailboxes.length > 0 && !currentMailboxId) { - const inbox = mailboxes.find((m) => m.role === 'inbox') || mailboxes[0]; + const own = ownMailboxes(mailboxes); + const inbox = own.find((m) => m.role === 'inbox') || own[0]; + if (!inbox) return; void selectMailbox(inbox.id); } }, [mailboxes, currentMailboxId, selectMailbox]); @@ -620,7 +631,13 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setDrawerOpen(true)} style={styles.headerButton}> - {currentMailbox?.name ?? 'Inbox'} + + {currentMailbox?.isShared && currentMailbox.accountName + // Name the owning group account — a bare "Inbox" would look like + // the user's own. + ? `${currentMailbox.accountName} · ${currentMailbox.name}` + : currentMailbox?.name ?? 'Inbox'} + { void handleImport(); }} @@ -1013,7 +1030,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setPendingMoveId(null)} - mailboxes={mailboxes} + mailboxes={scopedMailboxes} currentMailboxId={currentMailboxId} onPick={(toId) => { const id = pendingMoveId; @@ -1027,7 +1044,7 @@ export default function EmailListScreen({ onEmailPress, onComposePress }: EmailL setBatchMoveOpen(false)} - mailboxes={mailboxes} + mailboxes={scopedMailboxes} currentMailboxId={currentMailboxId} onPick={handleBatchMovePick} /> diff --git a/src/screens/EmailSourceScreen.tsx b/src/screens/EmailSourceScreen.tsx index de222a37..778a5741 100644 --- a/src/screens/EmailSourceScreen.tsx +++ b/src/screens/EmailSourceScreen.tsx @@ -15,7 +15,9 @@ type Props = NativeStackScreenProps; export default function EmailSourceScreen({ route, navigation }: Props) { const c = useColors(); const styles = React.useMemo(() => makeStyles(c), [c]); - const { blobId, subject } = route.params; + // Blobs are account-scoped, so a message from a shared/group mailbox has + // to be fetched against its owning account. + const { blobId, subject, jmapAccountId } = route.params; const [raw, setRaw] = React.useState(null); const [error, setError] = React.useState(null); @@ -23,18 +25,18 @@ export default function EmailSourceScreen({ route, navigation }: Props) { let cancelled = false; void (async () => { try { - const text = await fetchRawEmail(blobId); + const text = await fetchRawEmail(blobId, jmapAccountId); if (!cancelled) setRaw(text); } catch (e) { if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load email source'); } })(); return () => { cancelled = true; }; - }, [blobId]); + }, [blobId, jmapAccountId]); const onShare = async () => { try { - await shareEmailEml(blobId, undefined, subject); + await shareEmailEml(blobId, undefined, subject, jmapAccountId); } catch (e) { Alert.alert('Share failed', e instanceof Error ? e.message : String(e)); } diff --git a/src/screens/EmailThreadScreen.tsx b/src/screens/EmailThreadScreen.tsx index 14632870..564bdce1 100644 --- a/src/screens/EmailThreadScreen.tsx +++ b/src/screens/EmailThreadScreen.tsx @@ -30,7 +30,7 @@ import { shareEmailEml, shareAttachment, downloadAttachment } from '../lib/email import { useKeywordsStore, keywordToken, type KeywordDef } from '../stores/keywords-store'; import { useSheetDrag } from '../lib/use-sheet-drag'; import { useLocaleStore } from '../stores/locale-store'; -import { findTrashMailbox } from '../lib/mailbox-tree'; +import { findTrashMailbox, mailboxesForSiblingOf } from '../lib/mailbox-tree'; import type { Email, Mailbox } from '../api/types'; import type { RootStackParamList } from '../navigation/types'; @@ -84,6 +84,17 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const currentMailboxId = useEmailStore((s) => s.currentMailboxId); const emails = useEmailStore((s) => s.emails); + // The JMAP account the open message belongs to: the route param when the + // unified inbox opened a group message, otherwise the account behind the + // folder it was opened from. Undefined means the user's own mail. + const ownerAccountId = React.useMemo(() => { + if (jmapAccountId) return jmapAccountId; + const current = currentMailboxId + ? mailboxes.find((m) => m.id === currentMailboxId) + : undefined; + return current?.isShared ? current.accountId : undefined; + }, [jmapAccountId, mailboxes, currentMailboxId]); + const currentIndex = emails.findIndex((e) => e.id === activeEmailId); const prevEmail = currentIndex > 0 ? emails[currentIndex - 1] : null; const nextEmail = currentIndex >= 0 && currentIndex < emails.length - 1 ? emails[currentIndex + 1] : null; @@ -128,13 +139,13 @@ export default function EmailThreadScreen({ route, navigation }: Props) { const ensureDetail = React.useCallback((id: string): Promise => { const pending = inFlight.get(id); if (pending) return pending; - const p = getEmailDetail(id, jmapAccountId) + const p = getEmailDetail(id, ownerAccountId) .then((fetched) => { detailCache.set(id, fetched); bumpCache(); return fetched; }) .catch(() => detailCache.get(id) ?? null) .finally(() => { inFlight.delete(id); }); inFlight.set(id, p); return p; - }, [getEmailDetail, jmapAccountId, detailCache, bumpCache, inFlight]); + }, [getEmailDetail, ownerAccountId, detailCache, bumpCache, inFlight]); // Slide to a page by index and reflect it in the toolbar immediately; a swipe // that settles on the same page confirms the same id via onMomentumEnd. @@ -190,9 +201,9 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setDownloadingBlobId(blobId); try { if (mailAttachmentAction === 'download') { - await downloadAttachment(blobId, name, type, target); + await downloadAttachment(blobId, name, type, target, ownerAccountId); } else { - await shareAttachment(blobId, name, type, target); + await shareAttachment(blobId, name, type, target, ownerAccountId); } } catch (e) { Alert.alert('Download failed', e instanceof Error ? e.message : String(e)); @@ -200,7 +211,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setDownloadingBlobId(null); } }, - [downloadingBlobId, mailAttachmentAction], + [downloadingBlobId, mailAttachmentAction, ownerAccountId], ); const keywordDefs = useKeywordsStore((s) => s.keywords); const hydrateKeywords = useKeywordsStore((s) => s.hydrate); @@ -226,10 +237,10 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!fetched.keywords?.$seen) { if (markAsReadDelay > 0) { readTimer = setTimeout(() => { - if (!cancelled) void markRead(activeEmailId, jmapAccountId); + if (!cancelled) void markRead(activeEmailId, ownerAccountId); }, markAsReadDelay); } else { - void markRead(activeEmailId, jmapAccountId); + void markRead(activeEmailId, ownerAccountId); } } })(); @@ -237,24 +248,33 @@ export default function EmailThreadScreen({ route, navigation }: Props) { cancelled = true; if (readTimer) clearTimeout(readTimer); }; - }, [activeEmailId, ensureDetail, markRead, markAsReadDelay, jmapAccountId, detailCache]); + }, [activeEmailId, ensureDetail, markRead, markAsReadDelay, ownerAccountId, detailCache]); const starred = !!email?.keywords?.$flagged; const unread = !!email && !email.keywords?.$seen; + // Move/archive/spam targets have to live in the same account as the folder + // the message was opened from — a shared (group account) message can't be + // filed into the user's own folders. + const scopedMailboxes = React.useMemo( + () => mailboxesForSiblingOf(mailboxes, currentMailboxId), + [mailboxes, currentMailboxId], + ); const archiveMailbox = React.useMemo( - () => mailboxes.find((m) => m.role === 'archive'), - [mailboxes], + () => scopedMailboxes.find((m) => m.role === 'archive'), + [scopedMailboxes], ); const junkMailbox = React.useMemo( - () => mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'), - [mailboxes], + () => scopedMailboxes.find((m) => m.role === 'junk' || m.role === 'spam'), + [scopedMailboxes], ); const inboxMailbox = React.useMemo( - () => mailboxes.find((m) => m.role === 'inbox'), - [mailboxes], + () => scopedMailboxes.find((m) => m.role === 'inbox'), + [scopedMailboxes], ); - const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]); + // `mailboxIds` comes back from the server unprefixed, so compare on the + // folder's raw id rather than the sidebar key. + const isInJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.originalId ?? junkMailbox.id]); // Optimistically write a message's keywords into the cache (the single source // of truth for every pane) and bump the version so the panes re-render. @@ -271,7 +291,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (next[token]) delete next[token]; else next[token] = true; updateLocalKeywords(email.id, next); - void setEmailKeywords(email.id, next); + void setEmailKeywords(email.id, next, ownerAccountId); }; // Toggle the star on a specific message — used both by the toolbar (current @@ -282,27 +302,27 @@ export default function EmailThreadScreen({ route, navigation }: Props) { else next.$flagged = true; const prev = detailCache.get(target.id); if (prev) { detailCache.set(target.id, { ...prev, keywords: next }); bumpCache(); } - void setEmailKeywords(target.id, next); - }, [detailCache, bumpCache]); + void setEmailKeywords(target.id, next, ownerAccountId); + }, [detailCache, bumpCache, ownerAccountId]); const onToggleStar = () => { if (email) toggleStarFor(email); }; const onToggleUnread = () => { if (!email) return; if (unread) { - void markRead(email.id); + void markRead(email.id, ownerAccountId); updateLocalKeywords(email.id, { ...email.keywords, $seen: true }); } else { const next = { ...email.keywords }; delete next.$seen; updateLocalKeywords(email.id, next); - void setEmailKeywords(email.id, next); + void setEmailKeywords(email.id, next, ownerAccountId); } }; const onDelete = () => { if (!email || !currentMailboxId) return; - const trash = findTrashMailbox(mailboxes); + const trash = findTrashMailbox(scopedMailboxes); if (!trash) { Alert.alert( t('email_list.error', 'Error'), @@ -325,7 +345,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { if (!email || !currentMailboxId) return; setMoreMenuOpen(false); if (isInJunk) { - const target = inboxMailbox ?? mailboxes.find((m) => m.id !== currentMailboxId); + const target = inboxMailbox ?? scopedMailboxes.find((m) => m.id !== currentMailboxId); if (!target) return; void moveToMailbox(email.id, currentMailboxId, target.id); } else { @@ -534,6 +554,7 @@ export default function EmailThreadScreen({ route, navigation }: Props) { setMoveMenuOpen(false)} - mailboxes={mailboxes} + mailboxes={scopedMailboxes} currentMailboxId={currentMailboxId} onPick={onMoveToMailbox} /> @@ -640,6 +662,8 @@ export default function EmailThreadScreen({ route, navigation }: Props) { interface EmailPaneProps { id: string; email: Email | null; + /** Owning account when the message lives in a shared/group mailbox. */ + jmapAccountId?: string; ensureDetail: (id: string) => Promise; c: ThemePalette; styles: ReturnType; @@ -659,7 +683,7 @@ interface EmailPaneProps { // scroll position and "show all attachments" toggle, and renders directly from // the email passed to it — neighbours show real content, not a placeholder. function EmailPane({ - id, email, ensureDetail, c, styles, bottomBarHeight, attachmentPosition, + id, email, jmapAccountId, ensureDetail, c, styles, bottomBarHeight, attachmentPosition, hideInlineImageAttachments, downloadingBlobId, onToggleStar, onPressAttachment, onSwipe, onZoomChange, }: EmailPaneProps) { @@ -806,6 +830,7 @@ function EmailPane({ { setPinching(z.pinching); onZoomChange(z); }} /> diff --git a/src/stores/__tests__/email-store.test.ts b/src/stores/__tests__/email-store.test.ts index 1782c3af..4d9b7489 100644 --- a/src/stores/__tests__/email-store.test.ts +++ b/src/stores/__tests__/email-store.test.ts @@ -5,6 +5,7 @@ vi.mock('../../api/email', () => ({ // New helpers used by the incremental-sync path. Default behavior: behave // like a first-ever load — no prior state, full re-query expected. getMailboxesWithState: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), + getSharedMailboxes: vi.fn(async () => []), getMailboxesByIds: vi.fn(async () => ({ list: [], state: 'mb-state-0' })), getMailboxChanges: vi.fn(async () => null), queryEmails: vi.fn(), @@ -24,6 +25,8 @@ vi.mock('../../api/email', () => ({ deleteEmail: vi.fn(), deleteEmails: vi.fn(), searchEmails: vi.fn(), + unprefixMailboxId: (id: string, accountId?: string) => + (accountId && id.startsWith(`${accountId}:`) ? id.slice(accountId.length + 1) : id), })); // The mutations now route through the offline outbox. In tests we want the @@ -31,11 +34,11 @@ vi.mock('../../api/email', () => ({ // op's primitive) immediately so the existing api-call assertions still hold, // without pulling in network-store / NetInfo. vi.mock('../outbox-store', async () => { - const api = await import('../../api/email') as Record Promise>; - const runOp = async (op: { kind: string; emailId: string; keywords?: unknown; mailboxIds?: unknown }) => { - if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords); - if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds); - if (op.kind === 'destroy') return api.destroyEmails([op.emailId]); + const api = await import('../../api/email') as unknown as Record Promise>; + const runOp = async (op: { kind: string; emailId: string; accountId?: string; keywords?: unknown; mailboxIds?: unknown }) => { + if (op.kind === 'keywords') return api.setEmailKeywords(op.emailId, op.keywords, op.accountId); + if (op.kind === 'mailboxes') return api.setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId); + if (op.kind === 'destroy') return api.destroyEmails([op.emailId], op.accountId); }; const applyOrQueueBatch = async (ops: any[], onlineRun?: () => Promise) => { if (onlineRun) await onlineRun(); @@ -100,6 +103,8 @@ vi.mock('../../api/jmap-client', () => ({ currentSession: { apiUrl: 'https://mail.example.com/jmap/' }, // refreshEmails / loadMoreEmails chunk by this value when fetching ids. getMaxObjectsInGet: () => 500, + getMaxCallsInRequest: () => 16, + getSharedMailAccounts: () => [{ id: 'grp-1', name: 'Support' }], }, })); @@ -110,6 +115,7 @@ import * as emailApi from '../../api/email'; import { useEmailStore } from '../email-store'; const mockGetMailboxesWithState = emailApi.getMailboxesWithState as ReturnType; +const mockGetSharedMailboxes = emailApi.getSharedMailboxes as ReturnType; const mockQueryEmails = emailApi.queryEmails as ReturnType; const mockGetEmailQueryChanges = emailApi.getEmailQueryChanges as ReturnType; const mockGetEmails = emailApi.getEmails as ReturnType; @@ -224,7 +230,7 @@ describe('email-store', () => { await useEmailStore.getState().markRead('e1'); - expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }); + expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); expect(useEmailStore.getState().emails[0].keywords.$seen).toBe(true); }); }); @@ -238,7 +244,7 @@ describe('email-store', () => { await useEmailStore.getState().markUnread('e1'); - expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }); + expect(mockSetKeywords).toHaveBeenCalledWith('e1', { $flagged: true }, undefined); expect(useEmailStore.getState().emails[0].keywords.$seen).toBeUndefined(); }); }); @@ -276,7 +282,7 @@ describe('email-store', () => { await useEmailStore.getState().moveToMailbox('e1', 'inbox', 'archive'); - expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive'); + expect(mockMoveEmail).toHaveBeenCalledWith('e1', 'inbox', 'archive', undefined); expect(useEmailStore.getState().emails).toHaveLength(1); expect(useEmailStore.getState().emails[0].id).toBe('e2'); }); @@ -467,4 +473,91 @@ describe('email-store', () => { expect(result).toEqual(fullEmail); }); }); + // ── Shared (Stalwart group account) mailboxes ──────────────────────────── + describe('shared mailboxes', () => { + const RIGHTS = { + mayReadItems: true, mayAddItems: true, mayRemoveItems: true, + maySetSeen: true, maySetKeywords: true, mayCreateChild: true, + mayRename: true, mayDelete: true, maySubmit: true, + }; + const ownInbox = { + id: 'mb-1', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0, + totalThreads: 0, unreadThreads: 0, myRights: RIGHTS, + accountId: 'acc-1', isShared: false, + } as any; + const sharedInbox = { + id: 'grp-1:s-inbox', originalId: 's-inbox', name: 'Inbox', role: 'inbox', + totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, + myRights: RIGHTS, accountId: 'grp-1', accountName: 'Support', isShared: true, + } as any; + const sharedTrash = { + ...sharedInbox, id: 'grp-1:s-trash', originalId: 's-trash', name: 'Trash', role: 'trash', + } as any; + + it('merges shared folders in alongside the own ones', async () => { + mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' }); + mockGetSharedMailboxes.mockResolvedValue([sharedInbox]); + + await useEmailStore.getState().fetchMailboxes(); + + expect(useEmailStore.getState().mailboxes).toEqual([ownInbox, sharedInbox]); + }); + + it('keeps the own folders when a shared account cannot be reached', async () => { + mockGetMailboxesWithState.mockResolvedValue({ list: [ownInbox], state: 'mb-1' }); + mockGetSharedMailboxes.mockRejectedValue(new Error('Session expired')); + + await useEmailStore.getState().fetchMailboxes(); + + const state = useEmailStore.getState(); + expect(state.mailboxes).toEqual([ownInbox]); + expect(state.error).toBeNull(); + }); + + it('queries a shared folder against its owning account by raw id', async () => { + useEmailStore.setState({ mailboxes: [ownInbox, sharedInbox] }); + mockQueryEmails.mockResolvedValue({ ids: ['e1'], total: 1, queryState: 'q-1' }); + mockGetEmailsWithState.mockResolvedValue({ + list: [{ id: 'e1', subject: 'Hi' }], state: 'em-1', + }); + + await useEmailStore.getState().selectMailbox('grp-1:s-inbox'); + + expect(mockQueryEmails).toHaveBeenCalledWith('s-inbox', expect.objectContaining({ + accountId: 'grp-1', + filter: { inMailbox: 's-inbox' }, + })); + expect(mockGetEmailsWithState).toHaveBeenCalledWith(['e1'], 'grp-1'); + // Email state tokens are per-account, so the shared account gets its own. + expect(useEmailStore.getState().emailStates).toEqual({ 'grp-1': 'em-1' }); + }); + + it('deletes from a shared folder against the owning account', async () => { + useEmailStore.setState({ + mailboxes: [ownInbox, sharedInbox, sharedTrash], + currentMailboxId: 'grp-1:s-inbox', + emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any], + }); + + await useEmailStore.getState().deleteEmail('e1', 'grp-1:s-trash', 'grp-1:s-inbox'); + + expect(mockDeleteEmail).toHaveBeenCalledWith('e1', 's-trash', 's-inbox', 'grp-1'); + expect(useEmailStore.getState().emails).toHaveLength(0); + }); + + it('refuses to move a message between accounts', async () => { + useEmailStore.setState({ + mailboxes: [ownInbox, sharedInbox], + currentMailboxId: 'grp-1:s-inbox', + emails: [{ id: 'e1', keywords: {}, mailboxIds: { 's-inbox': true } } as any], + }); + + await useEmailStore.getState().moveToMailbox('e1', 'grp-1:s-inbox', 'mb-1'); + + expect(mockMoveEmail).not.toHaveBeenCalled(); + const state = useEmailStore.getState(); + expect(state.error).toMatch(/same account/); + expect(state.emails).toHaveLength(1); + }); + }); }); diff --git a/src/stores/__tests__/outbox-store.test.ts b/src/stores/__tests__/outbox-store.test.ts index f7d4af6d..0b7b8984 100644 --- a/src/stores/__tests__/outbox-store.test.ts +++ b/src/stores/__tests__/outbox-store.test.ts @@ -79,7 +79,7 @@ describe('applyOrQueue', () => { it('runs immediately when online with an empty queue', async () => { const result = await applyOrQueue({ kind: 'keywords', emailId: 'e1', keywords: { $seen: true } }); expect(result.queued).toBe(false); - expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }); + expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); expect(useOutboxStore.getState().entries).toHaveLength(0); }); @@ -137,9 +137,9 @@ describe('flush', () => { useNetworkStore.setState({ online: true }); await useOutboxStore.getState().flush(); - expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }); - expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }); - expect(destroyEmails).toHaveBeenCalledWith(['e3']); + expect(setEmailKeywords).toHaveBeenCalledWith('e1', { $seen: true }, undefined); + expect(setEmailMailboxes).toHaveBeenCalledWith('e2', { trash: true }, undefined); + expect(destroyEmails).toHaveBeenCalledWith(['e3'], undefined); expect(useOutboxStore.getState().entries).toHaveLength(0); }); diff --git a/src/stores/email-store.ts b/src/stores/email-store.ts index 4c1fab1e..8e5c0661 100644 --- a/src/stores/email-store.ts +++ b/src/stores/email-store.ts @@ -6,6 +6,7 @@ import { jmapClient } from '../api/jmap-client'; import { getMailboxes as fetchMailboxes, getMailboxesWithState, + getSharedMailboxes, getMailboxesByIds, getMailboxChanges, queryEmails, @@ -24,7 +25,9 @@ import { deleteEmails as apiDeleteEmails, restoreEmailMailboxes, searchEmails as apiSearchEmails, + unprefixMailboxId, } from '../api/email'; +import { mailboxesForSiblingOf } from '../lib/mailbox-tree'; import { toWildcardQuery } from '../lib/search-utils'; import { generateAccountId } from '../lib/account-utils'; import { useSettingsStore } from './settings-store'; @@ -54,6 +57,69 @@ function mailboxesAfterMove( return next; } +// Where a folder actually lives. The user's own folders keep their raw JMAP id +// and get no account override, so every existing call path stays exactly as it +// was; a shared (Stalwart group account) folder resolves to its owning account +// plus the unprefixed id the server knows it by. +interface MailboxRef { + /** JMAP account override — undefined for the user's own folders. */ + accountId?: string; + /** Id to send to the server, with any `:` prefix stripped. */ + id: string; +} + +function refFor(mailboxes: Mailbox[], mailboxId: string): MailboxRef { + const mailbox = mailboxes.find((m) => m.id === mailboxId); + if (!mailbox?.isShared) return { id: mailboxId }; + return { accountId: mailbox.accountId, id: mailbox.originalId ?? mailboxId }; +} + +function rawMailboxId(mailboxes: Mailbox[], mailboxId: string): string { + return refFor(mailboxes, mailboxId).id; +} + +// The JMAP account behind the folder currently on screen. Undefined for the +// user's own folders, which keeps every own-mail call on the default path. +function currentAccountId(state: EmailState): string | undefined { + if (!state.currentMailboxId) return undefined; + return refFor(state.mailboxes, state.currentMailboxId).accountId; +} + +// Strip the shared-folder id prefix off a whole list. Server-side folder +// matching (archive year/month auto-foldering) compares ids and parent links +// against what Mailbox/set returns, which is always unprefixed. +function toRawMailboxes(mailboxes: Mailbox[]): Mailbox[] { + return mailboxes.map((m) => (m.isShared + ? { + ...m, + id: m.originalId ?? m.id, + parentId: m.parentId ? unprefixMailboxId(m.parentId, m.accountId) : m.parentId, + } + : m)); +} + +// Key for `emailStates`. JMAP Email state tokens are per-account, so the +// primary account and each shared account track their own. +const PRIMARY_STATE_KEY = '@primary'; +function stateKey(accountId?: string): string { + return accountId ?? PRIMARY_STATE_KEY; +} + +// Record (or forget, when the server said cannotCalculateChanges) one +// account's Email state without disturbing the others'. +function withEmailState( + states: Record, + accountId: string | undefined, + value: string | undefined, +): Record { + const key = stateKey(accountId); + if (value === undefined) { + const { [key]: _drop, ...rest } = states; + return rest; + } + return { ...states, [key]: value }; +} + // True only when the JMAP client is actually serving the email-store's active // account. During an account switch there's a window between // setActiveAccount() (which swaps the email-store view immediately) and @@ -91,6 +157,8 @@ export interface UndoEntry { label: string; /** Time the entry was created - the snackbar uses this to drive its timer. */ createdAt: number; + /** JMAP account the messages live under; unset for the user's own mail. */ + accountId?: string; /** Each item is one email's pre-action mailboxIds, used to restore it. */ items: Array<{ email: Email; originalMailboxIds: Record }>; } @@ -109,7 +177,9 @@ interface MailboxSnapshot { interface AccountSnapshot { mailboxes: Mailbox[]; mailboxState?: string; // JMAP Mailbox state (drives Mailbox/changes) - emailState?: string; // JMAP Email state (drives Email/changes) + // JMAP Email state per JMAP account (drives Email/changes). Keyed by + // `stateKey()`: the primary account plus any shared/group accounts. + emailStates: Record; currentMailboxId: string | null; mailboxSnapshots: Record; } @@ -118,7 +188,7 @@ export interface EmailState { // ── Per-account persisted caches ────────────────────────────── // accountSnapshots is the source of truth for accounts the user is *not* // currently viewing. The active account's data lives in the top-level - // fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailState`, + // fields below (`mailboxes`, `mailboxSnapshots`, `mailboxState`, `emailStates`, // `currentMailboxId`, `emails`, `totalEmails`, `queryState`) so consumers // keep reading the same shape they always have. accountSnapshots: Record; @@ -127,7 +197,7 @@ export interface EmailState { // ── Active view (the currently-shown account/mailbox) ───────── mailboxes: Mailbox[]; mailboxState?: string; - emailState?: string; + emailStates: Record; currentMailboxId: string | null; mailboxSnapshots: Record; emails: Email[]; @@ -251,7 +321,7 @@ function snapshotFromActive(state: EmailState): AccountSnapshot { return { mailboxes: state.mailboxes, mailboxState: state.mailboxState, - emailState: state.emailState, + emailStates: state.emailStates, currentMailboxId: state.currentMailboxId, mailboxSnapshots, }; @@ -260,7 +330,7 @@ function snapshotFromActive(state: EmailState): AccountSnapshot { function viewFromSnapshot(snap: AccountSnapshot | null): { mailboxes: Mailbox[]; mailboxState?: string; - emailState?: string; + emailStates: Record; currentMailboxId: string | null; mailboxSnapshots: Record; emails: Email[]; @@ -271,7 +341,7 @@ function viewFromSnapshot(snap: AccountSnapshot | null): { return { mailboxes: [], mailboxState: undefined, - emailState: undefined, + emailStates: {}, currentMailboxId: null, mailboxSnapshots: {}, emails: [], @@ -285,7 +355,7 @@ function viewFromSnapshot(snap: AccountSnapshot | null): { return { mailboxes: snap.mailboxes, mailboxState: snap.mailboxState, - emailState: snap.emailState, + emailStates: snap.emailStates ?? {}, currentMailboxId: snap.currentMailboxId, mailboxSnapshots: snap.mailboxSnapshots, emails: mailboxSnap?.emails ?? [], @@ -297,14 +367,14 @@ function viewFromSnapshot(snap: AccountSnapshot | null): { // JMAP's maxObjectsInGet bounds how many ids we can pull in one Email/get. // Chunk to that ceiling (with a small safety fallback) so large change sets // don't trip 429/413 responses. -async function fetchEmailsChunked(ids: string[]): Promise { +async function fetchEmailsChunked(ids: string[], accountId?: string): Promise { if (ids.length === 0) return []; const cap = Math.max(1, jmapClient.getMaxObjectsInGet()); const chunk = Math.min(cap, 200); - if (ids.length <= chunk) return fetchEmails(ids); + if (ids.length <= chunk) return fetchEmails(ids, accountId); const out: Email[] = []; for (let i = 0; i < ids.length; i += chunk) { - const slice = await fetchEmails(ids.slice(i, i + chunk)); + const slice = await fetchEmails(ids.slice(i, i + chunk), accountId); out.push(...slice); } return out; @@ -340,7 +410,7 @@ export const useEmailStore = create()( mailboxes: [], mailboxState: undefined, - emailState: undefined, + emailStates: {}, currentMailboxId: null, mailboxSnapshots: {}, emails: [], @@ -403,7 +473,7 @@ export const useEmailStore = create()( activeAccountId: null, mailboxes: [], mailboxState: undefined, - emailState: undefined, + emailStates: {}, currentMailboxId: null, mailboxSnapshots: {}, emails: [], @@ -426,7 +496,7 @@ export const useEmailStore = create()( activeAccountId: null, mailboxes: [], mailboxState: undefined, - emailState: undefined, + emailStates: {}, currentMailboxId: null, mailboxSnapshots: {}, emails: [], @@ -455,16 +525,30 @@ export const useEmailStore = create()( if (!jmapClientServesActiveAccount(activeAccountId)) return; const prevState = get().mailboxState; + // Swap in a freshly-synced set of own folders while leaving the shared + // (group account) ones alone, and vice versa — the two are fetched by + // separate calls and must not clobber each other. + const replaceOwn = (own: Mailbox[], mailboxState?: string) => { + set({ + mailboxes: [...own, ...get().mailboxes.filter((m) => m.isShared)], + ...(mailboxState !== undefined ? { mailboxState } : {}), + }); + }; + try { + let drainAgain = false; + // Incremental path: ask for just what changed since last time. Fall // through to a full refetch when the server can't compute the diff or // we have no previous state to compare against. + let syncedOwn = false; if (prevState) { const changes = await getMailboxChanges(prevState); // Bail if the user switched accounts during the await — anything we // set() now would land in the wrong account's bucket. if (get().activeAccountId !== activeAccountId) return; if (changes) { + syncedOwn = true; // No changes at all — keep the cached list, just bump the state. if ( changes.created.length === 0 && @@ -472,35 +556,50 @@ export const useEmailStore = create()( changes.destroyed.length === 0 ) { set({ mailboxState: changes.newState }); - return; + } else { + const toFetch = [...changes.created, ...changes.updated]; + const fetched = toFetch.length > 0 + ? (await getMailboxesByIds(toFetch)).list + : []; + if (get().activeAccountId !== activeAccountId) return; + const destroyed = new Set(changes.destroyed); + const byId = new Map(); + for (const m of get().mailboxes) { + if (!m.isShared) byId.set(m.id, m); + } + for (const m of fetched) byId.set(m.id, m); + for (const id of destroyed) byId.delete(id); + replaceOwn( + Array.from(byId.values()), + changes.hasMoreChanges ? prevState : changes.newState, + ); + // hasMoreChanges = there are still pending changes past the + // server's response cap. Run the same path again to drain. + drainAgain = changes.hasMoreChanges; } - const toFetch = [...changes.created, ...changes.updated]; - const fetched = toFetch.length > 0 - ? (await getMailboxesByIds(toFetch)).list - : []; - if (get().activeAccountId !== activeAccountId) return; - const destroyed = new Set(changes.destroyed); - const byId = new Map(); - for (const m of get().mailboxes) byId.set(m.id, m); - for (const m of fetched) byId.set(m.id, m); - for (const id of destroyed) byId.delete(id); - set({ - mailboxes: Array.from(byId.values()), - mailboxState: changes.hasMoreChanges ? prevState : changes.newState, - }); - // hasMoreChanges = there are still pending changes past the - // server's response cap. Run the same path again to drain. - if (changes.hasMoreChanges) { - void get().fetchMailboxes(); - } - return; } // changes === null → cannotCalculateChanges. Fall through to full. } - const { list, state } = await getMailboxesWithState(); - if (get().activeAccountId !== activeAccountId) return; - set({ mailboxes: list, mailboxState: state }); + if (!syncedOwn) { + const { list, state } = await getMailboxesWithState(); + if (get().activeAccountId !== activeAccountId) return; + replaceOwn(list, state); + } + + // Shared/group accounts have their own Mailbox state tokens, and there + // are only ever a handful of them, so they're re-read in full rather + // than diffed. Failing to reach one must not lose the own folders we + // just synced, hence the separate try. + try { + const shared = await getSharedMailboxes(); + if (get().activeAccountId !== activeAccountId) return; + set({ mailboxes: [...get().mailboxes.filter((m) => !m.isShared), ...shared] }); + } catch (err) { + console.warn('[email-store] shared mailbox fetch failed:', err); + } + + if (drainAgain) void get().fetchMailboxes(); } catch (err) { console.warn('[email-store] fetchMailboxes failed:', err); if (get().activeAccountId !== activeAccountId) return; @@ -550,7 +649,12 @@ export const useEmailStore = create()( if (cacheStore.totalCount() > 0) { try { const limit = useSettingsStore.getState().emailsPerPage; - seededEmails = await cacheStore.getEmailsInMailbox(mailboxId, Math.max(limit, 50)); + // Cached messages carry raw JMAP mailboxIds, so look up by the + // unprefixed id rather than the sidebar's shared-folder key. + seededEmails = await cacheStore.getEmailsInMailbox( + rawMailboxId(state.mailboxes, mailboxId), + Math.max(limit, 50), + ); seededTotal = seededEmails.length; } catch (err) { console.warn('[email-store] cache seed failed:', err); @@ -590,15 +694,17 @@ export const useEmailStore = create()( set({ loading: true }); try { - const filter = buildJmapFilter(currentMailboxId, searchQuery, filters); + const ref = refFor(get().mailboxes, currentMailboxId); + const filter = buildJmapFilter(ref.id, searchQuery, filters); const limit = useSettingsStore.getState().emailsPerPage; - const { ids } = await queryEmails(currentMailboxId, { + const { ids } = await queryEmails(ref.id, { position: emails.length, limit, filter, + accountId: ref.accountId, }); if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; - const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids) : []; + const newEmails = ids.length > 0 ? await fetchEmailsChunked(ids, ref.accountId) : []; if (get().activeAccountId !== activeAccountId || get().currentMailboxId !== currentMailboxId) return; const merged = [...emails, ...newEmails]; const updates: Partial = { emails: merged, loading: false }; @@ -624,6 +730,9 @@ export const useEmailStore = create()( // import time (that native dep can't load in the test/SSR environment). const { uploadBytes } = await import('../api/blob'); const { expandImportableEml } = await import('../lib/eml-import'); + // Both the blob upload and the import have to target the folder's owning + // account, or the import references a blob the server can't see. + const ref = refFor(get().mailboxes, mailboxId); let imported = 0; let failed = 0; for (const file of files) { @@ -633,8 +742,8 @@ export const useEmailStore = create()( if (emls.length === 0) failed += 1; for (const eml of emls) { try { - const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822'); - await importEmailBlob(blobId, mailboxId); + const { blobId } = await uploadBytes(eml.bytes, 'message/rfc822', ref.accountId); + await importEmailBlob(blobId, ref.id, undefined, ref.accountId); imported += 1; } catch { failed += 1; @@ -653,12 +762,16 @@ export const useEmailStore = create()( refreshEmails: async () => { const state = get(); - const { currentMailboxId, searchQuery, filters, emails: existing, emailState, activeAccountId } = state; + const { currentMailboxId, searchQuery, filters, emails: existing, activeAccountId } = state; if (!currentMailboxId) return; if (!jmapClientServesActiveAccount(activeAccountId)) return; set({ loading: true, error: null }); - const filter = buildJmapFilter(currentMailboxId, searchQuery, filters); + // A shared (group account) folder is queried against its owning account + // with its unprefixed id; own folders resolve to no override at all. + const ref = refFor(state.mailboxes, currentMailboxId); + const emailState = state.emailStates[stateKey(ref.accountId)]; + const filter = buildJmapFilter(ref.id, searchQuery, filters); const limit = useSettingsStore.getState().emailsPerPage; const baseView = isBaseView(searchQuery, filters); @@ -692,8 +805,9 @@ export const useEmailStore = create()( snap.emails.length >= Math.min(limit, snap.total) ) { const baseEmails = snap.emails; - const queryChanges = await getEmailQueryChanges(currentMailboxId, snap.queryState, { + const queryChanges = await getEmailQueryChanges(ref.id, snap.queryState, { filter: undefined, + accountId: ref.accountId, }); if (queryChanges) { // What's in the visible window now: drop removed ids, then apply @@ -709,7 +823,7 @@ export const useEmailStore = create()( let destroyedExtra: string[] = []; let nextEmailState: string | undefined = emailState; if (emailState) { - const ec = await getEmailChanges(emailState); + const ec = await getEmailChanges(emailState, undefined, ref.accountId); if (ec) { updatedIds = ec.updated; destroyedExtra = ec.destroyed; @@ -732,7 +846,7 @@ export const useEmailStore = create()( let fetchState: string | undefined; let fetched: Email[] = []; if (idsToFetch.length > 0) { - const res = await getEmailsWithState(idsToFetch); + const res = await getEmailsWithState(idsToFetch, ref.accountId); fetched = res.list; fetchState = res.state; } @@ -768,7 +882,11 @@ export const useEmailStore = create()( emails: trimmed, totalEmails: nextTotal, queryState: nextQueryState, - emailState: nextEmailState ?? fetchState ?? emailState, + emailStates: withEmailState( + get().emailStates, + ref.accountId, + nextEmailState ?? fetchState ?? emailState, + ), loading: false, mailboxSnapshots: { ...get().mailboxSnapshots, @@ -788,9 +906,9 @@ export const useEmailStore = create()( // Full re-query path. Used when there's no prior queryState, when the // user has search/filters active (queryState only tracks the base // query), or when the server returned cannotCalculateChanges above. - const queryRes = await queryEmails(currentMailboxId, { limit, filter }); + const queryRes = await queryEmails(ref.id, { limit, filter, accountId: ref.accountId }); const fetched = queryRes.ids.length > 0 - ? await getEmailsWithState(queryRes.ids) + ? await getEmailsWithState(queryRes.ids, ref.accountId) : { list: [], state: undefined as string | undefined }; if (viewChanged()) return; @@ -802,7 +920,7 @@ export const useEmailStore = create()( }; if (baseView) { updates.queryState = queryRes.queryState; - updates.emailState = fetched.state; + updates.emailStates = withEmailState(get().emailStates, ref.accountId, fetched.state); updates.mailboxSnapshots = { ...get().mailboxSnapshots, [currentMailboxId]: { @@ -825,7 +943,7 @@ export const useEmailStore = create()( if (!cacheStore.hydrated) await cacheStore.hydrate(); if (cacheStore.totalCount() > 0) { const cached = await cacheStore.getEmailsInMailbox( - currentMailboxId, + ref.id, Math.max(limit, 50), ); if ( @@ -854,12 +972,26 @@ export const useEmailStore = create()( // currently showing (e.g. push notifications received during/just after // an account switch). if (!jmapClientServesActiveAccount(get().activeAccountId)) return; - const accountId = jmapClient.accountId; - const accountChanges = change.changed?.[accountId]; - if (!accountChanges) return; + // Push/EventSource state changes cover every account in the session, so a + // shared (group account) mailbox reports under its own account id. Fold + // them all in: any account's Mailbox change refreshes the folder list, but + // only the account behind the open folder needs its message list re-read. + const primaryId = jmapClient.accountId; + const state = get(); + const currentAccountId = + state.currentMailboxId + ? refFor(state.mailboxes, state.currentMailboxId).accountId ?? primaryId + : primaryId; + const known = new Set([primaryId, ...jmapClient.getSharedMailAccounts().map((a) => a.id)]); - const mailboxChanged = 'Mailbox' in accountChanges; - const emailChanged = 'Email' in accountChanges || 'EmailDelivery' in accountChanges; + let mailboxChanged = false; + let emailChanged = false; + for (const [accountId, accountChanges] of Object.entries(change.changed ?? {})) { + if (!known.has(accountId) || !accountChanges) continue; + if ('Mailbox' in accountChanges) mailboxChanged = true; + if (accountId !== currentAccountId) continue; + if ('Email' in accountChanges || 'EmailDelivery' in accountChanges) emailChanged = true; + } if (!mailboxChanged && !emailChanged) return; if (mailboxChanged) { @@ -925,7 +1057,8 @@ export const useEmailStore = create()( }, markRead: async (emailId, accountId) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); const nextKeywords = { ...(email?.keywords ?? {}), $seen: true }; // A group/shared message opened from the unified inbox lives under another // JMAP account and isn't in the active list/cache or the (account-scoped) @@ -934,7 +1067,8 @@ export const useEmailStore = create()( await setEmailKeywords(emailId, nextKeywords, accountId); return; } - await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords }); + const owner = accountId ?? currentAccountId(state); + await applyOrQueue({ kind: 'keywords', emailId, accountId: owner, keywords: nextKeywords }); set({ emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords: nextKeywords } : e, @@ -944,10 +1078,16 @@ export const useEmailStore = create()( }, markUnread: async (emailId) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); if (!email) return; const { $seen, ...rest } = email.keywords; - await applyOrQueue({ kind: 'keywords', emailId, keywords: rest }); + await applyOrQueue({ + kind: 'keywords', + emailId, + accountId: currentAccountId(state), + keywords: rest, + }); set({ emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords: rest } : e, @@ -957,7 +1097,8 @@ export const useEmailStore = create()( }, toggleStar: async (emailId, starred) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; if (starred) { @@ -965,7 +1106,12 @@ export const useEmailStore = create()( } else { delete keywords.$flagged; } - await applyOrQueue({ kind: 'keywords', emailId, keywords }); + await applyOrQueue({ + kind: 'keywords', + emailId, + accountId: currentAccountId(state), + keywords, + }); set({ emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords } : e, @@ -975,7 +1121,8 @@ export const useEmailStore = create()( }, togglePin: async (emailId, pinned) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); if (!email) return; const keywords = { ...email.keywords }; if (pinned) { @@ -983,7 +1130,12 @@ export const useEmailStore = create()( } else { delete keywords.$important; } - await applyOrQueue({ kind: 'keywords', emailId, keywords }); + await applyOrQueue({ + kind: 'keywords', + emailId, + accountId: currentAccountId(state), + keywords, + }); set({ emails: get().emails.map((e) => e.id === emailId ? { ...e, keywords } : e, @@ -993,13 +1145,23 @@ export const useEmailStore = create()( }, moveToMailbox: async (emailId, fromMailboxId, toMailboxId) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); + const from = refFor(state.mailboxes, fromMailboxId); + const to = refFor(state.mailboxes, toMailboxId); + // A single Email/set is scoped to one account, so there's no move between + // the user's own folders and a shared account's (or between two shared + // accounts) — that would be a copy-then-delete across accounts. + if (from.accountId !== to.accountId) { + set({ error: 'Messages can only be moved within the same account' }); + return; + } const original = email ? { ...email.mailboxIds } : null; - const target = mailboxesAfterMove(email?.mailboxIds, fromMailboxId, toMailboxId); + const target = mailboxesAfterMove(email?.mailboxIds, from.id, to.id); await applyOrQueue( - { kind: 'mailboxes', emailId, mailboxIds: target }, - () => moveEmail(emailId, fromMailboxId, toMailboxId), + { kind: 'mailboxes', emailId, accountId: from.accountId, mailboxIds: target }, + () => moveEmail(emailId, from.id, to.id, from.accountId), ); set({ emails: get().emails.filter((e) => e.id !== emailId) }); patchCache(emailId, { mailboxIds: target }); @@ -1011,6 +1173,7 @@ export const useEmailStore = create()( kind: 'move', label: targetName ? `Email moved to ${targetName}` : 'Email moved', createdAt: Date.now(), + accountId: from.accountId, items: [{ email, originalMailboxIds: original }], }, }); @@ -1018,15 +1181,19 @@ export const useEmailStore = create()( }, archiveEmail: async (emailId) => { - const { emails, mailboxes } = get(); - const email = emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); if (!email) return; - const archiveMailbox = mailboxes.find( + // Archive into the *same account's* Archive folder — a shared mailbox's + // messages can't be filed into the user's own. + const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId); + const archiveMailbox = scoped.find( (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive', ); if (!archiveMailbox) return; - if (email.mailboxIds?.[archiveMailbox.id]) return; + const archive = refFor(state.mailboxes, archiveMailbox.id); + if (email.mailboxIds?.[archive.id]) return; const mode = useSettingsStore.getState().archiveMode; const original = { ...email.mailboxIds }; @@ -1035,12 +1202,18 @@ export const useEmailStore = create()( // archive root (we can't create folders without a connection); the queued // op replays as a plain move into Archive. const { queued } = await applyOrQueue( - { kind: 'mailboxes', emailId, mailboxIds: { [archiveMailbox.id]: true } }, + { + kind: 'mailboxes', + emailId, + accountId: archive.accountId, + mailboxIds: { [archive.id]: true }, + }, () => apiArchiveEmails( [{ id: email.id, receivedAt: email.receivedAt }], - archiveMailbox.id, + archive.id, mode, - mailboxes, + toRawMailboxes(scoped), + archive.accountId, ), ); @@ -1050,10 +1223,11 @@ export const useEmailStore = create()( kind: 'archive', label: 'Email archived', createdAt: Date.now(), + accountId: archive.accountId, items: [{ email, originalMailboxIds: original }], }, }); - patchCache(emailId, { mailboxIds: { [archiveMailbox.id]: true } }); + patchCache(emailId, { mailboxIds: { [archive.id]: true } }); // Auto-sort modes may have created new year/month folders - refresh the // mailbox list so the sidebar picks them up on the next render. Skip when @@ -1064,12 +1238,16 @@ export const useEmailStore = create()( }, deleteEmail: async (emailId, trashMailboxId, currentMailboxId) => { - const email = get().emails.find((e) => e.id === emailId); + const state = get(); + const email = state.emails.find((e) => e.id === emailId); const original = email ? { ...email.mailboxIds } : null; const settings = useSettingsStore.getState(); - const { mailboxes } = get(); - const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'); - const inJunk = !!(junkMailbox && email?.mailboxIds?.[junkMailbox.id]); + const trash = refFor(state.mailboxes, trashMailboxId); + const source = refFor(state.mailboxes, currentMailboxId); + const junkMailbox = mailboxesForSiblingOf(state.mailboxes, currentMailboxId) + .find((m) => m.role === 'junk' || m.role === 'spam'); + const junkId = junkMailbox ? rawMailboxId(state.mailboxes, junkMailbox.id) : null; + const inJunk = !!(junkId && email?.mailboxIds?.[junkId]); const inTrash = currentMailboxId === trashMailboxId; // Resolve effective destination: @@ -1086,21 +1264,26 @@ export const useEmailStore = create()( // Use the trash mailbox as the "current" so apiDeleteEmail takes the // destroy branch even when the source folder isn't trash. await applyOrQueue( - { kind: 'destroy', emailId }, - () => apiDeleteEmail(emailId, trashMailboxId, trashMailboxId), + { kind: 'destroy', emailId, accountId: trash.accountId }, + () => apiDeleteEmail(emailId, trash.id, trash.id, trash.accountId), ); dropFromCache([emailId]); } else { - const target = mailboxesAfterMove(email?.mailboxIds, currentMailboxId, trashMailboxId); + const target = mailboxesAfterMove(email?.mailboxIds, source.id, trash.id); await applyOrQueue( - { kind: 'mailboxes', emailId, mailboxIds: target }, - () => apiDeleteEmail(emailId, trashMailboxId, currentMailboxId), + { kind: 'mailboxes', emailId, accountId: source.accountId, mailboxIds: target }, + () => apiDeleteEmail(emailId, trash.id, source.id, source.accountId), ); // "Move to Trash and mark as read" (#323): when the user picked that // delete action, also clear unread state for messages moved to trash. if (settings.deleteAction === 'trash-and-read' && email && !email.keywords?.$seen) { const nextKeywords = { ...email.keywords, $seen: true }; - await applyOrQueue({ kind: 'keywords', emailId, keywords: nextKeywords }); + await applyOrQueue({ + kind: 'keywords', + emailId, + accountId: source.accountId, + keywords: nextKeywords, + }); patchCache(emailId, { mailboxIds: target, keywords: nextKeywords }); } else { patchCache(emailId, { mailboxIds: target }); @@ -1116,6 +1299,7 @@ export const useEmailStore = create()( kind: 'delete', label: 'Email moved to Trash', createdAt: Date.now(), + accountId: source.accountId, items: [{ email, originalMailboxIds: original }], }, }); @@ -1127,27 +1311,35 @@ export const useEmailStore = create()( // so a multi-select archive/move/delete is reversed with one snackbar tap. archiveEmailsBatch: async (emailIds) => { - const { emails, mailboxes } = get(); - const archiveMailbox = mailboxes.find( + const state = get(); + const scoped = mailboxesForSiblingOf(state.mailboxes, state.currentMailboxId); + const archiveMailbox = scoped.find( (m) => m.role === 'archive' || m.name.toLowerCase() === 'archive', ); if (!archiveMailbox) return; - const targets = emails.filter( - (e) => emailIds.includes(e.id) && !e.mailboxIds?.[archiveMailbox.id], + const archive = refFor(state.mailboxes, archiveMailbox.id); + const targets = state.emails.filter( + (e) => emailIds.includes(e.id) && !e.mailboxIds?.[archive.id], ); if (targets.length === 0) return; const mode = useSettingsStore.getState().archiveMode; const items = targets.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })); - const archiveTarget = { [archiveMailbox.id]: true }; + const archiveTarget = { [archive.id]: true }; const { queued } = await applyOrQueueBatch( - targets.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, mailboxIds: archiveTarget })), + targets.map((e): OutboxOp => ({ + kind: 'mailboxes', + emailId: e.id, + accountId: archive.accountId, + mailboxIds: archiveTarget, + })), () => apiArchiveEmails( targets.map((e) => ({ id: e.id, receivedAt: e.receivedAt })), - archiveMailbox.id, + archive.id, mode, - mailboxes, + toRawMailboxes(scoped), + archive.accountId, ), ); @@ -1158,6 +1350,7 @@ export const useEmailStore = create()( kind: 'archive', label: targets.length === 1 ? 'Email archived' : `${targets.length} emails archived`, createdAt: Date.now(), + accountId: archive.accountId, items, }, }); @@ -1169,6 +1362,13 @@ export const useEmailStore = create()( moveEmailsToMailbox: async (emailIds, toMailboxId) => { const { emails, currentMailboxId, mailboxes } = get(); if (!currentMailboxId || toMailboxId === currentMailboxId) return; + const source = refFor(mailboxes, currentMailboxId); + const to = refFor(mailboxes, toMailboxId); + // See moveToMailbox: one Email/set can't span two accounts. + if (source.accountId !== to.accountId) { + set({ error: 'Messages can only be moved within the same account' }); + return; + } const targets = emails.filter((e) => emailIds.includes(e.id)); if (targets.length === 0) return; @@ -1178,14 +1378,15 @@ export const useEmailStore = create()( targets.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, - mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId), + accountId: source.accountId, + mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id), })), - () => apiMoveEmails(targets.map((e) => e.id), currentMailboxId, toMailboxId), + () => apiMoveEmails(targets.map((e) => e.id), source.id, to.id, source.accountId), ); const removed = new Set(targets.map((e) => e.id)); for (const e of targets) { - patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, toMailboxId) }); + patchCache(e.id, { mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, to.id) }); } const targetName = mailboxes.find((m) => m.id === toMailboxId)?.name; set({ @@ -1196,6 +1397,7 @@ export const useEmailStore = create()( ? `${targets.length === 1 ? 'Email' : `${targets.length} emails`} moved to ${targetName}` : 'Emails moved', createdAt: Date.now(), + accountId: source.accountId, items, }, }); @@ -1204,7 +1406,11 @@ export const useEmailStore = create()( deleteEmailsBatch: async (emailIds, trashMailboxId, currentMailboxId) => { const { emails, mailboxes } = get(); const settings = useSettingsStore.getState(); - const junkMailbox = mailboxes.find((m) => m.role === 'junk' || m.role === 'spam'); + const trash = refFor(mailboxes, trashMailboxId); + const source = refFor(mailboxes, currentMailboxId); + const junkMailbox = mailboxesForSiblingOf(mailboxes, currentMailboxId) + .find((m) => m.role === 'junk' || m.role === 'spam'); + const junkId = junkMailbox ? rawMailboxId(mailboxes, junkMailbox.id) : null; const inTrash = currentMailboxId === trashMailboxId; const targets = emails.filter((e) => emailIds.includes(e.id)); if (targets.length === 0) return; @@ -1215,7 +1421,7 @@ export const useEmailStore = create()( const toDestroy: Email[] = []; const toTrash: Email[] = []; for (const e of targets) { - const inJunk = !!(junkMailbox && e.mailboxIds?.[junkMailbox.id]); + const inJunk = !!(junkId && e.mailboxIds?.[junkId]); const destroy = inTrash || settings.deleteAction === 'permanent' || @@ -1234,28 +1440,35 @@ export const useEmailStore = create()( ); const ops: OutboxOp[] = [ - ...toDestroy.map((e): OutboxOp => ({ kind: 'destroy', emailId: e.id })), + ...toDestroy.map((e): OutboxOp => ({ + kind: 'destroy', + emailId: e.id, + accountId: trash.accountId, + })), ...toTrash.map((e): OutboxOp => ({ kind: 'mailboxes', emailId: e.id, - mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId), + accountId: source.accountId, + mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), })), ...toMarkRead.map((e): OutboxOp => ({ kind: 'keywords', emailId: e.id, + accountId: source.accountId, keywords: markReadKeywords.get(e.id)!, })), ]; await applyOrQueueBatch(ops, async () => { if (toDestroy.length > 0) { - await apiDeleteEmails(toDestroy.map((e) => e.id), trashMailboxId, trashMailboxId); + await apiDeleteEmails(toDestroy.map((e) => e.id), trash.id, trash.id, trash.accountId); } if (toTrash.length > 0) { - await apiMoveEmails(toTrash.map((e) => e.id), currentMailboxId, trashMailboxId); + await apiMoveEmails(toTrash.map((e) => e.id), source.id, trash.id, source.accountId); } if (toMarkRead.length > 0) { await setKeywordsForEmails( toMarkRead.map((e) => ({ id: e.id, keywords: markReadKeywords.get(e.id)! })), + source.accountId, ); } }); @@ -1263,7 +1476,7 @@ export const useEmailStore = create()( if (toDestroy.length > 0) dropFromCache(toDestroy.map((e) => e.id)); for (const e of toTrash) { patchCache(e.id, { - mailboxIds: mailboxesAfterMove(e.mailboxIds, currentMailboxId, trashMailboxId), + mailboxIds: mailboxesAfterMove(e.mailboxIds, source.id, trash.id), ...(markReadKeywords.has(e.id) ? { keywords: markReadKeywords.get(e.id) } : {}), }); } @@ -1278,6 +1491,7 @@ export const useEmailStore = create()( kind: 'delete', label: toTrash.length === 1 ? 'Email moved to Trash' : `${toTrash.length} emails moved to Trash`, createdAt: Date.now(), + accountId: source.accountId, items: toTrash.map((e) => ({ email: e, originalMailboxIds: { ...e.mailboxIds } })), }, }); @@ -1285,8 +1499,10 @@ export const useEmailStore = create()( }, setKeywordForEmails: async (emailIds, token, on) => { - const targets = get().emails.filter((e) => emailIds.includes(e.id)); + const state = get(); + const targets = state.emails.filter((e) => emailIds.includes(e.id)); if (targets.length === 0) return; + const owner = currentAccountId(state); const updates = targets.map((e) => { const keywords = { ...e.keywords }; if (on) keywords[token] = true; @@ -1294,8 +1510,13 @@ export const useEmailStore = create()( return { id: e.id, keywords }; }); await applyOrQueueBatch( - updates.map((u): OutboxOp => ({ kind: 'keywords', emailId: u.id, keywords: u.keywords })), - () => setKeywordsForEmails(updates), + updates.map((u): OutboxOp => ({ + kind: 'keywords', + emailId: u.id, + accountId: owner, + keywords: u.keywords, + })), + () => setKeywordsForEmails(updates, owner), ); const byId = new Map(updates.map((u) => [u.id, u.keywords])); set({ @@ -1316,10 +1537,12 @@ export const useEmailStore = create()( entry.items.map((it): OutboxOp => ({ kind: 'mailboxes', emailId: it.email.id, + accountId: entry.accountId, mailboxIds: it.originalMailboxIds, })), () => restoreEmailMailboxes( entry.items.map((it) => ({ id: it.email.id, mailboxIds: it.originalMailboxIds })), + entry.accountId, ), ); for (const it of entry.items) { @@ -1333,10 +1556,11 @@ export const useEmailStore = create()( // Re-insert each restored email into the visible list if its original // mailboxIds include the current view. Server is the source of truth for // ordering, but local re-insertion gives the user instant feedback. - const { currentMailboxId, emails } = get(); + const { currentMailboxId, emails, mailboxes } = get(); if (currentMailboxId) { + const currentRawId = rawMailboxId(mailboxes, currentMailboxId); const restored = entry.items - .filter((it) => it.originalMailboxIds[currentMailboxId]) + .filter((it) => it.originalMailboxIds[currentRawId]) .map((it) => ({ ...it.email, mailboxIds: it.originalMailboxIds })); if (restored.length > 0) { const merged = [...restored, ...emails].sort( @@ -1350,15 +1574,18 @@ export const useEmailStore = create()( clearUndo: () => set({ pendingUndo: null }), searchEmails: async (query) => { - const ids = await apiSearchEmails(query); + // Search the account whose folder is open, so a shared mailbox searches + // its own messages rather than the user's. + const owner = currentAccountId(get()); + const ids = await apiSearchEmails(query, undefined, 30, owner); if (ids.length === 0) return []; - return fetchEmails(ids); + return fetchEmails(ids, owner); }, reset: () => set({ mailboxes: [], mailboxState: undefined, - emailState: undefined, + emailStates: {}, currentMailboxId: null, mailboxSnapshots: {}, emails: [], @@ -1377,19 +1604,39 @@ export const useEmailStore = create()( // refresh once the session is ready. name: 'email-cache', storage: createJSONStorage(() => AsyncStorage), - version: 1, + version: 2, // v0 → v1: drop every cached queryState. Pre-v1 builds could persist a // search-result list next to the base view's queryState, and the // incremental sync path would then "confirm" those search results as // the whole mailbox and bake them into the snapshot (issue #10). // Without a queryState the next refresh does a full re-query, which // rebuilds any poisoned window from the server. + // + // v1 → v2: the single `emailState` became `emailStates`, keyed per JMAP + // account so shared (group account) folders track their own. The old + // token is dropped; the next refresh re-primes it from Email/get. migrate: (persisted, version) => { - if (version >= 1) return persisted as EmailState; const s = persisted as Pick< EmailState, 'accountSnapshots' | 'mailboxSnapshots' | 'queryState' > & Record; + + const dropEmailState = (input: Record): Record => { + const { emailState: _drop, ...rest } = input; + return { ...rest, emailStates: {} }; + }; + + if (version >= 2) return persisted as EmailState; + if (version >= 1) { + const accountSnapshots: Record = {}; + for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) { + accountSnapshots[id] = dropEmailState( + acc as unknown as Record, + ) as unknown as AccountSnapshot; + } + return { ...dropEmailState(s), accountSnapshots } as unknown as EmailState; + } + const stripQueryStates = ( snaps: Record | undefined, ): Record => @@ -1401,13 +1648,13 @@ export const useEmailStore = create()( ); const accountSnapshots: Record = {}; for (const [id, acc] of Object.entries(s.accountSnapshots ?? {})) { - accountSnapshots[id] = { + accountSnapshots[id] = dropEmailState({ ...acc, mailboxSnapshots: stripQueryStates(acc.mailboxSnapshots), - }; + }) as unknown as AccountSnapshot; } return { - ...s, + ...dropEmailState(s), accountSnapshots, mailboxSnapshots: stripQueryStates(s.mailboxSnapshots), queryState: undefined, @@ -1418,7 +1665,7 @@ export const useEmailStore = create()( activeAccountId: state.activeAccountId, mailboxes: state.mailboxes, mailboxState: state.mailboxState, - emailState: state.emailState, + emailStates: state.emailStates, currentMailboxId: state.currentMailboxId, mailboxSnapshots: state.mailboxSnapshots, emails: state.emails, diff --git a/src/stores/outbox-store.ts b/src/stores/outbox-store.ts index 570d9e90..2906e2f7 100644 --- a/src/stores/outbox-store.ts +++ b/src/stores/outbox-store.ts @@ -34,10 +34,13 @@ function storageKey(accountId: string): string { return `${KEY_PREFIX}${accountId}`; } +// `accountId` is the JMAP account the message lives under. Absent for the +// user's own mail (the client's primary account); set when the message belongs +// to a shared/group account, so replay after a reconnect still targets it. export type OutboxOp = - | { kind: 'keywords'; emailId: string; keywords: Record } - | { kind: 'mailboxes'; emailId: string; mailboxIds: Record } - | { kind: 'destroy'; emailId: string }; + | { kind: 'keywords'; emailId: string; accountId?: string; keywords: Record } + | { kind: 'mailboxes'; emailId: string; accountId?: string; mailboxIds: Record } + | { kind: 'destroy'; emailId: string; accountId?: string }; export interface OutboxEntry { id: string; @@ -85,11 +88,11 @@ async function load(accountId: string): Promise { async function runOp(op: OutboxOp): Promise { switch (op.kind) { case 'keywords': - return setEmailKeywords(op.emailId, op.keywords); + return setEmailKeywords(op.emailId, op.keywords, op.accountId); case 'mailboxes': - return setEmailMailboxes(op.emailId, op.mailboxIds); + return setEmailMailboxes(op.emailId, op.mailboxIds, op.accountId); case 'destroy': - return destroyEmails([op.emailId]); + return destroyEmails([op.emailId], op.accountId); } } @@ -136,20 +139,23 @@ export const useOutboxStore = create((set, get) => ({ return; } let entries = [...get().entries]; - const { emailId } = op; + // Message identity is (account, id): JMAP ids are only unique within an + // account, and the queue can hold ops for shared accounts alongside own. + const sameMessage = (other: OutboxOp) => + other.emailId === op.emailId && other.accountId === op.accountId; if (op.kind === 'destroy') { // Destroy is terminal — drop any pending edits for this message and // append the destroy so it runs last. - entries = entries.filter((e) => e.op.emailId !== emailId); + entries = entries.filter((e) => !sameMessage(e.op)); entries.push({ id: generateUUID(), op, createdAt: Date.now(), attempts: 0 }); } else { // A queued destroy wins; further edits to a doomed message are pointless. - if (entries.some((e) => e.op.emailId === emailId && e.op.kind === 'destroy')) return; + if (entries.some((e) => sameMessage(e.op) && e.op.kind === 'destroy')) return; // Coalesce: replace any pending op of the same kind for this message // (full-state replace makes the latest one authoritative). Keep its // position so creation order is preserved for replay. - const idx = entries.findIndex((e) => e.op.emailId === emailId && e.op.kind === op.kind); + const idx = entries.findIndex((e) => sameMessage(e.op) && e.op.kind === op.kind); if (idx >= 0) { entries[idx] = { ...entries[idx], op, attempts: 0, lastError: undefined }; } else {