diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index de779ed4..c7befde0 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -908,7 +908,7 @@ export function EmailViewer({ // Tablet list visibility const { isTablet, isMobile } = useDeviceDetection(); const { tabletListVisible } = useUIStore(); - const { identities, client, isDemoMode } = useAuthStore(); + const { identities, client, isDemoMode, activeAccountId } = useAuthStore(); const resolvedTheme = useThemeStore((state) => state.resolvedTheme); const { startTour } = useTour(); const [showFullHeaders, setShowFullHeaders] = useState(false); @@ -962,9 +962,9 @@ export function EmailViewer({ // Ensure S/MIME key records are loaded from IndexedDB useLayoutEffect(() => { - smimeStore.load(); + smimeStore.load(activeAccountId ?? undefined); // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [activeAccountId]); // Build mailbox tree for move-to dropdown const moveTargetIds = useMemo(() => new Set( @@ -1544,6 +1544,18 @@ export function EmailViewer({ // Encrypted message const { keyRecords, unlockedDecryptionKeys } = smimeStore; smimeDebug('[S/MIME] decrypt attempt:', { keyRecordCount: keyRecords.length, unlockedKeyCount: unlockedDecryptionKeys.size, keyRecordIds: keyRecords.map(k => k.id) }); + + // Short-circuit: no keys imported at all + if (keyRecords.length === 0) { + smimeDebug('[S/MIME] no key records available, skipping decrypt'); + setSmimeStatus({ + isSigned: false, + isEncrypted: true, + decryptionError: 'no-key', + }); + return; + } + try { let result: Awaited> | null = null; let lastError: unknown = null; @@ -1635,8 +1647,15 @@ export function EmailViewer({ if (!existing) { try { await smimeStore.importPublicCert(verifyResult.status.signerCert.certificate, 'signed-email'); - } catch { /* ignore import errors */ } + smimeDebug('[S/MIME] auto-imported signer cert:', { email: verifyResult.status.signerCert.email, fingerprint: verifyResult.status.signerCert.fingerprint }); + } catch (importErr) { + smimeError('[S/MIME] auto-import signer cert failed:', importErr); + } + } else { + smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } + } else if (verifyResult.status.signatureValid && verifyResult.status.signerCert) { + smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: verifyResult.status.signerCert.email }); } } catch (error) { smimeError('[S/MIME] nested signature verify failed:', { @@ -1674,10 +1693,12 @@ export function EmailViewer({ decryptionError: 'locked', }); } else { + const errMsg = err instanceof Error ? err.message : 'Decryption failed'; + const isNoKeyError = errMsg.includes('No imported S/MIME key matches'); setSmimeStatus({ isSigned: false, isEncrypted: true, - decryptionError: err instanceof Error ? err.message : 'Decryption failed', + decryptionError: isNoKeyError ? 'no-key' : errMsg, }); } } @@ -1739,8 +1760,15 @@ export function EmailViewer({ if (!existing) { try { await smimeStore.importPublicCert(result.status.signerCert.certificate, 'signed-email'); - } catch { /* ignore import errors */ } + smimeDebug('[S/MIME] auto-imported signer cert:', { email: result.status.signerCert.email, fingerprint: result.status.signerCert.fingerprint }); + } catch (importErr) { + smimeError('[S/MIME] auto-import signer cert failed:', importErr); + } + } else { + smimeDebug('[S/MIME] signer cert already imported:', { email: existing.email, fingerprint: existing.fingerprint }); } + } else if (result.status.signatureValid && result.status.signerCert) { + smimeDebug('[S/MIME] auto-import disabled, skipping signer cert:', { email: result.status.signerCert.email }); } } catch (err) { if (cancelled) return; diff --git a/components/email/smime-status-banner.tsx b/components/email/smime-status-banner.tsx index 010c45f4..4ce318bc 100644 --- a/components/email/smime-status-banner.tsx +++ b/components/email/smime-status-banner.tsx @@ -30,6 +30,12 @@ export function SmimeStatusBanner({ status, onUnlockKey, className }: SmimeStatu text: t('unlock_key_desc'), variant: 'warning', }); + } else if (status.decryptionError === 'no-key') { + items.push({ + icon: , + text: t('status_encrypted_no_key'), + variant: 'warning', + }); } else { items.push({ icon: , diff --git a/components/settings/smime-settings.tsx b/components/settings/smime-settings.tsx index 760d3c56..56bc9969 100644 --- a/components/settings/smime-settings.tsx +++ b/components/settings/smime-settings.tsx @@ -19,6 +19,7 @@ import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-di import { SmimeCertificateModal } from "@/components/settings/smime-certificate-modal"; import { useSmimeStore } from "@/stores/smime-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useAuthStore } from "@/stores/auth-store"; import { exportPkcs12, downloadPkcs12 } from "@/lib/smime/pkcs12-export"; import type { SmimeKeyRecord, SmimePublicCert } from "@/lib/smime/types"; @@ -50,6 +51,7 @@ export function SmimeSettings() { } = useSmimeStore(); const { identities } = useIdentityStore(); + const activeAccountId = useAuthStore((s) => s.activeAccountId); // Local UI state const [importDialogOpen, setImportDialogOpen] = useState(false); @@ -75,8 +77,8 @@ export function SmimeSettings() { const [exportError, setExportError] = useState(null); useEffect(() => { - load(); - }, [load]); + load(activeAccountId ?? undefined); + }, [load, activeAccountId]); // ── PKCS#12 import flow ──────────────────────────────────────── diff --git a/lib/account-state-manager.ts b/lib/account-state-manager.ts index b7661023..86d89b26 100644 --- a/lib/account-state-manager.ts +++ b/lib/account-state-manager.ts @@ -11,6 +11,7 @@ import { useFilterStore } from '@/stores/filter-store'; import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils'; import { useIdentityStore } from '@/stores/identity-store'; import { useVacationStore } from '@/stores/vacation-store'; +import { useSmimeStore } from '@/stores/smime-store'; // Minimal snapshot shapes — we only capture what we need // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -117,6 +118,7 @@ export function clearAllStores(): void { useVacationStore.getState().clearState(); useCalendarStore.getState().clearState(); useFilterStore.getState().clearState(); + useSmimeStore.getState().clearState(); } /** Evict cached state for one account */ diff --git a/lib/smime/__tests__/key-storage.test.ts b/lib/smime/__tests__/key-storage.test.ts index 66cdfc0b..bd194330 100644 --- a/lib/smime/__tests__/key-storage.test.ts +++ b/lib/smime/__tests__/key-storage.test.ts @@ -148,4 +148,50 @@ describe('key-storage', () => { expect(certs.find(c => c.id === id)).toBeUndefined(); }); }); + + describe('accountId filtering', () => { + it('listKeyRecords filters by accountId', async () => { + const id1 = uid(); + const id2 = uid(); + await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); + await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); + + const acct1Records = await listKeyRecords('acct-1'); + expect(acct1Records.find(r => r.id === id1)).toBeDefined(); + expect(acct1Records.find(r => r.id === id2)).toBeUndefined(); + }); + + it('listKeyRecords includes records without accountId when filtering', async () => { + const id1 = uid(); + const id2 = uid(); + await saveKeyRecord(makeKeyRecord({ id: id1, email: `${id1}@a.com` })); + await saveKeyRecord(makeKeyRecord({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); + + const acct1Records = await listKeyRecords('acct-1'); + expect(acct1Records.find(r => r.id === id1)).toBeDefined(); + expect(acct1Records.find(r => r.id === id2)).toBeDefined(); + }); + + it('listPublicCerts filters by accountId', async () => { + const id1 = uid(); + const id2 = uid(); + await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com`, accountId: 'acct-1' })); + await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-2' })); + + const acct1Certs = await listPublicCerts('acct-1'); + expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); + expect(acct1Certs.find(c => c.id === id2)).toBeUndefined(); + }); + + it('listPublicCerts includes certs without accountId when filtering', async () => { + const id1 = uid(); + const id2 = uid(); + await savePublicCert(makePublicCert({ id: id1, email: `${id1}@a.com` })); + await savePublicCert(makePublicCert({ id: id2, email: `${id2}@b.com`, accountId: 'acct-1' })); + + const acct1Certs = await listPublicCerts('acct-1'); + expect(acct1Certs.find(c => c.id === id1)).toBeDefined(); + expect(acct1Certs.find(c => c.id === id2)).toBeDefined(); + }); + }); }); diff --git a/lib/smime/__tests__/smime-store.test.ts b/lib/smime/__tests__/smime-store.test.ts index 676ebb54..6765a14e 100644 --- a/lib/smime/__tests__/smime-store.test.ts +++ b/lib/smime/__tests__/smime-store.test.ts @@ -57,7 +57,9 @@ beforeEach(() => { defaultSignIdentity: {}, defaultEncrypt: false, rememberUnlockedKeys: false, - autoImportSignerCerts: false, + autoImportSignerCerts: true, + accountPreferences: {}, + currentAccountId: null, isLoading: false, error: null, }); diff --git a/lib/smime/key-storage.ts b/lib/smime/key-storage.ts index 795e0d9f..30a1a33e 100644 --- a/lib/smime/key-storage.ts +++ b/lib/smime/key-storage.ts @@ -1,22 +1,35 @@ import type { SmimeKeyRecord, SmimePublicCert } from './types'; const DB_NAME = 'smime-store'; -const DB_VERSION = 1; +const DB_VERSION = 2; const KEY_RECORDS_STORE = 'key-records'; const PUBLIC_CERTS_STORE = 'public-certs'; function openDB(): Promise { return new Promise((resolve, reject) => { const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { + request.onupgradeneeded = (event) => { const db = request.result; - if (!db.objectStoreNames.contains(KEY_RECORDS_STORE)) { + const oldVersion = event.oldVersion; + if (oldVersion < 1) { const keyStore = db.createObjectStore(KEY_RECORDS_STORE, { keyPath: 'id' }); keyStore.createIndex('email', 'email', { unique: false }); - } - if (!db.objectStoreNames.contains(PUBLIC_CERTS_STORE)) { + keyStore.createIndex('accountId', 'accountId', { unique: false }); const certStore = db.createObjectStore(PUBLIC_CERTS_STORE, { keyPath: 'id' }); certStore.createIndex('email', 'email', { unique: false }); + certStore.createIndex('accountId', 'accountId', { unique: false }); + } + if (oldVersion >= 1 && oldVersion < 2) { + // Add accountId index to existing stores + const tx = request.transaction!; + const keyStore = tx.objectStore(KEY_RECORDS_STORE); + if (!keyStore.indexNames.contains('accountId')) { + keyStore.createIndex('accountId', 'accountId', { unique: false }); + } + const certStore = tx.objectStore(PUBLIC_CERTS_STORE); + if (!certStore.indexNames.contains('accountId')) { + certStore.createIndex('accountId', 'accountId', { unique: false }); + } } }; request.onsuccess = () => resolve(request.result); @@ -62,9 +75,11 @@ export async function getKeyRecordForEmail(email: string): Promise { +export async function listKeyRecords(accountId?: string): Promise { const db = await openDB(); - return txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll()); + const all = await txPromise(db, KEY_RECORDS_STORE, 'readonly', (s) => s.getAll()); + if (!accountId) return all; + return all.filter((r) => r.accountId === accountId || !r.accountId); } export async function deleteKeyRecord(id: string): Promise { @@ -90,9 +105,11 @@ export async function getPublicCertForEmail(email: string): Promise { +export async function listPublicCerts(accountId?: string): Promise { const db = await openDB(); - return txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll()); + const all = await txPromise(db, PUBLIC_CERTS_STORE, 'readonly', (s) => s.getAll()); + if (!accountId) return all; + return all.filter((c) => c.accountId === accountId || !c.accountId); } export async function deletePublicCert(id: string): Promise { diff --git a/lib/smime/types.ts b/lib/smime/types.ts index 2f674f70..0282a196 100644 --- a/lib/smime/types.ts +++ b/lib/smime/types.ts @@ -1,6 +1,7 @@ /** Stored record for an imported S/MIME private key + certificate. */ export interface SmimeKeyRecord { id: string; + accountId?: string; email: string; certificate: ArrayBuffer; // DER-encoded X.509 leaf cert certificateChain: ArrayBuffer[]; // DER-encoded intermediates @@ -34,6 +35,7 @@ export interface SmimeUnlockedKey { /** A recipient or contact public certificate. */ export interface SmimePublicCert { id: string; + accountId?: string; email: string; certificate: ArrayBuffer; // DER-encoded X.509 issuer: string; diff --git a/locales/de/common.json b/locales/de/common.json index e49ac17f..ccbb6e0e 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME-Signatur deaktiviert", "smime_encrypt_on": "S/MIME-Verschlüsselung aktiviert", "smime_encrypt_off": "S/MIME-Verschlüsselung deaktiviert", - "smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich: {reason}", + "smime_encrypt_unavailable": "S/MIME-Verschlüsselung nicht möglich – fehlende Empfängerzertifikate", "smime_unlock_title": "S/MIME-Schlüssel entsperren", "smime_unlock_message": "Geben Sie die Passphrase ein, um Ihren S/MIME-Schlüssel zu entsperren.", "smime_unlock_button": "Entsperren", diff --git a/locales/es/common.json b/locales/es/common.json index 37f1057d..0955e2c0 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Firma S/MIME desactivada", "smime_encrypt_on": "Cifrado S/MIME activado", "smime_encrypt_off": "Cifrado S/MIME desactivado", - "smime_encrypt_unavailable": "El cifrado S/MIME no está disponible: {reason}", + "smime_encrypt_unavailable": "Cifrado S/MIME no disponible – faltan certificados de destinatario", "smime_unlock_title": "Desbloquear clave S/MIME", "smime_unlock_message": "Introduce la contraseña para desbloquear tu clave S/MIME.", "smime_unlock_button": "Desbloquear", diff --git a/locales/fr/common.json b/locales/fr/common.json index 3848f571..dd35ee44 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Signature S/MIME désactivée", "smime_encrypt_on": "Chiffrement S/MIME activé", "smime_encrypt_off": "Chiffrement S/MIME désactivé", - "smime_encrypt_unavailable": "Le chiffrement S/MIME n'est pas disponible : {reason}", + "smime_encrypt_unavailable": "Chiffrement S/MIME indisponible – certificats des destinataires manquants", "smime_unlock_title": "Déverrouiller la clé S/MIME", "smime_unlock_message": "Entrez la phrase secrète pour déverrouiller votre clé S/MIME.", "smime_unlock_button": "Déverrouiller", diff --git a/locales/it/common.json b/locales/it/common.json index 6228ee6a..5fbdd3d4 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Firma S/MIME disattivata", "smime_encrypt_on": "Cifratura S/MIME attivata", "smime_encrypt_off": "Cifratura S/MIME disattivata", - "smime_encrypt_unavailable": "La cifratura S/MIME non è disponibile: {reason}", + "smime_encrypt_unavailable": "Cifratura S/MIME non disponibile – certificati destinatario mancanti", "smime_unlock_title": "Sblocca chiave S/MIME", "smime_unlock_message": "Inserisci la passphrase per sbloccare la tua chiave S/MIME.", "smime_unlock_button": "Sblocca", diff --git a/locales/ja/common.json b/locales/ja/common.json index 59ded38f..69eb5283 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME 署名を無効化しました", "smime_encrypt_on": "S/MIME 暗号化を有効化しました", "smime_encrypt_off": "S/MIME 暗号化を無効化しました", - "smime_encrypt_unavailable": "S/MIME 暗号化は利用できません: {reason}", + "smime_encrypt_unavailable": "S/MIME 暗号化は利用できません – 受信者の証明書がありません", "smime_unlock_title": "S/MIME 鍵のロックを解除", "smime_unlock_message": "S/MIME 鍵を解除するためのパスフレーズを入力してください。", "smime_unlock_button": "ロック解除", diff --git a/locales/nl/common.json b/locales/nl/common.json index a158c11e..05d6585a 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "S/MIME-ondertekening uitgeschakeld", "smime_encrypt_on": "S/MIME-versleuteling ingeschakeld", "smime_encrypt_off": "S/MIME-versleuteling uitgeschakeld", - "smime_encrypt_unavailable": "S/MIME-versleuteling is niet beschikbaar: {reason}", + "smime_encrypt_unavailable": "S/MIME-versleuteling niet beschikbaar – ontbrekende ontvangercertificaten", "smime_unlock_title": "S/MIME-sleutel ontgrendelen", "smime_unlock_message": "Voer de wachtwoordzin in om uw S/MIME-sleutel te ontgrendelen.", "smime_unlock_button": "Ontgrendelen", diff --git a/locales/pt/common.json b/locales/pt/common.json index a96a67f4..dc728c90 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -477,7 +477,7 @@ "smime_sign_off": "Assinatura S/MIME desativada", "smime_encrypt_on": "Criptografia S/MIME ativada", "smime_encrypt_off": "Criptografia S/MIME desativada", - "smime_encrypt_unavailable": "A criptografia S/MIME não está disponível: {reason}", + "smime_encrypt_unavailable": "Criptografia S/MIME indisponível – certificados de destinatário ausentes", "smime_unlock_title": "Desbloquear chave S/MIME", "smime_unlock_message": "Insira a frase secreta para desbloquear sua chave S/MIME.", "smime_unlock_button": "Desbloquear", diff --git a/stores/smime-store.ts b/stores/smime-store.ts index 5488f8a0..3301dcdc 100644 --- a/stores/smime-store.ts +++ b/stores/smime-store.ts @@ -123,14 +123,23 @@ async function restoreRememberedKeys(keyRecords: SmimeKeyRecord[]): Promise<{ } interface SmimePersistedState { - identityKeyBindings: Record; // identityId → keyRecordId - defaultSignIdentity: Record; // identityId → sign by default - defaultEncrypt: boolean; + /** Account-scoped preferences: accountId → { identityKeyBindings, defaultSignIdentity, defaultEncrypt } */ + accountPreferences: Record; + defaultSignIdentity: Record; + defaultEncrypt: boolean; + }>; rememberUnlockedKeys: boolean; autoImportSignerCerts: boolean; } interface SmimeStore extends SmimePersistedState { + // Current account scope + currentAccountId: string | null; + // Account-scoped convenience accessors (derived from accountPreferences + currentAccountId) + identityKeyBindings: Record; + defaultSignIdentity: Record; + defaultEncrypt: boolean; // Loaded from IndexedDB keyRecords: SmimeKeyRecord[]; publicCerts: SmimePublicCert[]; @@ -141,7 +150,8 @@ interface SmimeStore extends SmimePersistedState { error: string | null; // Actions - load: () => Promise; + load: (accountId?: string) => Promise; + clearState: () => void; importPKCS12: (file: ArrayBuffer, p12Passphrase: string, storagePassphrase: string) => Promise; importPublicCert: (data: ArrayBuffer | string, source: SmimePublicCert['source'], contactId?: string) => Promise; bindIdentityToKey: (identityId: string, keyRecordId: string | null) => void; @@ -166,13 +176,15 @@ export const useSmimeStore = create()( persist( (set, get) => ({ // Persisted preferences + accountPreferences: {}, + rememberUnlockedKeys: false, + autoImportSignerCerts: true, + + // Runtime state + currentAccountId: null, identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false, - rememberUnlockedKeys: false, - autoImportSignerCerts: false, - - // Runtime state keyRecords: [], publicCerts: [], unlockedKeys: new Map(), @@ -180,12 +192,30 @@ export const useSmimeStore = create()( isLoading: false, error: null, - load: async () => { - set({ isLoading: true, error: null }); + load: async (accountId) => { + const acctId = accountId ?? get().currentAccountId; + set({ isLoading: true, error: null, currentAccountId: acctId }); + + // Restore account-scoped preferences + const prefs = acctId ? get().accountPreferences[acctId] : undefined; + if (prefs) { + set({ + identityKeyBindings: prefs.identityKeyBindings, + defaultSignIdentity: prefs.defaultSignIdentity, + defaultEncrypt: prefs.defaultEncrypt, + }); + } else { + set({ + identityKeyBindings: {}, + defaultSignIdentity: {}, + defaultEncrypt: false, + }); + } + try { const [keyRecords, publicCerts] = await Promise.all([ - listKeyRecords(), - listPublicCerts(), + listKeyRecords(acctId ?? undefined), + listPublicCerts(acctId ?? undefined), ]); if (get().rememberUnlockedKeys) { @@ -219,6 +249,8 @@ export const useSmimeStore = create()( set({ isLoading: true, error: null }); try { const { keyRecord } = await importPkcs12(file, p12Passphrase, storagePassphrase); + const acctId = get().currentAccountId; + if (acctId) keyRecord.accountId = acctId; await saveKeyRecord(keyRecord); set((state) => ({ keyRecords: [...state.keyRecords, keyRecord], @@ -245,6 +277,7 @@ export const useSmimeStore = create()( const publicCert: SmimePublicCert = { id: crypto.randomUUID(), + accountId: get().currentAccountId ?? undefined, email: email.toLowerCase(), certificate: der, issuer: info.issuer, @@ -279,7 +312,15 @@ export const useSmimeStore = create()( } else { bindings[identityId] = keyRecordId; } - return { identityKeyBindings: bindings }; + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + identityKeyBindings: bindings, + }; + } + return { identityKeyBindings: bindings, accountPreferences }; }); }, @@ -296,11 +337,17 @@ export const useSmimeStore = create()( for (const [identityId, keyId] of Object.entries(bindings)) { if (keyId === id) delete bindings[identityId]; } + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId && accountPreferences[acctId]) { + accountPreferences[acctId] = { ...accountPreferences[acctId], identityKeyBindings: bindings }; + } return { keyRecords: state.keyRecords.filter((k) => k.id !== id), unlockedKeys, unlockedDecryptionKeys, identityKeyBindings: bindings, + accountPreferences, }; }); }, @@ -378,13 +425,32 @@ export const useSmimeStore = create()( }, setSignDefault: (identityId, value) => { - set((state) => ({ - defaultSignIdentity: { ...state.defaultSignIdentity, [identityId]: value }, - })); + set((state) => { + const defaultSignIdentity = { ...state.defaultSignIdentity, [identityId]: value }; + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + defaultSignIdentity, + }; + } + return { defaultSignIdentity, accountPreferences }; + }); }, setEncryptDefault: (value) => { - set({ defaultEncrypt: value }); + set((state) => { + const accountPreferences = { ...state.accountPreferences }; + const acctId = state.currentAccountId; + if (acctId) { + accountPreferences[acctId] = { + ...(accountPreferences[acctId] ?? { identityKeyBindings: {}, defaultSignIdentity: {}, defaultEncrypt: false }), + defaultEncrypt: value, + }; + } + return { defaultEncrypt: value, accountPreferences }; + }); }, setRememberUnlockedKeys: (value) => { @@ -403,17 +469,41 @@ export const useSmimeStore = create()( getUnlockedKey: (id) => get().unlockedKeys.get(id), + clearState: () => { + clearRememberedUnlocks(); + set({ + keyRecords: [], + publicCerts: [], + unlockedKeys: new Map(), + unlockedDecryptionKeys: new Map(), + identityKeyBindings: {}, + defaultSignIdentity: {}, + defaultEncrypt: false, + currentAccountId: null, + isLoading: false, + error: null, + }); + }, + setError: (error) => set({ error }), }), { name: 'smime-preferences', partialize: (state): SmimePersistedState => ({ - identityKeyBindings: state.identityKeyBindings, - defaultSignIdentity: state.defaultSignIdentity, - defaultEncrypt: state.defaultEncrypt, + accountPreferences: state.accountPreferences, rememberUnlockedKeys: state.rememberUnlockedKeys, autoImportSignerCerts: state.autoImportSignerCerts, }), + merge: (persisted, current) => { + const p = persisted as Partial; defaultSignIdentity?: Record; defaultEncrypt?: boolean }>; + return { + ...current, + // Migrate legacy flat preferences into accountPreferences + accountPreferences: p?.accountPreferences ?? {}, + rememberUnlockedKeys: p?.rememberUnlockedKeys ?? false, + autoImportSignerCerts: p?.autoImportSignerCerts ?? true, + }; + }, }, ), ); diff --git a/test-certs/leon.p12 b/test-certs/leon.p12 new file mode 100644 index 00000000..47580421 Binary files /dev/null and b/test-certs/leon.p12 differ diff --git a/test-certs/root-rbm.p12 b/test-certs/root-rbm.p12 new file mode 100644 index 00000000..9bbd17ba Binary files /dev/null and b/test-certs/root-rbm.p12 differ