This commit is contained in:
Linus Rath
2026-06-22 00:10:13 +02:00
4 changed files with 49 additions and 29 deletions
+6 -6
View File
@@ -58,7 +58,7 @@ describe('snapshotAccount / restoreAccount', () => {
expect(useVacationStore.getState().isEnabled).toBe(true); expect(useVacationStore.getState().isEnabled).toBe(true);
}); });
it('CHARACTERISATION: only snapshotted fields are restored; others survive', () => { it('resets fields outside the snapshot subset to their defaults (no cross-account leak)', () => {
// isLoading is NOT part of the email snapshot subset. // isLoading is NOT part of the email snapshot subset.
useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false }); useEmailStore.setState({ selectedMailbox: 'a-in', isLoading: false });
snapshotAccount('A'); snapshotAccount('A');
@@ -66,11 +66,11 @@ describe('snapshotAccount / restoreAccount', () => {
restoreAccount('A'); restoreAccount('A');
expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // restored expect(useEmailStore.getState().selectedMailbox).toBe('a-in'); // captured → restored
expect(useEmailStore.getState().isLoading).toBe(true); // NOT restored (merge) expect(useEmailStore.getState().isLoading).toBe(false); // uncaptured → reset, not leaked
}); });
it('CHARACTERISATION: snapshot stores array references, not deep clones', () => { it('decouples the snapshot from later in-place mutation of the source array', () => {
const arr = [makeEmail({ id: '1' })]; const arr = [makeEmail({ id: '1' })];
useEmailStore.setState({ emails: arr }); useEmailStore.setState({ emails: arr });
snapshotAccount('A'); snapshotAccount('A');
@@ -78,8 +78,8 @@ describe('snapshotAccount / restoreAccount', () => {
useEmailStore.setState({ emails: [] }); useEmailStore.setState({ emails: [] });
restoreAccount('A'); restoreAccount('A');
// The post-snapshot mutation leaked into the snapshot. // The post-snapshot mutation did NOT leak into the snapshot.
expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1', '2']); expect(useEmailStore.getState().emails.map((e) => e.id)).toEqual(['1']);
}); });
it('returns false and leaves stores untouched for an unknown account', () => { it('returns false and leaves stores untouched for an unknown account', () => {
+4 -4
View File
@@ -41,11 +41,11 @@ describe('emailExportFilename', () => {
expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml'); expect(emailExportFilename(makeEmail({}), '{unknown_token}')).toBe('email.eml');
}); });
it('CHARACTERISATION: per-token sanitise caps each value at 80 chars', () => { it('lets a single long token reach the 200-char filename cap', () => {
// sanitizePart defaults to maxLen=80, applied per {token} during render — // Each {token} is now capped at FILENAME_MAX_LEN (200), so the overall
// so a single long {subject} is truncated to 80 well before the 200 cap. // filename limit governs instead of an earlier 80-char per-token cap.
const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}'); const out = emailExportFilename(makeEmail({ subject: 'a'.repeat(300) }), '{subject}');
expect(out).toBe('a'.repeat(80) + '.eml'); expect(out).toBe('a'.repeat(200) + '.eml');
}); });
}); });
+26 -11
View File
@@ -37,33 +37,37 @@ export function snapshotAccount(accountId: string): void {
const identityState = useIdentityStore.getState(); const identityState = useIdentityStore.getState();
const vacationState = useVacationStore.getState(); const vacationState = useVacationStore.getState();
// Copy the captured collections so the snapshot is decoupled from the live
// store: a later in-place mutation (e.g. an array push/splice, or stamping
// fields onto a shared email object) must not retroactively corrupt a
// snapshot taken earlier.
cache.set(accountId, { cache.set(accountId, {
email: { email: {
emails: emailState.emails, emails: [...emailState.emails],
mailboxes: emailState.mailboxes, mailboxes: [...emailState.mailboxes],
selectedEmail: emailState.selectedEmail, selectedEmail: emailState.selectedEmail,
selectedMailbox: emailState.selectedMailbox, selectedMailbox: emailState.selectedMailbox,
searchQuery: emailState.searchQuery, searchQuery: emailState.searchQuery,
quota: emailState.quota, quota: emailState.quota ? { ...emailState.quota } : emailState.quota,
}, },
contact: { contact: {
contacts: contactState.contacts, contacts: [...contactState.contacts],
addressBooks: contactState.addressBooks, addressBooks: [...contactState.addressBooks],
supportsSync: contactState.supportsSync, supportsSync: contactState.supportsSync,
}, },
calendar: { calendar: {
calendars: calendarState.calendars, calendars: [...calendarState.calendars],
events: calendarState.events, events: [...calendarState.events],
selectedCalendarIds: calendarState.selectedCalendarIds, selectedCalendarIds: [...calendarState.selectedCalendarIds],
viewMode: calendarState.viewMode, viewMode: calendarState.viewMode,
supportsCalendar: calendarState.supportsCalendar, supportsCalendar: calendarState.supportsCalendar,
}, },
filter: { filter: {
rules: filterState.rules, rules: [...filterState.rules],
isSupported: filterState.isSupported, isSupported: filterState.isSupported,
}, },
identity: { identity: {
identities: identityState.identities, identities: [...identityState.identities],
preferredPrimaryId: identityState.preferredPrimaryId, preferredPrimaryId: identityState.preferredPrimaryId,
}, },
vacation: { vacation: {
@@ -73,11 +77,22 @@ export function snapshotAccount(accountId: string): void {
}); });
} }
/** Restore cached store states for the given account. Returns false if no cache exists. */ /**
* Restore cached store states for the given account. Returns false if no cache
* exists.
*
* The snapshot only captures a subset of each store's fields (the loaded data),
* so we reset every store to its baseline first. Without this, fields outside
* the captured subset (e.g. email selection, loading flags, tag counts) would
* carry over from whatever account was active, leaking state across accounts.
* `setState` merges, so the captured fields are then layered back on top.
*/
export function restoreAccount(accountId: string): boolean { export function restoreAccount(accountId: string): boolean {
const snapshot = cache.get(accountId); const snapshot = cache.get(accountId);
if (!snapshot) return false; if (!snapshot) return false;
clearAllStores();
useEmailStore.setState(snapshot.email); useEmailStore.setState(snapshot.email);
useContactStore.setState(snapshot.contact); useContactStore.setState(snapshot.contact);
useCalendarStore.setState(snapshot.calendar); useCalendarStore.setState(snapshot.calendar);
+13 -8
View File
@@ -64,6 +64,11 @@ export const BUNDLE_TOKENS: { token: string; description: string }[] = [
{ token: "day", description: "2-digit day" }, { token: "day", description: "2-digit day" },
]; ];
// Overall cap for a generated filename stem. Also used as the per-token cap so
// a single long token (e.g. {subject}) isn't truncated earlier than the final
// filename would be.
const FILENAME_MAX_LEN = 200;
function sanitizePart(input: string, maxLen = 80): string { function sanitizePart(input: string, maxLen = 80): string {
const cleaned = input const cleaned = input
.replace(SAFE_CHARS, "_") .replace(SAFE_CHARS, "_")
@@ -173,7 +178,7 @@ function renderRaw(template: string, vars: Record<string, string>): string {
return template.replace(/\{(\w+)\}/g, (_, key: string) => { return template.replace(/\{(\w+)\}/g, (_, key: string) => {
const value = vars[key]; const value = vars[key];
if (value === undefined) return ""; if (value === undefined) return "";
return sanitizePart(value); return sanitizePart(value, FILENAME_MAX_LEN);
}); });
} }
@@ -184,9 +189,9 @@ export function emailExportFilename(
const opts = typeof options === "string" ? { template: options } : options; const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE; const template = opts.template ?? DEFAULT_EMAIL_TEMPLATE;
const rendered = renderRaw(template, emailVars(email)); const rendered = renderRaw(template, emailVars(email));
const cleaned = sanitizePart(rendered, 200); const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
const transformed = applyTransforms(cleaned, opts); const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "email"; const stem = transformed.slice(0, FILENAME_MAX_LEN) || "email";
return `${stem}.eml`; return `${stem}.eml`;
} }
@@ -199,7 +204,7 @@ export function attachmentDownloadFilename(
const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE; const template = opts.template ?? DEFAULT_ATTACHMENT_TEMPLATE;
if (!email) { if (!email) {
const filename = (attachment.name || "attachment").trim(); const filename = (attachment.name || "attachment").trim();
const cleaned = sanitizePart(filename, 200) || "attachment"; const cleaned = sanitizePart(filename, FILENAME_MAX_LEN) || "attachment";
return applyTransforms(cleaned, opts) || cleaned; return applyTransforms(cleaned, opts) || cleaned;
} }
const vars = attachmentVars(email, attachment); const vars = attachmentVars(email, attachment);
@@ -208,10 +213,10 @@ export function attachmentDownloadFilename(
if (value === undefined) return ""; if (value === undefined) return "";
// Preserve dots in {filename} so the original extension survives the // Preserve dots in {filename} so the original extension survives the
// sanitiser (it strips trailing dots otherwise). // sanitiser (it strips trailing dots otherwise).
return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value); return key === "filename" ? value.replace(SAFE_CHARS, "_") : sanitizePart(value, FILENAME_MAX_LEN);
}); });
const templateMentionsExt = /\{(ext|filename)\}/.test(template); const templateMentionsExt = /\{(ext|filename)\}/.test(template);
const cleaned = sanitizePart(rendered, 200) || "attachment"; const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN) || "attachment";
if (templateMentionsExt) { if (templateMentionsExt) {
return applyTransforms(cleaned, opts) || cleaned; return applyTransforms(cleaned, opts) || cleaned;
} }
@@ -235,9 +240,9 @@ export function bundleExportFilename(
const opts = typeof options === "string" ? { template: options } : options; const opts = typeof options === "string" ? { template: options } : options;
const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE; const template = opts.template ?? DEFAULT_BUNDLE_TEMPLATE;
const rendered = renderRaw(template, bundleVars(count, iso)); const rendered = renderRaw(template, bundleVars(count, iso));
const cleaned = sanitizePart(rendered, 200); const cleaned = sanitizePart(rendered, FILENAME_MAX_LEN);
const transformed = applyTransforms(cleaned, opts); const transformed = applyTransforms(cleaned, opts);
const stem = transformed.slice(0, 200) || "emails"; const stem = transformed.slice(0, FILENAME_MAX_LEN) || "emails";
return `${stem}.zip`; return `${stem}.zip`;
} }