Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -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', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user