Merge branch 'bulwarkmail:main' into feature/scheduled-send
This commit is contained in:
@@ -7,6 +7,7 @@ import { normalizeAllDayDuration } from '@/lib/calendar-utils';
|
||||
import { parseDuration } from '@/components/calendar/event-card';
|
||||
import { sanitizeOutgoingCalendarEventData } from '@/lib/calendar-event-normalization';
|
||||
import { expandRecurringEvents } from '@/lib/recurrence-expansion';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { generateUUID } from '@/lib/utils';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { BIRTHDAY_CALENDAR_ID } from '@/lib/birthday-calendar';
|
||||
@@ -302,8 +303,12 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
after: start,
|
||||
before: end,
|
||||
});
|
||||
// Filter out malformed events missing required 'start' field
|
||||
const validEvents = rawEvents.filter(e => typeof e.start === 'string' && e.start);
|
||||
// Filter out malformed events missing required 'start' field, or
|
||||
// whose start string fails to parse (would otherwise crash format()
|
||||
// calls in the rendering path - #316).
|
||||
const validEvents = rawEvents.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const droppedEvents = rawEvents.length - validEvents.length;
|
||||
// Expand recurring events client-side (Stalwart doesn't support
|
||||
// mutations on synthetic IDs from server-side expandRecurrences)
|
||||
@@ -366,7 +371,9 @@ export const useCalendarStore = create<CalendarStore>()(
|
||||
accounts.map(async ({ client, localAccountId }) => {
|
||||
try {
|
||||
const raw = await client.queryAllCalendarEvents({ after: start, before: end });
|
||||
const valid = raw.filter(e => typeof e.start === 'string' && e.start);
|
||||
const valid = raw.filter(e =>
|
||||
typeof e.start === 'string' && e.start && !isNaN(parseISO(e.start).getTime())
|
||||
);
|
||||
const expanded = expandRecurringEvents(valid, start, end);
|
||||
return prefixEventsWithLocalAccount(
|
||||
expanded,
|
||||
|
||||
+20
-9
@@ -872,14 +872,18 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
forceDelete = true;
|
||||
}
|
||||
|
||||
// If deleteAction is 'trash' and not forced permanent delete, try to move to trash mailbox
|
||||
if (deleteAction === 'trash' && !forceDelete) {
|
||||
// If deleteAction is 'trash' or 'trash-and-read' and not forced permanent delete, try to move to trash mailbox
|
||||
if ((deleteAction === 'trash' || deleteAction === 'trash-and-read') && !forceDelete) {
|
||||
const trashMailbox = findTrashMailbox(mailboxes, { accountId });
|
||||
const alsoMarkRead = deleteAction === 'trash-and-read' && isUnread;
|
||||
|
||||
if (trashMailbox) {
|
||||
// Use originalId for shared mailboxes if available
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId);
|
||||
await effectiveClient.moveToTrash(emailId, trashId, accountId, alsoMarkRead);
|
||||
|
||||
// After marking read in the same request, the email arrives in trash as read.
|
||||
const arrivesUnread = isUnread && !alsoMarkRead;
|
||||
|
||||
// Remove from local state (email moved to trash, not in current view)
|
||||
set((state) => {
|
||||
@@ -902,9 +906,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return {
|
||||
...mailbox,
|
||||
totalEmails: mailbox.totalEmails + 1,
|
||||
unreadEmails: isUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
unreadEmails: arrivesUnread ? mailbox.unreadEmails + 1 : mailbox.unreadEmails,
|
||||
totalThreads: mailbox.totalThreads + 1,
|
||||
unreadThreads: isUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
unreadThreads: arrivesUnread ? mailbox.unreadThreads + 1 : mailbox.unreadThreads
|
||||
};
|
||||
}
|
||||
return mailbox;
|
||||
@@ -1613,6 +1617,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const permanentlyDeleteJunk = useSettingsStore.getState().permanentlyDeleteJunk;
|
||||
const isInJunk = currentMailbox?.role === 'junk';
|
||||
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
// Group emails by accountId (handles unified view and search results spanning accounts).
|
||||
const emailsByAccount = new Map<string, string[]>();
|
||||
@@ -1653,7 +1658,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
return;
|
||||
}
|
||||
const trashId = trashMailbox.originalId || trashMailbox.id;
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId);
|
||||
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
|
||||
ids.forEach(id => movedEmailIds.add(id));
|
||||
});
|
||||
await Promise.allSettled(promises);
|
||||
@@ -1844,7 +1849,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
});
|
||||
|
||||
try {
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId);
|
||||
const isUnread = !email.keywords?.$seen;
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read' && isUnread;
|
||||
await resolveActionClient(client).markAsSpam(emailId, currentMailbox.accountId, alsoMarkRead);
|
||||
|
||||
set(state => ({
|
||||
emails: state.emails.filter(e => e.id !== emailId),
|
||||
@@ -1899,16 +1906,20 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
},
|
||||
|
||||
batchMarkAsSpam: async (client, emailIds) => {
|
||||
const { selectedMailbox } = get();
|
||||
const { selectedMailbox, emails } = get();
|
||||
const mailboxes = resolveActionMailboxes();
|
||||
const effectiveClient = resolveActionClient(client);
|
||||
|
||||
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
|
||||
if (!currentMailbox) return;
|
||||
|
||||
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
|
||||
|
||||
try {
|
||||
for (const emailId of emailIds) {
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId);
|
||||
const email = emails.find(e => e.id === emailId);
|
||||
const markRead = alsoMarkRead && !!email && !email.keywords?.$seen;
|
||||
await effectiveClient.markAsSpam(emailId, currentMailbox.accountId, markRead);
|
||||
}
|
||||
|
||||
set(state => ({
|
||||
|
||||
@@ -28,7 +28,7 @@ export type FontSize = 'small' | 'medium' | 'large';
|
||||
export type Density = 'extra-compact' | 'compact' | 'regular' | 'comfortable';
|
||||
/** @deprecated Use Density instead */
|
||||
export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'permanent';
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
@@ -241,6 +241,16 @@ interface SettingsState {
|
||||
tourCompleted: boolean; // Interactive tour completed
|
||||
showOnboardingOnNewDevices: boolean; // When true, onboarding shows again on each new device
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: string;
|
||||
attachmentDownloadTemplate: string;
|
||||
bundleDownloadTemplate: string;
|
||||
filenameSpaceReplacement: 'keep' | 'underscore' | 'dash';
|
||||
filenameLowercase: boolean;
|
||||
filenameStripDiacritics: boolean;
|
||||
filenameCollapseSeparators: boolean;
|
||||
postExportAction: 'keep' | 'archive' | 'trash';
|
||||
|
||||
// Advanced
|
||||
debugMode: boolean;
|
||||
debugCategories: Record<DebugCategory, boolean>;
|
||||
@@ -427,6 +437,16 @@ const DEFAULT_SETTINGS = {
|
||||
tourCompleted: false,
|
||||
showOnboardingOnNewDevices: false,
|
||||
|
||||
// Downloads
|
||||
emailDownloadTemplate: '{date} ({from}-{to}) {subject}',
|
||||
attachmentDownloadTemplate: '{filename}',
|
||||
bundleDownloadTemplate: 'emails-{count}',
|
||||
filenameSpaceReplacement: 'keep' as 'keep' | 'underscore' | 'dash',
|
||||
filenameLowercase: false,
|
||||
filenameStripDiacritics: false,
|
||||
filenameCollapseSeparators: true,
|
||||
postExportAction: 'keep' as 'keep' | 'archive' | 'trash',
|
||||
|
||||
// Advanced
|
||||
debugMode: false,
|
||||
debugCategories: {
|
||||
|
||||
Reference in New Issue
Block a user