From dbc0eea1484337432830ddd4ca19593cffc8f4ae Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 21 May 2026 23:07:02 +0200 Subject: [PATCH] feat: group composer From dropdown by account in Pro shell --- components/email/email-composer.tsx | 96 +++++++++++++--- hooks/use-pro-multi-account-identities.ts | 133 ++++++++++++++++++++++ lib/jmap/types.ts | 6 + 3 files changed, 219 insertions(+), 16 deletions(-) create mode 100644 hooks/use-pro-multi-account-identities.ts diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index c4f9a89c..d13f3ddf 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -14,6 +14,7 @@ import { emailHooks, contactHooks } from "@/lib/plugin-hooks"; import type { OutgoingEmail, RecipientSuggestion } from "@/lib/plugin-types"; import { useAuthStore } from "@/stores/auth-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useProMultiAccountIdentities, stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useAccountStore } from "@/stores/account-store"; import { useSmimeStore } from "@/stores/smime-store"; import { useEmailStore } from "@/stores/email-store"; @@ -75,6 +76,11 @@ interface EmailComposerProps { fromName?: string; identityId?: string; envelopeMailFrom?: string; + /** Local account ID owning the selected identity. Set when the user + * picked an identity from a non-active account in the Pro multi- + * account dropdown; parents should send through that account's + * client instead of the currently-active one. */ + localAccountId?: string; attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>; inReplyTo?: string[]; references?: string[]; @@ -179,8 +185,18 @@ export function EmailComposer({ const attachmentReminderKeywords = useSettingsStore((state) => state.attachmentReminderKeywords); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); - const identities = useIdentityStore((s) => s.identities); - const primaryIdentity = identities[0] ?? null; + const activeIdentities = useIdentityStore((s) => s.identities); + // Pro shell: surface identities from every connected account, grouped + // for the From dropdown's s. Outside Pro this collapses to + // the active account's identities only. + const multiAccountIdentities = useProMultiAccountIdentities(); + const identities = multiAccountIdentities.enabled + ? multiAccountIdentities.allIdentities + : activeIdentities; + const identityGroups = multiAccountIdentities.enabled + ? multiAccountIdentities.groups + : []; + const primaryIdentity = activeIdentities[0] ?? null; // The signature identity used when embedding the signature into the initial // body for "above quote" mode. Mirrors the signatureIdentity derivation @@ -389,6 +405,19 @@ export function EmailComposer({ const currentIdentity = selectedIdentityId ? identities.find((identity) => identity.id === selectedIdentityId) || primaryIdentity : primaryIdentity; + + // When the selected identity belongs to a non-active account (Pro + // multi-account dropdown), `currentIdentity.id` carries a "::" + // namespace and JMAP calls must be routed through that account's + // client with the un-prefixed id. `composerClient` and + // `currentIdentityRawId` are what save/send code should use. + const currentIdentityParts = currentIdentity?.id + ? stripCrossAccountIdentityPrefix(currentIdentity.id) + : { localAccountId: null, rawId: undefined }; + const composerClient = currentIdentityParts.localAccountId + ? (useAuthStore.getState().getClientForAccount(currentIdentityParts.localAccountId) ?? client) + : client; + const currentIdentityRawId = currentIdentityParts.rawId ?? currentIdentity?.id; // Alias identities often lack a configured signature - fall back to the primary // identity's signature so replies (which auto-select a matching alias) still // populate the user's signature. @@ -906,7 +935,7 @@ export function EmailComposer({ // Auto-save draft functionality const saveDraftOnce = async (): Promise => { - if (!client) return null; + if (!client || !composerClient) return null; const toAddresses = to.split(",").map(e => e.trim()).filter(Boolean); const ccAddresses = cc.split(",").map(e => e.trim()).filter(Boolean); @@ -952,13 +981,16 @@ export function EmailComposer({ try { const previousDraftId = draftIdRef.current; - const savedDraftId = await client.createDraft( + // Use the JMAP client and raw identity id for the *owning* account + // — falls back to active client for single-account / same-account + // identities. See `composerClient` derivation above. + const savedDraftId = await composerClient.createDraft( toAddresses, subject || t('no_subject'), plainTextMode ? body : htmlToPlainText(body), ccAddresses, bccAddresses, - currentIdentity?.id, + currentIdentityRawId, fromEmail, previousDraftId || undefined, uploadedAttachments, @@ -1255,6 +1287,13 @@ export function EmailComposer({ // S/MIME send pipeline: build raw MIME → sign → encrypt → sendRawEmail if ((smimeSign_ || smimeEncrypt_) && client && currentIdentity?.id) { + // S/MIME keys are scoped to one JMAP account's identity — sending + // from a cross-account identity via S/MIME would mix accounts' + // certs/clients. Refuse upfront and tell the user to switch. + const crossAccount = stripCrossAccountIdentityPrefix(currentIdentity.id); + if (crossAccount.localAccountId) { + throw new Error('S/MIME sending from another account’s identity is not supported. Switch to that account first.'); + } // 1. Resolve S/MIME key if (smimeSign_ && !smimeKeyRecord) { throw new Error('No S/MIME key bound to this identity'); @@ -1400,6 +1439,15 @@ export function EmailComposer({ }; const outgoing = await emailHooks.onTransformOutgoingEmail.transform(transformInput); + // Strip the cross-account namespace from the identity id before + // handing it to the parent — the JMAP server only knows the raw + // id. The owning local account travels alongside so the parent + // can route the send through the right client. + const rawIdentityId = outgoing.identityId || currentIdentity?.id; + const { localAccountId: identityLocalAccountId, rawId } = rawIdentityId + ? stripCrossAccountIdentityPrefix(rawIdentityId) + : { localAccountId: null, rawId: undefined }; + await onSend?.({ to: outgoing.to, cc: outgoing.cc, @@ -1410,8 +1458,9 @@ export function EmailComposer({ draftId: finalDraftId || undefined, fromEmail, fromName, - identityId: outgoing.identityId || currentIdentity?.id, + identityId: rawId, envelopeMailFrom, + localAccountId: identityLocalAccountId ?? undefined, attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, @@ -1581,16 +1630,31 @@ export function EmailComposer({ onChange={(e) => setSelectedIdentityId(e.target.value)} className="flex-1 bg-transparent text-sm text-foreground outline-none cursor-pointer hover:text-muted-foreground transition-colors min-w-0 truncate" > - {identities.map((identity) => { - const displayEmail = subAddressTag - ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) - : identity.email; - return ( - - ); - })} + {identityGroups.length > 0 + ? identityGroups.map((group) => ( + + {group.identities.map((identity) => { + const displayEmail = subAddressTag + ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) + : identity.email; + return ( + + ); + })} + + )) + : identities.map((identity) => { + const displayEmail = subAddressTag + ? generateSubAddress(identity.email, subAddressTag, subAddressDelimiter) + : identity.email; + return ( + + ); + })} ) : ( diff --git a/hooks/use-pro-multi-account-identities.ts b/hooks/use-pro-multi-account-identities.ts new file mode 100644 index 00000000..ddff1c2c --- /dev/null +++ b/hooks/use-pro-multi-account-identities.ts @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useAccountStore } from "@/stores/account-store"; +import { useAuthStore } from "@/stores/auth-store"; +import { useIdentityStore } from "@/stores/identity-store"; +import { useSettingsStore } from "@/stores/settings-store"; +import { useIsEmbedded } from "@/hooks/use-is-embedded"; +import type { Identity } from "@/lib/jmap/types"; + +interface AccountIdentityGroup { + localAccountId: string; + accountLabel: string; + identities: Identity[]; +} + +const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::'; + +/** Cross-account identity IDs are namespaced to avoid collisions between + * JMAP servers that happen to issue the same opaque ID. The active + * account's IDs are left untouched so existing single-account code paths + * (reply-identity resolution, S/MIME bindings) keep working unchanged. + */ +export function isCrossAccountIdentityId(id: string): boolean { + return id.includes(CROSS_ACCOUNT_IDENTITY_DELIMITER); +} + +export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: string | null; rawId: string } { + const idx = id.indexOf(CROSS_ACCOUNT_IDENTITY_DELIMITER); + if (idx < 0) return { localAccountId: null, rawId: id }; + return { + localAccountId: id.slice(0, idx), + rawId: id.slice(idx + CROSS_ACCOUNT_IDENTITY_DELIMITER.length), + }; +} + +/** + * Pro shell only: load identities from every connected account and group + * them by local account so the composer's From dropdown can render an + * per account — mirrors [[useProMultiAccountCalendars]] and + * [[useProMultiAccountContacts]]. + * + * Outside Pro / embedded mode the hook returns `enabled: false` and the + * caller falls back to the active account's identities from + * [[useIdentityStore]]. + */ +export function useProMultiAccountIdentities(): { + enabled: boolean; + groups: AccountIdentityGroup[]; + /** Flat list across all accounts, useful for lookup-by-id. */ + allIdentities: Identity[]; +} { + const isEmbedded = useIsEmbedded(); + const proInterface = useSettingsStore((s) => s.proInterface); + const accounts = useAccountStore((s) => s.accounts); + const activeAccountId = useAuthStore((s) => s.activeAccountId); + const activeIdentities = useIdentityStore((s) => s.identities); + + const enabled = (proInterface || isEmbedded) && accounts.filter(a => a.isConnected).length > 1; + + const [remoteIdentities, setRemoteIdentities] = useState>({}); + + // Cache identities fetched per non-active account. Active account's + // identities come live from useIdentityStore so signature/alias edits + // there are reflected immediately without an extra round-trip. + useEffect(() => { + if (!enabled) { + setRemoteIdentities({}); + return; + } + let cancelled = false; + const getClientForAccount = useAuthStore.getState().getClientForAccount; + (async () => { + const next: Record = {}; + await Promise.all( + accounts + .filter((a) => a.isConnected && a.id !== activeAccountId) + .map(async (account) => { + const client = getClientForAccount(account.id); + if (!client) return; + try { + const list = await client.getIdentities(); + if (!cancelled) next[account.id] = list; + } catch { + // Skip accounts that fail to load identities — one bad + // account shouldn't blank the whole dropdown. + } + }), + ); + if (!cancelled) setRemoteIdentities(next); + })(); + return () => { cancelled = true; }; + }, [enabled, accounts, activeAccountId]); + + const groups = useMemo(() => { + if (!enabled) return []; + const out: AccountIdentityGroup[] = []; + if (activeAccountId) { + const active = accounts.find((a) => a.id === activeAccountId); + const label = active?.label || active?.email || active?.username || activeAccountId; + out.push({ + localAccountId: activeAccountId, + accountLabel: label, + identities: activeIdentities.map((id) => ({ + ...id, + localAccountId: activeAccountId, + accountName: label, + })), + }); + } + for (const account of accounts) { + if (!account.isConnected || account.id === activeAccountId) continue; + const list = remoteIdentities[account.id]; + if (!list || list.length === 0) continue; + const label = account.label || account.email || account.username; + out.push({ + localAccountId: account.id, + accountLabel: label, + identities: list.map((id) => ({ + ...id, + id: `${account.id}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`, + localAccountId: account.id, + accountName: label, + })), + }); + } + return out; + }, [enabled, accounts, activeAccountId, activeIdentities, remoteIdentities]); + + const allIdentities = useMemo(() => groups.flatMap((g) => g.identities), [groups]); + + return { enabled, groups, allIdentities }; +} diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts index 691b2f72..17141ef8 100644 --- a/lib/jmap/types.ts +++ b/lib/jmap/types.ts @@ -159,6 +159,12 @@ export interface Identity { textSignature?: string; htmlSignature?: string; mayDelete: boolean; + // See `Calendar.localAccountId` — set when the Pro shell aggregates + // identities from multiple connected accounts so we can route sends + // back through the owning JMAP client. `accountName` is the + // user-facing label for the dropdown's optgroup. + localAccountId?: string; + accountName?: string; } // RFC 9553 JSContact / RFC 9610 JMAP for Contacts