From 75d17d4e378b6ae2227dffb7c69c6b4361d9bbf5 Mon Sep 17 00:00:00 2001 From: honzup <5564623+honzup@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:33:10 +0200 Subject: [PATCH 01/22] fix: keep target/rel on links in plain-text message bodies Plain-text bodies render into the main document rather than the sandboxed iframe, so an anchor without target="_blank" navigates the whole app away instead of opening a new tab. plainTextToSafeHtml emits target and rel correctly, but sanitizePlainTextRenderedHtml stripped both back off: DOMPurify URI-tests every attribute value not on its URI-safe list, and "_blank" does not match PLAIN_TEXT_RENDERED_CONFIG's ALLOWED_URI_REGEXP. EMAIL_SANITIZE_CONFIG avoids this only because its regex carries a catch-all alternation for non-URI values. Mark target and rel as URI-safe so they survive the URI test, rather than loosening href validation. --- lib/__tests__/email-sanitization.test.ts | 21 +++++++++++++++++++++ lib/email-sanitization.ts | 6 ++++++ 2 files changed, 27 insertions(+) diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index 4691b0e0..103d2dcf 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -6,6 +6,7 @@ import { parseHtmlSafely, hasRichFormatting, plainTextToSafeHtml, + sanitizePlainTextRenderedHtml, EMAIL_SANITIZE_CONFIG, EMAIL_IFRAME_SANITIZE_CONFIG, isExternalResourceUrl, @@ -580,4 +581,24 @@ describe('email-sanitization', () => { expect(result).toContain('javascript:alert(1)'); }); }); + + describe('sanitizePlainTextRenderedHtml', () => { + // This branch renders into the main document, not the sandboxed iframe, so + // an anchor that loses target="_blank" navigates the whole app away. + it('preserves target and rel on links emitted by plainTextToSafeHtml', () => { + const rendered = sanitizePlainTextRenderedHtml( + plainTextToSafeHtml('see https://github.com/honzup/webmail/pull/560'), + ); + expect(rendered).toContain('target="_blank"'); + expect(rendered).toContain('rel="noopener noreferrer"'); + }); + + it('still strips dangerous schemes and tags', () => { + const rendered = sanitizePlainTextRenderedHtml( + 'x', + ); + expect(rendered).not.toContain('javascript:'); + expect(rendered).not.toContain(' Date: Sat, 11 Jul 2026 09:45:13 +0200 Subject: [PATCH 02/22] fix: open signature links in a new tab instead of navigating the app away Signatures render into the main document - the identity form's live preview and the composer's signature block - rather than the sandboxed iframe used for message bodies. SIGNATURE_SANITIZE_CONFIG allows no target attribute, so those anchors were live and target-less: one click navigated the whole app away, discarding the unsent draft or the unsaved signature with it. Add sanitizeSignatureHtmlForDisplay, which keeps the storage sanitizer's image restrictions but forces target="_blank" rel="noopener noreferrer" on every anchor, and use it at the two render sites. The composer's SignatureBlock NodeView stamps the target on its rendered DOM instead, because attrs.html is what serializeEditorContent emits into the sent message - storage and the recipient's copy stay exactly as the user wrote them. --- components/email/email-composer.tsx | 4 +- components/email/signature-block.ts | 32 ++++++++++++++-- components/identity/identity-form.tsx | 4 +- lib/__tests__/email-sanitization.test.ts | 38 +++++++++++++++++++ lib/email-sanitization.ts | 47 +++++++++++++++++++++--- 5 files changed, 112 insertions(+), 13 deletions(-) diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 4e022d29..df7543bf 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -11,7 +11,7 @@ import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; import { useContextMenu } from "@/hooks/use-context-menu"; import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"; -import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization"; +import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization"; import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix"; import { isFilePreviewable } from "@/lib/file-preview"; import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html"; @@ -823,7 +823,7 @@ export function EmailComposer({ }, [composerClient, plainTextMode, mode]); const composerSignatureHtml = signatureIdentity?.htmlSignature - ? `
${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}
` + ? `
${sanitizeSignatureHtmlForDisplay(signatureIdentity.htmlSignature)}
` : signatureIdentity?.textSignature ? `
${getPlainTextSignature(signatureIdentity).replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
')}
` : ''; diff --git a/components/email/signature-block.ts b/components/email/signature-block.ts index 231b9f6a..9b0ee3ef 100644 --- a/components/email/signature-block.ts +++ b/components/email/signature-block.ts @@ -6,6 +6,23 @@ import { Node as TiptapNode, mergeAttributes } from "@tiptap/core"; // so parseHTML can recognise it on the way back in (initial content, drafts). export const SIGNATURE_BLOCK_MARKER = "data-signature-block-node"; +/** + * Force every link in the rendered signature to open in a new tab. + * + * Applied to the NodeView's DOM only, never to `attrs.html` — that attribute is + * what serializeEditorContent emits into the sent message, and the recipient's + * copy should stay exactly as the user wrote it. Without this the composer's + * signature is a set of live, target-less anchors in the main document (the + * message body gets a sandboxed iframe; this does not), so one stray click + * navigates the whole app away and takes the unsent draft with it. + */ +function forceLinksToNewTab(root: HTMLElement): void { + root.querySelectorAll("a[href]").forEach((a) => { + a.setAttribute("target", "_blank"); + a.setAttribute("rel", "noopener noreferrer"); + }); +} + /** * SignatureBlock — an atomic, NON-editable block node that carries the * *verbatim* HTML of the user's identity signature in its `html` attribute. @@ -61,6 +78,7 @@ export const SignatureBlock = TiptapNode.create({ dom.setAttribute(SIGNATURE_BLOCK_MARKER, ""); dom.className = "signature-block-island"; + // CRITICAL: render the signature inside a Shadow Root. The app's global // CSS (Tailwind preflight, .tiptap table/td rules, box-sizing resets) // would otherwise cascade INTO the signature and destroy its layout - @@ -71,7 +89,12 @@ export const SignatureBlock = TiptapNode.create({ const inner = document.createElement("div"); // Read-only: a signature is inserted/removed as a unit, not edited inline. inner.contentEditable = "false"; - inner.innerHTML = node.attrs.html || ""; + // Track what we were given, not what's in the DOM: forceLinksToNewTab + // rewrites the markup, so inner.innerHTML no longer round-trips against + // attrs.html and comparing the two would rewrite on every transaction. + let appliedHtml = node.attrs.html || ""; + inner.innerHTML = appliedHtml; + forceLinksToNewTab(inner); shadow.appendChild(inner); return { @@ -83,8 +106,11 @@ export const SignatureBlock = TiptapNode.create({ stopEvent: () => false, update: (updatedNode) => { if (updatedNode.type.name !== "signatureBlock") return false; - if (inner.innerHTML !== (updatedNode.attrs.html || "")) { - inner.innerHTML = updatedNode.attrs.html || ""; + const nextHtml = updatedNode.attrs.html || ""; + if (nextHtml !== appliedHtml) { + appliedHtml = nextHtml; + inner.innerHTML = nextHtml; + forceLinksToNewTab(inner); } return true; }, diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index 2e083225..0155b7b7 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -5,7 +5,7 @@ import { useTranslations } from 'next-intl'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import type { Identity, EmailAddress } from '@/lib/jmap/types'; -import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; +import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization'; import { getEmailValidationError, validateEmailList } from '@/lib/validation'; // Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes @@ -305,7 +305,7 @@ export function IdentityForm({ identity, onSave, onCancel }: IdentityFormProps)
{tDisplay('preview')}
diff --git a/lib/__tests__/email-sanitization.test.ts b/lib/__tests__/email-sanitization.test.ts index 103d2dcf..910d99d1 100644 --- a/lib/__tests__/email-sanitization.test.ts +++ b/lib/__tests__/email-sanitization.test.ts @@ -3,6 +3,7 @@ import DOMPurify from 'dompurify'; import { sanitizeEmailHtml, sanitizeSignatureHtml, + sanitizeSignatureHtmlForDisplay, parseHtmlSafely, hasRichFormatting, plainTextToSafeHtml, @@ -582,6 +583,43 @@ describe('email-sanitization', () => { }); }); + describe('sanitizeSignatureHtmlForDisplay', () => { + // Signatures render into the main document (identity-form preview, composer + // block), not the sandboxed iframe, so a target-less anchor navigates the + // whole app away and takes the unsaved draft/signature with it. + it('forces target=_blank and rel on signature links', () => { + const clean = sanitizeSignatureHtmlForDisplay('

Site

'); + expect(clean).toContain('target="_blank"'); + expect(clean).toContain('rel="noopener noreferrer"'); + }); + + it('overrides a target the user supplied themselves', () => { + const clean = sanitizeSignatureHtmlForDisplay('x'); + expect(clean).toContain('target="_blank"'); + expect(clean).not.toContain('_top'); + }); + + it('keeps the image restrictions of the storage sanitizer', () => { + const clean = sanitizeSignatureHtmlForDisplay( + '', + ); + expect(clean).not.toContain('insecure.example.com'); + expect(clean).toContain('https://cdn.example.com/l.png'); + }); + + it('does not leak target into the stored or sent signature', () => { + // sanitizeSignatureHtml feeds both storage and the outgoing message body. + const stored = sanitizeSignatureHtml('

Site

'); + expect(stored).toContain('href="https://example.com"'); + expect(stored).not.toContain('target='); + }); + + it('handles empty input', () => { + expect(sanitizeSignatureHtmlForDisplay('')).toBe(''); + expect(sanitizeSignatureHtmlForDisplay(' ')).toBe(''); + }); + }); + describe('sanitizePlainTextRenderedHtml', () => { // This branch renders into the main document, not the sandboxed iframe, so // an anchor that loses target="_blank" navigates the whole app away. diff --git a/lib/email-sanitization.ts b/lib/email-sanitization.ts index fe1bbff7..69afdc3d 100644 --- a/lib/email-sanitization.ts +++ b/lib/email-sanitization.ts @@ -79,26 +79,61 @@ export const SIGNATURE_SANITIZE_CONFIG = { FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'], }; +/** Drop images whose src isn't https: or a base64 raster data: URI. */ +function restrictSignatureImages(node: Element): void { + if (node.tagName !== 'IMG') return; + const src = node.getAttribute('src'); + if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) { + node.remove(); + } +} + /** - * Sanitize HTML signature for storage and display. + * Sanitize an HTML signature for storage and for the outgoing message. * img src is restricted to https: or base64-embedded raster data: URIs * (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect * bytes inside a data: URI. Images with a disallowed src are removed * entirely so they don't render as broken-image icons. + * + * Deliberately does NOT force target="_blank": what we store, and what the + * recipient receives, should stay as the user wrote it. Use + * `sanitizeSignatureHtmlForDisplay` for anything rendered in our own DOM. * @param html - User-provided HTML signature * @returns Sanitized signature (no scripts, no external resources) */ export function sanitizeSignatureHtml(html: string): string { + if (!html?.trim()) return ''; + DOMPurify.addHook('afterSanitizeAttributes', restrictSignatureImages); + try { + return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + } finally { + DOMPurify.removeAllHooks(); + } +} + +const SIGNATURE_DISPLAY_CONFIG = { + ...SIGNATURE_SANITIZE_CONFIG, + ALLOWED_ATTR: [...SIGNATURE_SANITIZE_CONFIG.ALLOWED_ATTR, 'target', 'rel'], +}; + +/** + * Sanitize an HTML signature for rendering inside our own DOM — the identity + * form's live preview and the composer's signature block. Both inject into the + * main document rather than the sandboxed iframe used for message bodies, so a + * link without target="_blank" navigates the whole app away, taking any unsent + * draft or unsaved signature with it. Force every anchor to open a new tab. + */ +export function sanitizeSignatureHtmlForDisplay(html: string): string { if (!html?.trim()) return ''; DOMPurify.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName !== 'IMG') return; - const src = node.getAttribute('src'); - if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) { - node.remove(); + restrictSignatureImages(node); + if (node.tagName === 'A') { + node.setAttribute('target', '_blank'); + node.setAttribute('rel', 'noopener noreferrer'); } }); try { - return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG); + return DOMPurify.sanitize(html, SIGNATURE_DISPLAY_CONFIG); } finally { DOMPurify.removeAllHooks(); } From c1acf58c5fa1d221c56179dd46035a72e03a807f Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:04:05 +0200 Subject: [PATCH 03/22] test(compose): add findComposeIdentityId to reply-identity mock The recipient chip-drag and paste tests render , which since b716f95a (feat(compose): preselect identity of the active mailbox) calls findComposeIdentityId() from @/lib/reply-identity in compose mode. Both tests mock that module but only returned resolveReplyFrom, so vitest threw "No 'findComposeIdentityId' export is defined on the mock" on mount, failing all 18 tests. Add the missing export (returns null; the composer guards with if (composeIdentityId)). --- components/email/__tests__/recipient-chip-drag.test.tsx | 5 ++++- components/email/__tests__/recipient-paste.test.tsx | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 461b4452..68f90dec 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -148,7 +148,10 @@ vi.mock('@/lib/email-sanitization', () => ({ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), })); -vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null })); +vi.mock('@/lib/reply-identity', () => ({ + resolveReplyFrom: () => null, + findComposeIdentityId: () => null, +})); vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), })); diff --git a/components/email/__tests__/recipient-paste.test.tsx b/components/email/__tests__/recipient-paste.test.tsx index 1a3ccef6..a8d4d15c 100644 --- a/components/email/__tests__/recipient-paste.test.tsx +++ b/components/email/__tests__/recipient-paste.test.tsx @@ -147,7 +147,10 @@ vi.mock('@/lib/email-sanitization', () => ({ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'), })); -vi.mock('@/lib/reply-identity', () => ({ resolveReplyFrom: () => null })); +vi.mock('@/lib/reply-identity', () => ({ + resolveReplyFrom: () => null, + findComposeIdentityId: () => null, +})); vi.mock('@/lib/email-threading', () => ({ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }), })); From 622adc34de11a290c49fe0522593c89db322a7ba Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Sun, 12 Jul 2026 18:30:50 +0200 Subject: [PATCH 04/22] feat: add onEmailsFetched and onSearchResults hook + new JMAP method getSomeEmails --- lib/demo/demo-client.ts | 13 +++ lib/jmap/client-interface.ts | 1 + lib/jmap/client.ts | 37 +++++++ lib/plugin-hooks.ts | 4 + stores/email-store.ts | 180 +++++++++++++++++++---------------- 5 files changed, 154 insertions(+), 81 deletions(-) diff --git a/lib/demo/demo-client.ts b/lib/demo/demo-client.ts index 4e94555e..72436779 100644 --- a/lib/demo/demo-client.ts +++ b/lib/demo/demo-client.ts @@ -166,6 +166,19 @@ export class DemoJMAPClient implements IJMAPClient { return { emails, hasMore: position + limit < total, total }; } + async getSomeEmails(emailsId: string[], _accountId?: string): Promise { + if (!emailsId || emailsId.length === 0) { + return []; + } + const filtered = this.data.emails.filter(e => emailsId.includes(e.id)); + + filtered.sort((a, b) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + + return filtered; + } + async getEmailsInMailbox(mailboxId: string): Promise { return this.data.emails.filter(e => e.mailboxIds[mailboxId]); } diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts index 5a7d4223..729c55f6 100644 --- a/lib/jmap/client-interface.ts +++ b/lib/jmap/client-interface.ts @@ -83,6 +83,7 @@ export interface IJMAPClient { getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; getEmailsInMailbox(mailboxId: string): Promise; getEmail(emailId: string, accountId?: string): Promise; + getSomeEmails(emailsId: string[], accountId?: string): Promise getTagCounts(tagIds: string[]): Promise>; searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>; advancedSearchEmails( diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 387f2393..2b6d2f50 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -550,6 +550,43 @@ export class JMAPClient implements IJMAPClient { this.authHeader = `Bearer ${token}`; } + async getSomeEmails(emailsId: string[], accountId?: string): Promise { + try { + const targetAccountId = accountId || this.accountId; + if (!emailsId || emailsId.length === 0) { + return []; + } + + const response = await this.request([ + ["Email/get", { + accountId: targetAccountId, + ids: emailsId, + properties: [...EMAIL_LIST_PROPERTIES], + }, "0"], + ]); + + const getResponse = response.methodResponses?.[0]?.[1]; + + if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) { + const emails = (getResponse.list || []) as Email[]; + + emails.sort((a: Email, b: Email) => + new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime() + ); + + if (accountId && accountId !== this.accountId) { + namespaceMailboxIds(emails, accountId); + } + + return emails; + } + + return []; + } catch (error) { + console.error('Failed to get specific emails:', error); + return []; + } + } /** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */ upgradeToBearer(accessToken: string, onRefresh?: () => Promise): void { this.authMode = 'bearer'; diff --git a/lib/plugin-hooks.ts b/lib/plugin-hooks.ts index 00ccc58c..02fb8107 100644 --- a/lib/plugin-hooks.ts +++ b/lib/plugin-hooks.ts @@ -272,6 +272,10 @@ export const emailHooks = { // normally. This is the send-takeover hook used by the S/MIME plugin to // replace the former native sign+encrypt+sendRaw pipeline. onComposeSend: new HookBus(), + // Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to + // lets plugin edit emails before they are shown in row. Used to populate preview + // field for encryption plugins. + onEmailsFetched: new HookBus(), }; // §7.2 Calendar Hooks diff --git a/stores/email-store.ts b/stores/email-store.ts index 0967f670..d44d0414 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -918,8 +918,9 @@ export const useEmailStore = create((set, get) => ({ for (const email of result.emails) { email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes); } + const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ - emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), + emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, isLoading: false, @@ -946,8 +947,9 @@ export const useEmailStore = create((set, get) => ({ // When filtering by tag, omit the mailbox constraint so emails across // all folders that carry the tag are returned. const result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, 0, keywordFilter, true); + const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails); set({ - emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), + emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId), hasMoreEmails: result.hasMore, totalEmails: result.total, // Clear thread caches since the email list was fully replaced @@ -991,8 +993,9 @@ export const useEmailStore = create((set, get) => ({ const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, @@ -1034,8 +1037,9 @@ export const useEmailStore = create((set, get) => ({ const currentEmails = get().emails; const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = result.emails.filter(e => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false, @@ -1127,14 +1131,15 @@ export const useEmailStore = create((set, get) => ({ const existingIds = new Set(currentEmails.map(e => e.id)); const newEmails = annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId).filter((e: Email) => !existingIds.has(e.id)); + const enrichedNewEmails = await emailHooks.onEmailsFetched.transform(newEmails); set({ - emails: [...currentEmails, ...newEmails], + emails: [...currentEmails, ...enrichedNewEmails], hasMoreEmails: result.hasMore, totalEmails: result.total, isLoadingMore: false }); // Fetch full thread counts for newly loaded threads in the background - if (newEmails.length > 0) { + if (enrichedNewEmails.length > 0) { void get().fetchThreadEmailCounts(client); } } catch (error) { @@ -1754,60 +1759,66 @@ export const useEmailStore = create((set, get) => ({ searchEmails: async (client, query) => { set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state try { - const { isUnifiedView, unifiedRole, crossView } = get(); + const { isUnifiedView, unifiedRole, crossView, selectedMailbox, searchFilters } = get(); const emailsPerPage = useSettingsStore.getState().emailsPerPage; + let result; + let accountId; + let unifiedErrors; + if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - unifiedErrors: result.errors, - }); - return; - } - - if (isUnifiedView && unifiedRole) { + result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0); + unifiedErrors = result.errors; + + } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - unifiedErrors: result.errors, - }); - return; + result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0); + unifiedErrors = result.errors; + + } else { + // Get the current mailbox to scope the search. In the All Mail view the + // search spans every folder of the account (no inMailbox constraint). + const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; + const mailboxes = resolveActionMailboxes(); + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + // Use originalId for shared mailboxes + const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); + // Only pass accountId for shared mailboxes, not for primary account + accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + + result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); } - // Get the current mailbox to scope the search. In the All Mail view the - // search spans every folder of the account (no inMailbox constraint). - const selectedMailbox = get().selectedMailbox; - const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; - const mailboxes = resolveActionMailboxes(); - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - // Use originalId for shared mailboxes - const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); - // Only pass accountId for shared mailboxes, not for primary account - const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + const hookEdit = await emailHooks.onSearchResults.transform({ + newEmailIds: [] as string[], + result: result, + query: query, + filters: searchFilters + }); - const result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0); - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters }); + result = hookEdit.result; + if (hookEdit.newEmailIds.length > 0) { + // in unified, accountId will be undefined and we will use the default. + const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); + result.emails.push(...newEmails); + result.total += newEmails.length; + } + + const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { + query, + filters: searchFilters + }); + result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, hasMoreEmails: result.hasMore, totalEmails: result.total, - isLoading: false + isLoading: false, + ...(unifiedErrors ? { unifiedErrors } : {}) }); } catch (error) { set({ @@ -1841,60 +1852,64 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; + let result; + let accountId; + let unifiedErrors; if (isUnifiedView && crossView) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0); - if (controller.signal.aborted) return; - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - unifiedErrors: result.errors, - }); - return; - } + result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0); + unifiedErrors = result.errors; - if (isUnifiedView && unifiedRole) { + } else if (isUnifiedView && unifiedRole) { const includeGroup = useSettingsStore.getState().includeGroupInUnified; const built = await buildUnifiedAccountClients({ includeGroup }); - const result = await advancedSearchUnifiedEmails( + result = await advancedSearchUnifiedEmails( built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0, ); - if (controller.signal.aborted) return; - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); - set({ - emails: result.emails, - externalSearchResults: externals, - hasMoreEmails: result.hasMore, - totalEmails: result.total, - isLoading: false, - searchAbortController: null, - unifiedErrors: result.errors, - }); - return; + unifiedErrors = result.errors; + + } else { + const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; + const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); + const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); + accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); + + const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); + result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); } - const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID; - const mailbox = mailboxes.find(mb => mb.id === selectedMailbox); - const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox); - const accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined); - - const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId); - const result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0); - if (controller.signal.aborted) return; - const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters }); + const hookEdit = await emailHooks.onSearchResults.transform({ + newEmailIds: [] as string[], + result: result, + query: searchQuery, + filters: searchFilters + }); + result = hookEdit.result; + + if (hookEdit.newEmailIds.length > 0) { + const newEmails = await resolveActionClient(client).getSomeEmails(hookEdit.newEmailIds, accountId); + result.emails.push(...newEmails); + result.total += newEmails.length; + } + + if (controller.signal.aborted) return; + + const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { + query: searchQuery, + filters: searchFilters + }); + + if (controller.signal.aborted) return; + result.emails = await emailHooks.onEmailsFetched.transform(result.emails); set({ emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId), externalSearchResults: externals, @@ -1902,6 +1917,7 @@ export const useEmailStore = create((set, get) => ({ totalEmails: result.total, isLoading: false, searchAbortController: null, + ...(unifiedErrors ? { unifiedErrors } : {}) }); } catch (error) { if (controller.signal.aborted) return; @@ -3284,6 +3300,7 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await client.getScheduledEmails(emailsPerPage, 0); + result.emails = await emailHooks.onEmailsFetched.transform(result.emails); const scheduledEmailIds = new Set(result.emails.map(email => email.id)); const scheduledSubmissionByEmailId = new Map(result.emails.map(email => [email.id, { submissionId: email.emailSubmissionId, @@ -3324,6 +3341,7 @@ export const useEmailStore = create((set, get) => ({ try { const emailsPerPage = useSettingsStore.getState().emailsPerPage; const result = await client.getScheduledEmails(emailsPerPage, scheduledNextPosition); + result.emails = await emailHooks.onEmailsFetched.transform(result.emails); const merged = [...scheduledEmails, ...result.emails.filter(email => !scheduledEmails.some(existing => existing.id === email.id))]; const pendingUndoSend = get().pendingUndoSend; set({ From a08a9e9ed3f2badf013b42ecadbd12a300362a2e Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Mon, 13 Jul 2026 17:12:47 +0200 Subject: [PATCH 05/22] feat(plugins) : add new api api method : webauthn.getOrCreate --- lib/plugin-sandbox/host-api.ts | 114 +++++++++++++++++++++++++++++++++ lib/plugin-sandbox/protocol.ts | 1 + lib/plugin-sandbox/runtime.tsx | 3 + 3 files changed, 118 insertions(+) diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 6bcd2870..732c1029 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -22,6 +22,7 @@ const PRIVILEGED_ONLY_METHODS = new Set([ 'jmap.fetchBlob', 'jmap.sendRaw', 'upfiles.get', + 'webauthn.getOrCreate', 'upfiles.set', ]); @@ -47,6 +48,7 @@ const PERM_PER_METHOD: Record = { // To just read, use jmap.fetchBlob. 'upfiles.get' : 'email:blob-write', 'upfiles.save' : 'email:blob-write', + 'webauthn.getOrCreate': 'crypto:full', // admin 'admin.getConfig': 'admin:config', 'admin.getAllConfig': 'admin:config', @@ -274,6 +276,117 @@ async function doJmapSendRaw( ); } +// ─── WebAuthn (privileged tier) ───────────────────────────────────────────── + +// This salt acts as a constant context identifier for key derivation. +// While hardcoded, security is maintained because the WebAuthn PRF extension +// mixes this salt with the device's unique, hardware-bound private key. +// Changing this string will result in a completely different derived secret. +const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1"); + +/** + * Retrieves or creates a WebAuthn passkey and extracts its PRF secret. + * This secret is typically used as a local master encryption key. + */ +async function doGetOrCreatePRF( + masterCredentialIdBytes: number[] | undefined, + name?: string, + displayName?: string +): Promise<{ credentialId: number[]; prfSecret: number[] } | string> { + + // ─── CASE 1: Credential already exists (Authentication) ────────────────── + if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) { + const credentialId = new Uint8Array(masterCredentialIdBytes).buffer; + + // Request an assertion (login) while evaluating the PRF salt + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ type: "public-key", id: credentialId }], + userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN) + extensions: { prf: { eval: { first: PRF_SALT } } } as any + } + }) as PublicKeyCredential; + + // Extract the derived symmetric key from the authenticator's output + const outputs = assertion.getClientExtensionResults(); + const prfSecret = (outputs as any).prf?.results?.first; + if (!prfSecret) return 'Cannot get PRF secret from existing credential.'; + + return { + credentialId: masterCredentialIdBytes, + prfSecret: Array.from(new Uint8Array(prfSecret)) + }; + } + + // ─── CASE 2: No masterCredentialIdBytes passed, create a new key (Registration) ────────── + else if (name && displayName) { + // Create the new passkey credential + const credential = await navigator.credentials.create({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + rp: { name: "Bulwark Webmail", id: window.location.hostname }, + user: { + id: crypto.getRandomValues(new Uint8Array(16)), + name: name, + displayName: displayName + }, + // Supported cryptographic algorithms + pubKeyCredParams: [ + { type: "public-key" as const, alg: -7 }, // ES256 (Recommended) + { type: "public-key" as const, alg: -257 } // RS256 (Compatibility fallback) + ], + authenticatorSelection: { + authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.) + userVerification: "required" + }, + extensions: { prf: {} } as any // Request PRF extension support from the authenticator + } + }) as PublicKeyCredential; + + const outputs = credential.getClientExtensionResults(); + + // Ensure the authenticator successfully enabled and supports the PRF extension + const isPrfEnabled = (outputs as any).prf?.enabled; + if (!isPrfEnabled) { + return 'The authenticator does not support or has rejected the PRF extension.'; + } + + // Note: Since many authenticators do not return the PRF evaluation results + // directly during creation, we immediately run an assertion (get) to fetch the initial secret. + const assertion = await navigator.credentials.get({ + publicKey: { + challenge: crypto.getRandomValues(new Uint8Array(32)), + allowCredentials: [{ + type: "public-key", + id: credential.rawId + }], + userVerification: "required", + extensions: { + prf: { eval: { first: PRF_SALT } } + } as any + } + }) as PublicKeyCredential; + + const assertionOutputs = assertion.getClientExtensionResults(); + + const prfSecret = (assertionOutputs as any).prf?.results?.first; + if (!prfSecret) { + return 'Cannot get PRF secret from existing credential.'; + } + + return { + credentialId: Array.from(new Uint8Array(credential.rawId)), + prfSecret: Array.from(new Uint8Array(prfSecret)) + }; + } + + // ─── CASE 3: Insufficient parameters provided ─────────────────────────── + else { + throw new Error("Provide name and display name if you want to create a new PRF."); + } +} + // ─── Uploaded files in IndexedDB (privileged tier) ────────────────────────── async function getFile(fileID:string): Promise { @@ -361,6 +474,7 @@ export async function dispatchApiCall( ); case 'upfiles.get' : return getFile(args[0] as string); case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File); + case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined); case 'admin.getConfig': return adminGet(plugin.id, args[0] as string); case 'admin.getAllConfig': return adminGetAll(plugin.id); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index fe61555f..6db43b79 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -241,6 +241,7 @@ export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged'; export const API_METHODS = [ 'storage.get', 'storage.set', 'storage.remove', 'storage.keys', 'http.post', 'http.fetch', + 'webauthn.getOrCreate', 'jmap.fetchBlob', 'jmap.sendRaw', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'toast.success', 'toast.error', 'toast.info', 'toast.warning', diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index fa9343e5..e39a328d 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -165,6 +165,9 @@ function buildPluginApi(manifest: PluginManifest) { version: manifest.version, settings: { ...manifest.settings }, }, + webauthn: { + getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0) + }, storage: { get: (key: string) => callApi('storage.get', [key]), set: (key: string, value: unknown) => callApi('storage.set', [key, value]), From a679d82cc3b2c09e2d4bf1bd9675058448d3fc7c Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Mon, 13 Jul 2026 19:22:32 +0200 Subject: [PATCH 06/22] feat: add download file method for files generated by plugin --- lib/plugin-sandbox/host-api.ts | 23 +++++++++++++++++++++++ lib/plugin-sandbox/protocol.ts | 2 +- lib/plugin-sandbox/runtime.tsx | 3 +++ lib/plugin-types.ts | 1 + 4 files changed, 28 insertions(+), 1 deletion(-) diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 732c1029..f9c84323 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -60,6 +60,7 @@ const PERM_PER_METHOD: Record = { 'ui.prompt': null, 'ui.rerenderEmail': null, 'ui.openExternalUrl': null, + 'ui.downloadFile': 'ui:download-file' }; function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean { @@ -400,6 +401,24 @@ async function saveFile(formerFileID:string, file: File): Promise { return fileId; } +// ─── Download files generated by the plugin. This is not user's files or attachments ────────────────────────── +async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise { + const { content, filename, contentType = 'application/json' } = args; + + try { + const url = URL.createObjectURL(new Blob([content], { type: contentType })); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + throw new Error(`Failed to download file: ${error}`); + } +} + // ─── admin config (same as before) ──────────────────────────── async function adminGetAll(pluginId: string): Promise> { @@ -547,6 +566,10 @@ export async function dispatchApiCall( window.open(parsed.toString(), '_blank', 'noopener,noreferrer'); return undefined; } + case 'ui.downloadFile': { + const opts = args[0] as { content: string; filename: string; contentType?: string }; + return downloadFile(opts); + } default: throw new Error(`Unhandled method "${method}"`); diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 6db43b79..1cb025fa 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -245,7 +245,7 @@ export const API_METHODS = [ 'jmap.fetchBlob', 'jmap.sendRaw', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'toast.success', 'toast.error', 'toast.info', 'toast.warning', - 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', + 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', 'ui.downloadFile' ] as const; export type ApiMethod = (typeof API_METHODS)[number]; diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e39a328d..9e72b519 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -232,6 +232,9 @@ function buildPluginApi(manifest: PluginManifest) { /** Opens an http/https URL in a new tab via host `window.open`. */ openExternalUrl: (url: string, target?: string) => callApi('ui.openExternalUrl', [url, target]) as Promise, + /** Downloads a file generated by the plugin. Not a user's file or attachment. */ + downloadFile: (opts: { content: string; filename: string; contentType?: string }) => + callApi('ui.downloadFile', [opts]) as Promise, }, admin: { getConfig: (key: string) => callApi('admin.getConfig', [key]), diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts index 0245054c..32fcb573 100644 --- a/lib/plugin-types.ts +++ b/lib/plugin-types.ts @@ -925,6 +925,7 @@ export const ALL_PERMISSIONS = [ 'http:post', 'http:fetch', 'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer', 'ui:email-details', + 'ui:download-file', 'ui:composer-toolbar', 'ui:composer-sidebar', 'ui:sidebar-widget', 'ui:settings-section', 'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard', From b1f6758f98bb457ea7e7c1af7c8f593f6482c319 Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Mon, 13 Jul 2026 19:31:52 +0200 Subject: [PATCH 07/22] fix: add ui:download-file permission to consent screen. --- lib/plugin-sandbox/consent.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/plugin-sandbox/consent.ts b/lib/plugin-sandbox/consent.ts index 2a1f9871..d8941732 100644 --- a/lib/plugin-sandbox/consent.ts +++ b/lib/plugin-sandbox/consent.ts @@ -86,6 +86,7 @@ const PERMISSION_LABELS: Record = { 'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' }, 'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' }, 'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' }, + 'ui:download-file': { title: 'Download files', body: 'Download custom files generated by the plugin.' }, }; export function describePermission(perm: string): { title: string; body: string } { From 9072bf8470afb558d9672fcb0ac2481b60b39408 Mon Sep 17 00:00:00 2001 From: honzup <5564623+honzup@users.noreply.github.com> Date: Sun, 12 Jul 2026 08:22:31 +0200 Subject: [PATCH 08/22] fix: keep sidebar tag counts in step with read/unread changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marking mail as read left the sidebar's tag unread counts untouched — the folder counts cleared, but a tag went on showing "47 unread" in bold until the page was reloaded. tagCounts is fetched from the server (Email/query per $label keyword) rather than derived from state, and no read/unread mutation refreshed or adjusted it. The per-mailbox unreadEmails counters were kept current by a local delta; tags simply had no equivalent. Add applyTagCountReadDelta alongside the existing mailbox-counter helpers and apply it wherever the affected emails are known locally: markAsRead, batchMarkAsRead, and setEmailKeywordsLocal. Only a genuine $seen flip moves a count, so re-marking a read email as read cannot drift it, and unread is clamped at zero. A tag's total is never touched by a read-state change. markMailboxAsRead is the exception and refetches instead: it is a server-side bulk operation over an entire mailbox, so it also marks emails that were never loaded into state.emails, and a local delta would leave the counts high. --- .../__tests__/email-store-tag-counts.test.ts | 257 ++++++++++++++++++ stores/email-store.ts | 80 +++++- 2 files changed, 329 insertions(+), 8 deletions(-) create mode 100644 stores/__tests__/email-store-tag-counts.test.ts diff --git a/stores/__tests__/email-store-tag-counts.test.ts b/stores/__tests__/email-store-tag-counts.test.ts new file mode 100644 index 00000000..07fb9eb2 --- /dev/null +++ b/stores/__tests__/email-store-tag-counts.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useEmailStore } from '../email-store'; +import { useAuthStore } from '../auth-store'; +import { useSettingsStore } from '../settings-store'; +import type { Email, Mailbox } from '@/lib/jmap/types'; +import type { IJMAPClient } from '@/lib/jmap/client-interface'; + +// Sidebar tag badges render from `tagCounts`, which is fetched from the server +// (`fetchTagCounts` -> `client.getTagCounts`) rather than derived from +// `state.emails`. Read/unread mutations therefore have to keep it in step the +// same way they keep `mailboxes[].unreadEmails` in step, or the tag unread +// count (and the bold tag name) stays stale until a full page reload. + +function makeMailbox(overrides: Partial = {}): Mailbox { + return { + id: 'inbox', + name: 'Inbox', + role: 'inbox', + sortOrder: 0, + totalEmails: 10, + unreadEmails: 5, + totalThreads: 10, + unreadThreads: 5, + myRights: { + mayReadItems: true, + mayAddItems: true, + mayRemoveItems: true, + maySetSeen: true, + maySetKeywords: true, + mayCreateChild: true, + mayRename: true, + mayDelete: true, + maySubmit: true, + }, + isSubscribed: true, + isShared: false, + ...overrides, + }; +} + +function makeEmail(overrides: Partial = {}): Email { + return { + id: 'email-1', + threadId: 'thread-1', + subject: 'Hi', + receivedAt: new Date().toISOString(), + keywords: {}, + mailboxIds: { inbox: true }, + ...overrides, + } as Email; +} + +function makeClient() { + return { + markAsRead: vi.fn().mockResolvedValue(undefined), + batchMarkAsRead: vi.fn().mockResolvedValue(undefined), + markMailboxAsRead: vi.fn().mockResolvedValue(3), + getTagCounts: vi.fn().mockResolvedValue({}), + getAccountId: vi.fn().mockReturnValue('account-a'), + } as unknown as IJMAPClient; +} + +describe('email-store tag counts stay in step with read state', () => { + let client: IJMAPClient; + + beforeEach(() => { + client = makeClient(); + + useAuthStore.setState({ + activeAccountId: 'account-a', + getClientForAccount: (() => client) as never, + } as never); + + useSettingsStore.setState({ + emailKeywords: [ + { id: 'ingsel', label: 'Ingsel', color: 'red' }, + { id: 'work', label: 'Work', color: 'blue' }, + ], + } as never); + + useEmailStore.setState({ + isUnifiedView: false, + viewingAccountId: null, + selectedMailbox: 'inbox', + mailboxes: [makeMailbox()], + accountMailboxes: {}, + emails: [], + selectedEmail: null, + selectedEmailIds: new Set(), + processingReadStatus: new Set(), + threadEmailsCache: new Map(), + tagCounts: { + ingsel: { total: 1658, unread: 47 }, + work: { total: 200, unread: 9 }, + }, + } as never); + }); + + it('decrements only the matching tag when a tagged email is marked read', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + + expect(useEmailStore.getState().tagCounts).toEqual({ + ingsel: { total: 1658, unread: 46 }, + work: { total: 200, unread: 9 }, + }); + }); + + it('increments the tag again when the email is marked unread', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true, $seen: true } })], + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', false); + + expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 48 }); + }); + + it('updates both tags when an email carries two tags', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true, '$label:work': true } })], + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + + expect(useEmailStore.getState().tagCounts).toEqual({ + ingsel: { total: 1658, unread: 46 }, + work: { total: 200, unread: 8 }, + }); + }); + + it('leaves tag counts alone for an untagged email', async () => { + useEmailStore.setState({ emails: [makeEmail({ keywords: {} })] } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + + expect(useEmailStore.getState().tagCounts).toEqual({ + ingsel: { total: 1658, unread: 47 }, + work: { total: 200, unread: 9 }, + }); + }); + + it('does not double-decrement when an already-read email is marked read', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true, $seen: true } })], + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + + expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 47 }); + }); + + it('never drives a tag unread count negative', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + tagCounts: { ingsel: { total: 3, unread: 0 } }, + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + + expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 3, unread: 0 }); + }); + + it('never alters `total` on a read-state change', async () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + } as never); + + await useEmailStore.getState().markAsRead(client, 'email-1', true); + await useEmailStore.getState().markAsRead(client, 'email-1', false); + + expect(useEmailStore.getState().tagCounts.ingsel.total).toBe(1658); + expect(useEmailStore.getState().tagCounts.work.total).toBe(200); + }); + + describe('batchMarkAsRead', () => { + it('applies the delta once per tag per changed email', async () => { + useEmailStore.setState({ + emails: [ + makeEmail({ id: 'e1', keywords: { '$label:ingsel': true } }), + makeEmail({ id: 'e2', keywords: { '$label:ingsel': true, '$label:work': true } }), + // Already read: must not contribute a delta. + makeEmail({ id: 'e3', keywords: { '$label:work': true, $seen: true } }), + // Untagged: must not contribute a delta. + makeEmail({ id: 'e4', keywords: {} }), + ], + selectedEmailIds: new Set(['e1', 'e2', 'e3', 'e4']), + } as never); + + await useEmailStore.getState().batchMarkAsRead(client, true); + + expect(useEmailStore.getState().tagCounts).toEqual({ + ingsel: { total: 1658, unread: 45 }, + work: { total: 200, unread: 8 }, + }); + }); + }); + + describe('markMailboxAsRead', () => { + it('refetches tag counts from the server rather than applying a local delta', async () => { + (client.getTagCounts as ReturnType).mockResolvedValue({ + ingsel: { total: 1658, unread: 0 }, + work: { total: 200, unread: 4 }, + }); + + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + } as never); + + const count = await useEmailStore.getState().markMailboxAsRead(client, 'inbox'); + expect(count).toBe(3); + + // The server bulk-marks emails that are not in `state.emails`, so a local + // delta would under-count: it has to refetch. + expect(client.getTagCounts).toHaveBeenCalledWith(['ingsel', 'work']); + + await vi.waitFor(() => { + expect(useEmailStore.getState().tagCounts).toEqual({ + ingsel: { total: 1658, unread: 0 }, + work: { total: 200, unread: 4 }, + }); + }); + }); + }); + + describe('setEmailKeywordsLocal', () => { + it('adjusts tag unread counts when the local keyword patch flips $seen', () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + } as never); + + useEmailStore.getState().setEmailKeywordsLocal('email-1', { + '$label:ingsel': true, + $seen: true, + }); + + expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 46 }); + }); + + it('leaves tag unread counts alone when $seen is unchanged', () => { + useEmailStore.setState({ + emails: [makeEmail({ keywords: { '$label:ingsel': true } })], + } as never); + + // Pin toggle: labels/pin change, read state does not. + useEmailStore.getState().setEmailKeywordsLocal('email-1', { + '$label:ingsel': true, + $pinned: true, + }); + + expect(useEmailStore.getState().tagCounts.ingsel).toEqual({ total: 1658, unread: 47 }); + }); + }); +}); diff --git a/stores/email-store.ts b/stores/email-store.ts index d44d0414..c2440711 100644 --- a/stores/email-store.ts +++ b/stores/email-store.ts @@ -584,6 +584,37 @@ function applyBatchMailboxCounterUpdate( return { mailboxes, accountMailboxes }; } +// Sidebar tag badges render from `tagCounts`, which is *fetched from the server* +// (`fetchTagCounts` -> `getTagCounts`) rather than derived from `state.emails`. +// So a read/unread mutation has to keep it in step locally, exactly as it does +// for `mailboxes[].unreadEmails` - otherwise the tag unread count (and the bold +// tag name) stays stale until a full page reload. +// +// `changes` carries one entry per email whose read state *actually changed* +// (callers already compute that), with delta -1 when it became read and +1 when +// it became unread. Only `unread` moves: read state never changes tag +// membership, so `total` is left alone. +function applyTagCountReadDelta( + tagCounts: Record, + changes: Array<{ keywords?: Record; delta: number }>, +): Record { + const keywordIds = useSettingsStore.getState().emailKeywords.map(k => k.id); + if (keywordIds.length === 0) return tagCounts; + + let next: Record | null = null; + for (const { keywords, delta } of changes) { + if (!keywords || delta === 0) continue; + for (const id of keywordIds) { + if (!keywords[`$label:${id}`]) continue; + const current = (next ?? tagCounts)[id]; + if (!current) continue; // Tag not in the fetched counts yet; nothing to adjust. + next = next ?? { ...tagCounts }; + next[id] = { total: current.total, unread: Math.max(0, current.unread + delta) }; + } + } + return next ?? tagCounts; +} + // Per-mailbox counter map (for applyBatchMailboxCounterUpdate) for removing a // group of emails from a folder: decrement total (and unread for unseen) for // each group email that lives in the mailbox. @@ -1421,6 +1452,11 @@ export const useEmailStore = create((set, get) => ({ ? { ...state.selectedEmail, keywords: { ...state.selectedEmail.keywords, $seen: read } } : state.selectedEmail, ...mailboxPatch, + // Same delta, applied to every tag this email carries, so the sidebar + // tag badges track the folder counters instead of going stale. + tagCounts: applyTagCountReadDelta(state.tagCounts, [ + { keywords: emailInState.keywords, delta }, + ]), processingReadStatus: newProcessingSet, // Also update threadEmailsCache so expanded dropdowns reflect the change threadEmailsCache: (() => { @@ -1976,14 +2012,24 @@ export const useEmailStore = create((set, get) => ({ }, setEmailKeywordsLocal: (emailId, keywords) => { - set((state) => ({ - emails: state.emails.map(e => - e.id === emailId ? { ...e, keywords: { ...keywords } } : e - ), - selectedEmail: state.selectedEmail?.id === emailId - ? { ...state.selectedEmail, keywords: { ...keywords } } - : state.selectedEmail, - })); + set((state) => { + // This patch replaces the whole keyword map, so it can flip $seen as well + // as labels. Only a genuine read-state change moves the tag unread counts. + const previous = state.emails.find(e => e.id === emailId) ?? state.selectedEmail; + const wasRead = previous?.keywords?.$seen ?? false; + const isRead = keywords.$seen ?? false; + const delta = wasRead === isRead ? 0 : (isRead ? -1 : 1); + + return { + emails: state.emails.map(e => + e.id === emailId ? { ...e, keywords: { ...keywords } } : e + ), + selectedEmail: state.selectedEmail?.id === emailId + ? { ...state.selectedEmail, keywords: { ...keywords } } + : state.selectedEmail, + tagCounts: applyTagCountReadDelta(state.tagCounts, [{ keywords, delta }]), + }; + }); }, // Batch operations @@ -2041,9 +2087,19 @@ export const useEmailStore = create((set, get) => ({ }; }); + // Tag badges follow the same delta as the folder counters, counting only + // the emails whose read state actually changed. + const tagCounts = applyTagCountReadDelta( + get().tagCounts, + affectedEmails + .filter(email => (email.keywords?.$seen ?? false) !== read) + .map(email => ({ keywords: email.keywords, delta: read ? -1 : 1 })), + ); + set({ emails: updatedEmails, ...mailboxPatch, + tagCounts, selectedEmailIds: new Set(), isLoading: false }); @@ -3158,6 +3214,14 @@ export const useEmailStore = create((set, get) => ({ ), })); + // Tag counts are refetched here rather than adjusted with a local delta + // (as markAsRead/batchMarkAsRead do). This is a server-side bulk operation + // over the *whole* mailbox, so it also marks emails that were never loaded + // into `state.emails` - a local delta would only see the loaded page and + // would leave the tag counts drifting high. Fire-and-forget: the folder + // counters above already update instantly. + void get().fetchTagCounts(client); + return count; } catch (error) { set({ error: error instanceof Error ? error.message : 'Failed to mark folder as read' }); From 37152504b428f6f28f320cf5cd9f0c266da95e4e Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:57:19 +0200 Subject: [PATCH 09/22] feat(composer): drag-to-reorder To/Cc/Bcc recipient chips (#593) Recipient chips could already be dragged between the To/Cc/Bcc fields, but a drop always appended and same-field drops were a no-op, so recipients could not be rearranged without deleting and re-adding them. Add positional drag-and-drop: while dragging a chip, an insertion caret shows the gap it would land in (based on which half of the hovered chip the pointer is over, mirrored for RTL); dropping inserts it there. - same-field drop reorders the chip locally (via onChipsChange), using the source index carried in the drag payload (fromIndex) and adjusting for the removal shift; dropping onto its own position is a no-op; - cross-field drop inserts at the drop position: handleMoveChip gained an optional toIndex (omitted = append, e.g. dropping onto a hidden Cc/Bcc button, preserving existing behaviour); - per-chip onDragOver computes the target gap; the container handles the trailing gap (past the last chip / over the input). No new user-facing strings (the caret is purely visual), so no locale changes. Tests (components/email/__tests__/recipient-chip-drag.test.tsx): reorder to end / front, self-drop no-op, cross-field positional insert, and caret visibility. Also add the missing findComposeIdentityId export to the reply-identity mock in the recipient drag/paste suites so mounts in compose mode. --- .../__tests__/recipient-chip-drag.test.tsx | 121 +++++++++++++++++- components/email/email-composer.tsx | 111 ++++++++++++++-- 2 files changed, 216 insertions(+), 16 deletions(-) diff --git a/components/email/__tests__/recipient-chip-drag.test.tsx b/components/email/__tests__/recipient-chip-drag.test.tsx index 68f90dec..f3138b38 100644 --- a/components/email/__tests__/recipient-chip-drag.test.tsx +++ b/components/email/__tests__/recipient-chip-drag.test.tsx @@ -230,7 +230,7 @@ describe('RecipientChipInput drag and drop', () => { fireEvent.dragStart(chipSpan, { dataTransfer: dt }); const payload = JSON.parse(dt.getData('application/x-recipient-chip')); - expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to' }); + expect(payload).toEqual({ recipient: { email: 'alice@example.com' }, fromField: 'to', fromIndex: 0 }); }); it('keeps a display name with a comma in a single chip (array model)', async () => { @@ -244,7 +244,7 @@ describe('RecipientChipInput drag and drop', () => { const dt = new MockDataTransfer(); fireEvent.dragStart(chipSpan, { dataTransfer: dt }); const payload = JSON.parse(dt.getData('application/x-recipient-chip')); - expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to' }); + expect(payload).toEqual({ recipient: { name: 'Doo, John', email: 'john@doo.org' }, fromField: 'to', fromIndex: 0 }); }); it('onDragEnd clears the opacity class on the chip', async () => { @@ -343,4 +343,121 @@ describe('RecipientChipInput drag and drop', () => { const ccLabel = await screen.findByText('cc_label'); expect(ccLabel).toBeInTheDocument(); }); + + // ─── Reordering within / across fields (#593) ───────────────────────────────── + // jsdom ignores `clientX` in fireEvent's init for drag events (it's a + // read-only MouseEvent getter) and gives every element a zero-size rect at + // (0,0). So we dispatch events with `clientX` forced via defineProperty; with + // the rect midpoint at 0, clientX>0 lands AFTER the hovered chip, <0 BEFORE. + + const THREE = { ...BASE_DATA, to: 'alice@example.com, bob@example.com, carol@example.com, ' }; + + const chipByText = async (text: string) => + (await screen.findByText(text)).closest('[draggable]') as HTMLElement; + + /** Dispatch a drag event with a real clientX (fireEvent init drops it). */ + const fireDnd = (type: 'dragover' | 'drop', el: HTMLElement, dt: MockDataTransfer, clientX: number) => { + const e = new Event(type, { bubbles: true, cancelable: true }); + Object.defineProperty(e, 'clientX', { value: clientX }); + Object.defineProperty(e, 'dataTransfer', { value: dt }); + act(() => { fireEvent(el, e); }); + }; + const BEFORE = -100; + const AFTER = 100; + + /** Ordered chip labels of the field-container that holds `anchorText`. */ + const orderIn = (anchorText: string) => { + const containers = Array.from(document.querySelectorAll('[class*="flex-wrap"]')); + const c = containers.find(el => + Array.from(el.querySelectorAll('[draggable]')).some(d => d.textContent?.includes(anchorText)) + ) as HTMLElement; + return Array.from(c.querySelectorAll('[draggable]')).map(el => el.textContent?.trim() ?? ''); + }; + + /** All draggable chips (across fields) whose label contains `text`. */ + const draggableChipsWith = (text: string) => + Array.from(document.querySelectorAll('[draggable]')).filter(el => el.textContent?.includes(text)); + + it('reorders a chip to the end of the same field (drop after the last chip)', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); + const carol = await chipByText('carol@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); // fromIndex 0 + fireDnd('dragover', carol, dt, AFTER); // after carol -> index 3 + fireDnd('drop', carol, dt, AFTER); + + expect(orderIn('bob@example.com')).toEqual([ + 'bob@example.com', 'carol@example.com', 'alice@example.com', + ]); + }); + + it('reorders a chip to the front of the same field (drop before the first chip)', async () => { + render(); + await screen.findByText('carol@example.com'); + const carol = await chipByText('carol@example.com'); + const alice = await chipByText('alice@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(carol, { dataTransfer: dt }); // fromIndex 2 + fireDnd('dragover', alice, dt, BEFORE); // before alice -> index 0 + fireDnd('drop', alice, dt, BEFORE); + + expect(orderIn('alice@example.com')).toEqual([ + 'carol@example.com', 'alice@example.com', 'bob@example.com', + ]); + }); + + it('dropping a chip onto its own position leaves the order unchanged', async () => { + render(); + await screen.findByText('bob@example.com'); + const bob = await chipByText('bob@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(bob, { dataTransfer: dt }); // fromIndex 1 + fireDnd('dragover', bob, dt, BEFORE); // before itself -> index 1 (no-op) + fireDnd('drop', bob, dt, BEFORE); + + expect(orderIn('bob@example.com')).toEqual([ + 'alice@example.com', 'bob@example.com', 'carol@example.com', + ]); + }); + + it('moves a chip into another field at the drop position (cross-field reorder)', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); // To + const y = await chipByText('y@example.com'); // Cc + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); + fireDnd('dragover', y, dt, BEFORE); // before y -> index 1 in Cc + fireDnd('drop', y, dt, BEFORE); + + // alice lands between x and y; To no longer holds it (count only real chips, + // not the leftover jsdom drag-preview element) + expect(orderIn('x@example.com')).toEqual([ + 'x@example.com', 'alice@example.com', 'y@example.com', + ]); + expect(draggableChipsWith('alice@example.com')).toHaveLength(1); + }); + + it('shows a drop caret only while a chip is dragged over the field', async () => { + render(); + await screen.findByText('alice@example.com'); + const alice = await chipByText('alice@example.com'); + const bob = await chipByText('bob@example.com'); + + const dt = new MockDataTransfer(); + fireEvent.dragStart(alice, { dataTransfer: dt }); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull(); + + fireDnd('dragover', bob, dt, BEFORE); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).not.toBeNull(); + + fireEvent.dragEnd(alice); + expect(document.querySelector('[data-testid="recipient-drop-caret"]')).toBeNull(); + }); }); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index df7543bf..8d41f476 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -936,7 +936,10 @@ export function EmailComposer({ } }, [plainTextMode]); - const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => { + // Move a chip from one recipient field to another. `toIndex`, when given, + // inserts at that position in the destination (drag-and-drop reordering, + // #593); omitted, it appends (e.g. dropping onto a hidden Cc/Bcc button). + const handleMoveChip = useCallback((recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => { if (fromField === toField) return; const setters = { to: setTo, cc: setCc, bcc: setBcc }; const groupKey = (r: Recipient) => r.group ? r.group.members.map(m => m.email.toLowerCase()).join(',') : ''; @@ -946,7 +949,13 @@ export function EmailComposer({ const idx = prev.findIndex(r => sameRecipient(r, recipient)); return idx === -1 ? prev : prev.filter((_, i) => i !== idx); }); - setters[toField](prev => prev.some(r => sameRecipient(r, recipient)) ? prev : [...prev, recipient]); + setters[toField](prev => { + if (prev.some(r => sameRecipient(r, recipient))) return prev; + const at = toIndex == null ? prev.length : Math.max(0, Math.min(toIndex, prev.length)); + const next = [...prev]; + next.splice(at, 0, recipient); + return next; + }); if (toField === 'cc') setShowCc(true); if (toField === 'bcc') setShowBcc(true); }, [setTo, setCc, setBcc, setShowCc, setShowBcc]); @@ -2895,7 +2904,7 @@ function RecipientChipInput({ validationError?: boolean; validationMessage?: string; onTab?: () => void; - onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc') => void; + onMoveChip: (recipient: Recipient, fromField: 'to' | 'cc' | 'bcc', toField: 'to' | 'cc' | 'bcc', toIndex?: number) => void; }) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); @@ -2904,6 +2913,9 @@ function RecipientChipInput({ const [editValue, setEditValue] = useState(''); const [isDragOver, setIsDragOver] = useState(false); const [draggingIndex, setDraggingIndex] = useState(null); + // Gap (0..chips.length) a dragged chip would drop into; drives the insertion + // caret and positional drop for reordering (#593). null when not dragging. + const [dropIndex, setDropIndex] = useState(null); const editInputRef = useRef(null); // Focus edit input when editing starts @@ -3060,27 +3072,81 @@ function RecipientChipInput({ onAutoBlur(e, field); }; + const isChipDrag = (e: React.DragEvent) => + e.dataTransfer.types.includes('application/x-recipient-chip'); + + // Dragging over empty container space (past the last chip / over the input) + // targets the end of the list. const handleContainerDragOver = (e: React.DragEvent) => { - if (!e.dataTransfer.types.includes('application/x-recipient-chip')) return; + if (!isChipDrag(e)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; setIsDragOver(true); + setDropIndex(chips.length); }; const handleContainerDragLeave = (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) { setIsDragOver(false); + setDropIndex(null); + } + }; + + // Dragging over a chip picks the gap before or after it based on which half + // the pointer is in (mirrored for RTL). stopPropagation keeps the container + // handler from overriding this finer target. + const handleChipDragOver = (e: React.DragEvent, index: number) => { + if (!isChipDrag(e)) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const rtl = typeof window !== 'undefined' && + getComputedStyle(e.currentTarget as Element).direction === 'rtl'; + const past = rtl + ? e.clientX < rect.left + rect.width / 2 + : e.clientX > rect.left + rect.width / 2; + setIsDragOver(true); + setDropIndex(past ? index + 1 : index); + }; + + // Insert the dragged chip at `target`. Same-field is a local reorder; + // cross-field routes through onMoveChip with the destination index (#593). + const performDrop = (e: React.DragEvent, target: number) => { + e.preventDefault(); + setIsDragOver(false); + setDropIndex(null); + setDraggingIndex(null); + const raw = e.dataTransfer.getData('application/x-recipient-chip'); + if (!raw) return; + let payload: { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc'; fromIndex?: number }; + try { + payload = JSON.parse(raw); + } catch { + return; + } + const { recipient, fromField, fromIndex } = payload; + const to = Math.max(0, Math.min(target, chips.length)); + + if (fromField === field) { + const from = typeof fromIndex === 'number' + ? fromIndex + : chips.findIndex(c => c.email === recipient.email && (c.name ?? '') === (recipient.name ?? '')); + if (from < 0 || from >= chips.length) return; + // Removing the source before `to` shifts the target left by one. + const insertAt = to > from ? to - 1 : to; + if (insertAt === from) return; // dropped onto its own position + const next = [...chips]; + const [moved] = next.splice(from, 1); + next.splice(insertAt, 0, moved); + onChipsChange(next); + } else { + onMoveChip(recipient, fromField, field, to); } }; const handleContainerDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const raw = e.dataTransfer.getData('application/x-recipient-chip'); - if (!raw) return; - const { recipient, fromField } = JSON.parse(raw) as { recipient: Recipient; fromField: 'to' | 'cc' | 'bcc' }; - if (fromField === field) return; - onMoveChip(recipient, fromField, field); + performDrop(e, dropIndex ?? chips.length); }; return ( @@ -3100,20 +3166,29 @@ function RecipientChipInput({ const isEditing = editingChip?.index === i; const chipDisplay = formatChipDisplay(chip); return ( + + {dropIndex === i && ( + + )} { e.stopPropagation(); e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field })); + e.dataTransfer.setData('application/x-recipient-chip', JSON.stringify({ recipient: chip, fromField: field, fromIndex: i })); // Show the address while dragging, matching the email-list drag preview. const dragPreview = createChipDragPreview(chip.group ? chipDisplay : chip.email); e.dataTransfer.setDragImage(dragPreview, 0, 0); requestAnimationFrame(() => dragPreview.remove()); setDraggingIndex(i); }} - onDragEnd={() => setDraggingIndex(null)} + onDragEnd={() => { setDraggingIndex(null); setDropIndex(null); }} + onDragOver={(e) => handleChipDragOver(e, i)} + onDrop={(e) => { e.stopPropagation(); performDrop(e, dropIndex ?? i); }} className={cn( "inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-sm border border-border transition-colors", isEditing @@ -3179,8 +3254,16 @@ function RecipientChipInput({ )} + ); })} + {dropIndex === chips.length && chips.length > 0 && ( + + )} {!editingChip && ( Date: Sun, 12 Jul 2026 18:10:35 +0200 Subject: [PATCH 10/22] fix: add bodyValues to onRenderEmailBody hook --- components/email/email-viewer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 88ff7781..f808af0c 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -1175,6 +1175,7 @@ export function EmailViewer({ id: email.id, contentType, bodyStructure: email.bodyStructure, + bodyValues: email.bodyValues, attachments: email.attachments, blobId: email.blobId, from: email.from, From 20d02214df8412f5bc81a0a06e9306f80d556142 Mon Sep 17 00:00:00 2001 From: Paulhenry Saux Date: Sun, 12 Jul 2026 18:13:37 +0200 Subject: [PATCH 11/22] fix: add new plugin api methods introduced by #586 to protocol plugin sandbox --- lib/plugin-sandbox/protocol.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/plugin-sandbox/protocol.ts b/lib/plugin-sandbox/protocol.ts index 1cb025fa..33a11f21 100644 --- a/lib/plugin-sandbox/protocol.ts +++ b/lib/plugin-sandbox/protocol.ts @@ -243,6 +243,7 @@ export const API_METHODS = [ 'http.post', 'http.fetch', 'webauthn.getOrCreate', 'jmap.fetchBlob', 'jmap.sendRaw', + 'upfiles.get', 'upfiles.save', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'toast.success', 'toast.error', 'toast.info', 'toast.warning', 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', 'ui.downloadFile' From 01e5cd69cf19b9170c1aa256635feb7686ca6472 Mon Sep 17 00:00:00 2001 From: Stefan Hildebrandt <695494+hildebrandttk@users.noreply.github.com> Date: Sat, 27 Jun 2026 13:07:48 +0200 Subject: [PATCH 12/22] fix(identity): sync default sender identity per account (#507) The default sender identity (`preferredPrimaryId`) lived only in the browser-local `identity-storage` store and was never written to the synced settings, so the choice was lost on clearing site data / switching browsers and never appeared in exported settings. Persist it in the synced settings store, keyed **per account** (`preferredIdentityIds: Record`), mirroring the existing per-account `allMailFolderIds`. Per-account keying is required because JMAP identity ids are account-scoped and would otherwise collide across accounts / the unified mailbox. This supersedes the earlier username-keyed fix that had landed on main: the username-keyed map, `loadIdentities()` fallback write, and the `applyPreferredIdentityOrdering` store action (plus its settings-store hook) are removed so a single account-keyed mechanism remains. - settings-store: `preferredIdentityIds` (accountId -> identityId) in state, defaults, export, import (non-record guard), rehydrate coercion, v6 migration. - auth-store: `applyPreferredIdentity(accountId?)` reorders the active account's identities once synced settings load, and performs the one-time migration of the pre-#507 browser-local default into the synced map (keyed by accountId). Invoked from every `loadFromServer().finally()` (login / OAuth / SSO / switch / restore). `loadIdentities()` now only applies the local fallback ordering. - identity-manager-modal: the star action writes the choice by `activeAccountId`. - identity-store: `preferredPrimaryId` kept as a local (sync-off) fallback. - tests: per-account independence, export/import round-trip, import guard, and applyPreferredIdentity reorder / active-account gating / local-default migration. --- .../identity/identity-manager-modal.tsx | 14 ++- .../apply-preferred-identity.test.ts | 90 +++++++++++++++ .../settings-store-preferred-identity.test.ts | 58 ++++++++++ stores/auth-store.ts | 104 +++++++++++------- stores/identity-store.ts | 9 +- stores/settings-store.ts | 42 ++++--- 6 files changed, 249 insertions(+), 68 deletions(-) create mode 100644 stores/__tests__/apply-preferred-identity.test.ts create mode 100644 stores/__tests__/settings-store-preferred-identity.test.ts diff --git a/components/identity/identity-manager-modal.tsx b/components/identity/identity-manager-modal.tsx index 521ac5f1..5f42a563 100644 --- a/components/identity/identity-manager-modal.tsx +++ b/components/identity/identity-manager-modal.tsx @@ -9,6 +9,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { IdentityForm } from './identity-form'; import { useIdentityStore } from '@/stores/identity-store'; import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; import { useSettingsStore } from '@/stores/settings-store'; function useSyncIdentities() { @@ -207,15 +208,16 @@ export function IdentityManagerModal({ isOpen, onClose }: IdentityManagerModalPr const handleSetPrimary = useCallback((identity: Identity) => { setPreferredPrimary(identity.id); - // Persist to the synced settings (keyed by username, matching how - // loadIdentities reads it back) so the choice survives a new browser / - // cleared site data and reaches other devices (#507). - const username = useAuthStore.getState().username || ''; - if (username) { + // Persist the choice per account in the synced settings store so it + // survives clearing site data, follows the user across devices, and shows + // up in exported settings (issue #507). JMAP identity ids are account- + // scoped, so the default is keyed by the active account. + const activeAccountId = useAccountStore.getState().activeAccountId; + if (activeAccountId) { const current = useSettingsStore.getState().preferredIdentityIds; useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, - [username]: identity.id, + [activeAccountId]: identity.id, }); } // Re-sort: move the preferred identity to the front diff --git a/stores/__tests__/apply-preferred-identity.test.ts b/stores/__tests__/apply-preferred-identity.test.ts new file mode 100644 index 00000000..6443a65f --- /dev/null +++ b/stores/__tests__/apply-preferred-identity.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { applyPreferredIdentity, useAuthStore } from '../auth-store'; +import { useIdentityStore } from '../identity-store'; +import { useAccountStore } from '../account-store'; +import { useSettingsStore } from '../settings-store'; +import type { Identity } from '@/lib/jmap/types'; + +const makeIdentity = (overrides: Partial = {}): Identity => ({ + id: 'id-1', + name: 'Test User', + email: 'test@example.com', + mayDelete: true, + ...overrides, +}); + +const IDS = [ + makeIdentity({ id: 'id-1', name: 'Alice', email: 'alice@example.com' }), + makeIdentity({ id: 'id-2', name: 'Bob', email: 'bob@example.com' }), + makeIdentity({ id: 'id-3', name: 'Carol', email: 'carol@example.com' }), +]; + +/** + * applyPreferredIdentity() is the single mechanism that honours the synced, + * per-account default sender identity (#507). These tests drive the real + * zustand stores directly (as the other auth-store tests do). + */ +describe('applyPreferredIdentity (issue #507)', () => { + beforeEach(() => { + useIdentityStore.setState({ identities: [...IDS], preferredPrimaryId: null }); + useAuthStore.setState({ identities: [...IDS], primaryIdentity: IDS[0] }); + useAccountStore.setState({ activeAccountId: 'acc-1' }); + useSettingsStore.setState({ preferredIdentityIds: {} }); + }); + + it('reorders the active account so the synced preferred identity is primary', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } }); + + applyPreferredIdentity('acc-1'); + + expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-3', 'id-1', 'id-2']); + expect(useAuthStore.getState().primaryIdentity?.id).toBe('id-3'); + expect(useIdentityStore.getState().preferredPrimaryId).toBe('id-3'); + }); + + it('defaults to the active account when no accountId is passed', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-2' } }); + + applyPreferredIdentity(); + + expect(useAuthStore.getState().identities[0].id).toBe('id-2'); + }); + + it('is a no-op when the target is not the active account', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acc-2': 'id-3' } }); + + applyPreferredIdentity('acc-2'); + + // active account's live ordering must be untouched + expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']); + }); + + it('is a no-op when the account has no synced default and no local fallback', () => { + applyPreferredIdentity('acc-1'); + + expect(useAuthStore.getState().identities.map((i) => i.id)).toEqual(['id-1', 'id-2', 'id-3']); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({}); + }); + + it('migrates the pre-#507 browser-local default into the synced map, keyed by accountId', () => { + // No synced entry, but a local (identity-storage) preferred primary exists. + useIdentityStore.setState({ preferredPrimaryId: 'id-2' }); + + applyPreferredIdentity('acc-1'); + + // adopted, persisted per account, and applied to the live ordering + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-2' }); + expect(useAuthStore.getState().identities[0].id).toBe('id-2'); + }); + + it('prefers the synced value over the local fallback', () => { + useIdentityStore.setState({ preferredPrimaryId: 'id-2' }); + useSettingsStore.setState({ preferredIdentityIds: { 'acc-1': 'id-3' } }); + + applyPreferredIdentity('acc-1'); + + expect(useAuthStore.getState().identities[0].id).toBe('id-3'); + // the synced value is not overwritten by the migration + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acc-1': 'id-3' }); + }); +}); diff --git a/stores/__tests__/settings-store-preferred-identity.test.ts b/stores/__tests__/settings-store-preferred-identity.test.ts new file mode 100644 index 00000000..230efad1 --- /dev/null +++ b/stores/__tests__/settings-store-preferred-identity.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSettingsStore } from '../settings-store'; + +describe('settings-store per-account preferredIdentityIds (issue #507)', () => { + beforeEach(() => { + useSettingsStore.setState({ preferredIdentityIds: {} }); + }); + + it('defaults to an empty record (no account has a synced default)', () => { + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({}); + }); + + it('keeps each account default independent', () => { + useSettingsStore.setState({ + preferredIdentityIds: { 'acct-1': 'b', 'acct-2': 'c' }, + }); + const map = useSettingsStore.getState().preferredIdentityIds; + expect(map['acct-1']).toBe('b'); + expect(map['acct-2']).toBe('c'); + expect(map['acct-3']).toBeUndefined(); + }); + + it('round-trips through export -> import so the choice survives clearing site data', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } }); + const json = useSettingsStore.getState().exportSettings(); + // Appears in exported JSON (issue #507 acceptance criterion). + expect(JSON.parse(json).preferredIdentityIds).toEqual({ 'acct-1': 'b' }); + + // Simulate a fresh browser: clear, then import the exported settings. + useSettingsStore.setState({ preferredIdentityIds: {} }); + expect(useSettingsStore.getState().importSettings(json)).toBe(true); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' }); + }); + + describe('importSettings non-record guard', () => { + it('ignores a legacy array shape', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } }); + const ok = useSettingsStore.getState().importSettings( + JSON.stringify({ preferredIdentityIds: ['b'] }), + ); + expect(ok).toBe(true); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' }); + }); + + it('ignores a null value', () => { + useSettingsStore.setState({ preferredIdentityIds: { 'acct-1': 'b' } }); + useSettingsStore.getState().importSettings(JSON.stringify({ preferredIdentityIds: null })); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-1': 'b' }); + }); + + it('accepts a proper per-account record', () => { + useSettingsStore.getState().importSettings( + JSON.stringify({ preferredIdentityIds: { 'acct-9': 'a' } }), + ); + expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' }); + }); + }); +}); diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 85b39ec6..c117dffc 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -49,7 +49,6 @@ interface AuthState { clearError: () => void; syncIdentities: () => void; refreshIdentities: () => Promise; - applyPreferredIdentityOrdering: () => void; getClientForAccount: (accountId: string) => JMAPClient | undefined; getAllConnectedClients: () => Map; } @@ -198,25 +197,16 @@ function sortIdentities(rawIdentities: Identity[], username: string): Identity[] } function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } { - const settings = useSettingsStore.getState(); - const preferredMap = settings.preferredIdentityIds || {}; - let preferredPrimaryId = preferredMap[username] ?? null; - - // One-time migration: builds before #507 stored the preferred identity only - // in the browser-local identity-storage (never synced). If the synced - // settings have no entry for this account yet, adopt that legacy local value - // and write it into the synced settings so it persists across devices. - if (preferredPrimaryId == null) { - const legacy = useIdentityStore.getState().preferredPrimaryId; - if (legacy) { - preferredPrimaryId = legacy; - settings.updateSetting('preferredIdentityIds', { ...preferredMap, [username]: legacy }); - } - } + // The synced per-account default sender identity (#507) is keyed by + // AccountEntry.id and re-applied by applyPreferredIdentity() once + // loadFromServer resolves (the accountId isn't known here). At load time we + // only honour the browser-local fallback (identity-storage) so the ordering + // is stable before - or entirely without - settings sync. + const preferredPrimaryId = useIdentityStore.getState().preferredPrimaryId; const identities = sortIdentities(rawIdentities, username); - // If user has a preferred primary, move it to front + // If a local preferred primary is set, move it to the front. if (preferredPrimaryId) { const idx = identities.findIndex((id) => id.id === preferredPrimaryId); if (idx > 0) { @@ -227,12 +217,60 @@ function loadIdentities(rawIdentities: Identity[], username: string): { identiti const primaryIdentity = identities[0] ?? null; useIdentityStore.getState().setIdentities(identities); - // Mirror the resolved choice into the identity store so the identity-manager - // UI (the ⭐ marker) reflects the active account's preferred identity. - useIdentityStore.setState({ preferredPrimaryId }); return { identities, primaryIdentity }; } +/** + * Re-apply the per-account default sender identity once synced settings are + * available (issue #507). The choice is stored server-side in the settings + * store (`preferredIdentityIds`, keyed by AccountEntry.id), so it can only be + * applied after `loadFromServer` resolves. It reorders the account's identities + * so the preferred one is primary - the composer defaults its `From` to + * identities[0]. No-op when nothing is configured for the account. + * + * Also performs the one-time migration of the pre-#507 browser-local default + * (identity-storage) into the synced per-account map, keyed by accountId. + * + * @param accountId The account to apply for; defaults to the active account. + */ +export function applyPreferredIdentity(accountId?: string | null): void { + const targetId = accountId ?? useAccountStore.getState().activeAccountId; + if (!targetId) return; + + const idStore = useIdentityStore.getState(); + // Only touch the live identity store when it currently holds this account's + // identities (true for the active account). Switching snapshots/restores the + // ordering per account, so a background account's order is restored later. + // The local fallback below also belongs to the active account, so gate first. + if (useAccountStore.getState().activeAccountId !== targetId) return; + + let preferred = useSettingsStore.getState().preferredIdentityIds[targetId] ?? null; + + // One-time migration: before #507 the default lived only in the browser-local + // identity-storage (never synced). If the synced map has no entry for this + // account yet, adopt that local value and persist it (keyed by accountId) so + // it follows the user across devices. + if (!preferred) { + const legacy = idStore.preferredPrimaryId; + if (legacy) { + preferred = legacy; + const current = useSettingsStore.getState().preferredIdentityIds; + useSettingsStore.getState().updateSetting('preferredIdentityIds', { ...current, [targetId]: legacy }); + } + } + if (!preferred) return; + + idStore.setPreferredPrimary(preferred); + const ids = [...idStore.identities]; + const idx = ids.findIndex((i) => i.id === preferred); + if (idx > 0) { + const [p] = ids.splice(idx, 1); + ids.unshift(p); + idStore.setIdentities(ids); + } + useAuthStore.setState({ identities: ids, primaryIdentity: ids[0] ?? null }); +} + function getLocaleLoginPath(): string { if (typeof window === 'undefined') return '/en/login'; @@ -638,6 +676,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { useSettingsStore.getState().enableSync(username, serverUrl); + applyPreferredIdentity(accountId); }); }).catch(() => {}); @@ -840,6 +879,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { useSettingsStore.getState().enableSync(username, serverUrl); + applyPreferredIdentity(accountId); }); }).catch(() => {}); @@ -977,6 +1017,7 @@ export const useAuthStore = create()( if (!cfg.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => { useSettingsStore.getState().enableSync(username, ssoServerUrl); + applyPreferredIdentity(accountId); }); }).catch(() => {}); @@ -1363,6 +1404,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => { useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl); + applyPreferredIdentity(targetAccount.id); }); }).catch(() => {}); }, @@ -1534,6 +1576,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(targetAccount.username, targetAccount.serverUrl).finally(() => { useSettingsStore.getState().enableSync(targetAccount.username, targetAccount.serverUrl); + applyPreferredIdentity(targetAccount.id); }); }).catch(() => {}); return; @@ -1647,6 +1690,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(state.username || '', state.serverUrl!).finally(() => { useSettingsStore.getState().enableSync(state.username || '', state.serverUrl!); + applyPreferredIdentity(accountId); }); }).catch(() => {}); return; @@ -1718,6 +1762,7 @@ export const useAuthStore = create()( if (!config.settingsSyncEnabled) return; useSettingsStore.getState().loadFromServer(username, serverUrl).finally(() => { useSettingsStore.getState().enableSync(username, serverUrl); + applyPreferredIdentity(accountId); }); }).catch(() => {}); return; @@ -1761,25 +1806,6 @@ export const useAuthStore = create()( set({ identities, primaryIdentity }); }, - // Re-sort the already-loaded identities to honor the active account's - // synced preferred-primary identity, without a network round-trip. Used - // after settings load from the server so a fresh browser reflects the - // synced default (#507). - applyPreferredIdentityOrdering: () => { - const { username, identities } = get(); - if (!username || identities.length === 0) return; - const preferredId = useSettingsStore.getState().preferredIdentityIds?.[username] ?? null; - useIdentityStore.setState({ preferredPrimaryId: preferredId }); - if (!preferredId) return; - const idx = identities.findIndex((id) => id.id === preferredId); - if (idx <= 0) return; // already first, or not present - const reordered = [...identities]; - const [preferred] = reordered.splice(idx, 1); - reordered.unshift(preferred); - useIdentityStore.getState().setIdentities(reordered); - set({ identities: reordered, primaryIdentity: reordered[0] ?? null }); - }, - refreshIdentities: async () => { const { client, username } = get(); if (!client || !username) return; diff --git a/stores/identity-store.ts b/stores/identity-store.ts index e34a3223..e233be78 100644 --- a/stores/identity-store.ts +++ b/stores/identity-store.ts @@ -123,7 +123,14 @@ export const useIdentityStore = create()( }), { name: 'identity-storage', - // Only persist sub-addressing data, not identities (they're server-side) + // Only persist sub-addressing data, not identities (they're server-side). + // The default sender identity (`preferredPrimaryId`) is the per-account + // value for the *active* account; it is kept here purely as a local + // fallback so the choice survives a reload when settings sync is off. + // The durable, cross-device, exportable source of truth is the synced + // settings store, keyed per account (`preferredIdentityIds`), which is + // re-applied via applyPreferredIdentity() once server settings load and + // overrides this value per account (issue #507). partialize: (state) => ({ subAddress: state.subAddress, preferredPrimaryId: state.preferredPrimaryId, diff --git a/stores/settings-store.ts b/stores/settings-store.ts index 1cd4ccc5..21e68627 100644 --- a/stores/settings-store.ts +++ b/stores/settings-store.ts @@ -178,13 +178,6 @@ interface SettingsState { requestReadReceiptDefault: boolean; // Pre-check "request read receipt" in the composer readReceiptResponse: ReadReceiptResponse; // How to respond to incoming read-receipt requests - // Identities - // Per-account default ("preferred primary") sender identity, keyed by - // username (the same key settings sync uses). A JMAP identity id is only - // meaningful within its own account, so this must be account-scoped. Synced - // so the choice survives a new browser / cleared site data (#507). - preferredIdentityIds: Record; - // Privacy & Security sessionTimeout: number; // minutes (0 = never) trustedSenders: string[]; // Email addresses that can load external content @@ -258,6 +251,12 @@ interface SettingsState { // explicit [] = "no folders". (Replaced the legacy global string[] | null.) allMailFolderIds: Record; + // Per-account default sender identity, keyed by AccountEntry.id -> JMAP + // Identity id. Synced (and exported) so the chosen default survives clearing + // site data and follows the user across browsers/devices (issue #507). Kept + // per account because JMAP identity ids are account-scoped and would collide. + preferredIdentityIds: Record; + // Email Display disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation @@ -396,9 +395,6 @@ const DEFAULT_SETTINGS = { requestReadReceiptDefault: false, readReceiptResponse: 'ask' as ReadReceiptResponse, - // Identities - preferredIdentityIds: {} as Record, - // Privacy & Security sessionTimeout: 0, // Never trustedSenders: [] as string[], @@ -452,6 +448,7 @@ const DEFAULT_SETTINGS = { // All Mail view (gated) enableAllMailView: false, allMailFolderIds: {} as Record, + preferredIdentityIds: {} as Record, enableCrossUnreadView: false, enableCrossStarredView: false, @@ -609,7 +606,6 @@ export const useSettingsStore = create()( signatureSeparatorEnabled: state.signatureSeparatorEnabled, requestReadReceiptDefault: state.requestReadReceiptDefault, readReceiptResponse: state.readReceiptResponse, - preferredIdentityIds: state.preferredIdentityIds, sessionTimeout: state.sessionTimeout, emailNotificationsEnabled: state.emailNotificationsEnabled, emailNotificationSound: state.emailNotificationSound, @@ -637,6 +633,7 @@ export const useSettingsStore = create()( includeGroupInUnified: state.includeGroupInUnified, enableAllMailView: state.enableAllMailView, allMailFolderIds: state.allMailFolderIds, + preferredIdentityIds: state.preferredIdentityIds, enableCrossUnreadView: state.enableCrossUnreadView, enableCrossStarredView: state.enableCrossStarredView, enableCrossAllView: state.enableCrossAllView, @@ -694,8 +691,8 @@ export const useSettingsStore = create()( if (key === 'allMailFolderIds' && !isPlainRecord(settings[key])) { return; } - // Defensive: a non-record (e.g. a legacy scalar) would break the - // per-account map lookups - ignore it. + // Per-account map (accountId -> identityId); ignore any legacy + // global/non-record value rather than corrupting the map. if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) { return; } @@ -877,14 +874,10 @@ export const useSettingsStore = create()( get().importSettings(JSON.stringify(settings)); isLoadingFromServer = false; syncLog('Settings loaded from server successfully'); - // Re-apply the (possibly server-updated) per-account preferred - // sender identity to the already-loaded identities, so a fresh - // browser reflects the synced default without waiting for the next - // identity refresh. Dynamic import avoids a static import cycle - // (auth-store imports this store). (#507) - import('./auth-store') - .then(({ useAuthStore }) => useAuthStore.getState().applyPreferredIdentityOrdering()) - .catch(() => {}); + // The per-account preferred sender identity (#507) is re-applied by + // applyPreferredIdentity() in auth-store, invoked from the + // loadFromServer().finally() of every login / switch / restore path, + // so no extra hook is needed here. return true; } return false; @@ -897,7 +890,7 @@ export const useSettingsStore = create()( }), { name: 'settings-storage', - version: 5, + version: 6, migrate: (persisted, version) => { const state = persisted as Record; if (version < 2 && state.listDensity) { @@ -925,6 +918,11 @@ export const useSettingsStore = create()( if (version < 5 || !isPlainRecord(state.allMailFolderIds)) { state.allMailFolderIds = {}; } + // v6: introduced the per-account default-identity map (issue #507). + // Coerce any missing/legacy value to an empty record. + if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) { + state.preferredIdentityIds = {}; + } return state as unknown as SettingsState; }, onRehydrateStorage: () => { From 996fa7eea6594af59e35eeddd1394f3678509358 Mon Sep 17 00:00:00 2001 From: Joe Polastre Date: Tue, 14 Jul 2026 00:25:01 -0700 Subject: [PATCH 13/22] fix: Generate Message-ID client-side using the sender's domain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulwark currently sends Email/set create without a messageId property, leaving Message-ID generation to the JMAP server. Servers typically fall back to their OS hostname for this (Stalwart, via mail-builder's `gethostname()`), which produces IDs like: ``` <175234...abc@ip-10-0-12-97.ec2.internal> ``` This is bad for every deployment, in three escalating ways: 1. Information disclosure: the Message-ID travels in every outgoing message and permanently into archives, quoting, and In-Reply-To / References of replies. An internal hostname (container name, private DNS, k8s pod name) is infrastructure detail no recipient should see. 2. Deliverability: spam filters score Message-IDs whose domain part is not a plausible FQDN or is unrelated to the sender (SpamAssassin MSGID_FROM_MTA_HEADER and friends). Internal names like *.ec2.internal or bare container ids read as botnet-ish. 3. Correctness of intent: RFC 5322 §3.6.4 recommends the originator generate the Message-ID, using a domain it controls, so the id is meaningful and plausibly unique under that domain's authority. The sender's own domain is exactly that; the mail server's transient runtime hostname is exactly not. Generate the id in `sendEmail()` as `.@`, taken from the From address (falling back to the login username). The timestamp prefix keeps ids roughly sortable and adds entropy across UUID reuse concerns; crypto.randomUUID() is available in every runtime Bulwark supports (browsers and Node 19+). Per RFC 8621 §4.1.2.3 the JMAP messageId property carries bare msg-ids (no angle brackets), so none are added. Clients that never set messageId also can't thread their own sent mail reliably until the server echoes the message back; setting it at create time makes the id known and stable from the start. No behavior change for servers that honored client-provided ids all along; servers that previously synthesized an id now simply don't need to. --- lib/jmap/client.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 2b6d2f50..89ad0aff 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -432,6 +432,17 @@ function stripMessageIdBrackets(id: string): string { return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim(); } +// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per +// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS +// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into +// headers — an anti-spam signal and an information disclosure. Use the sender's +// domain instead, matching what receivers expect a Message-ID to look like. +function generateMessageId(fromEmail: string): string { + const at = fromEmail.lastIndexOf('@'); + const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost'; + return `${Date.now().toString(36)}.${crypto.randomUUID()}@${domain}`; +} + /** * Build a CalendarEvent/query filter restricting results to the given * calendars. Stalwart implements the singular `inCalendar` condition (one @@ -2467,6 +2478,7 @@ export class JMAPClient implements IJMAPClient { cc: cc?.length ? cc.map(parseRecipientString) : undefined, bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined, subject, + messageId: [generateMessageId(fromEmail || this.username)], inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined, references: normalizedReferences?.length ? normalizedReferences : undefined, keywords: { "$seen": true, "$draft": true }, From 511f9e5195f3eb3f94c747cead8b3cde89e44b60 Mon Sep 17 00:00:00 2001 From: Jesper Ordrup Date: Tue, 14 Jul 2026 15:38:51 +0200 Subject: [PATCH 14/22] fix: enable thread expansion in focused list --- components/email/thread-list-item.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 8e44f2a2..a4fb8ec8 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -660,7 +660,7 @@ export const ThreadListItem = React.forwardRef - {!isMobile && !isFocusedMailLayout && ( + {!isMobile && (