@@ -872,7 +872,7 @@ export default function LoginPage() {
diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx
index e9c283ef..24db868d 100644
--- a/app/(main)/[locale]/page.tsx
+++ b/app/(main)/[locale]/page.tsx
@@ -15,7 +15,7 @@ import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/li
import { useAccountStore } from "@/stores/account-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
-import { useEmailStore } from "@/stores/email-store";
+import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -342,47 +342,22 @@ export default function Home() {
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
+ const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
- const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
- const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
- const clients = useAuthStore.getState().getAllConnectedClients();
- const result: UnifiedAccountClient[] = [];
- for (const account of connected) {
- const accountClient = clients.get(account.id);
- if (!accountClient) continue;
- result.push({
- accountId: account.id,
- accountLabel: account.label || account.email,
- client: accountClient,
- mailboxes: [],
- });
- }
- return result;
+ // Builds the populated UnifiedAccountClient[] used by the unified-view
+ // effects and one-shot actions in this page. Reads the includeGroup
+ // setting at call time so the latest toggle value is always honored.
+ const buildPopulatedUnifiedAccounts = useCallback(async (): Promise
=> {
+ return buildUnifiedAccountClients({
+ includeGroup: useSettingsStore.getState().includeGroupInUnified,
+ });
}, []);
- const populateUnifiedAccountMailboxes = useCallback(
- async (list: UnifiedAccountClient[]): Promise => {
- const populated = await Promise.all(
- list.map(async (entry) => {
- try {
- const mailboxes = await entry.client.getMailboxes();
- return { ...entry, mailboxes };
- } catch (err) {
- debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
- return entry;
- }
- }),
- );
- return populated;
- },
- [],
- );
-
const getMailtoProtocolAccounts = useCallback(() => {
const connectedClients = useAuthStore.getState().getAllConnectedClients();
return useAccountStore.getState().accounts.filter((account) =>
@@ -1004,12 +979,12 @@ export default function Home() {
useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return;
if (!isAuthenticated || !client) return;
- const built = buildUnifiedAccounts();
- if (built.length < 2) return;
- populateUnifiedAccountMailboxes(built).then((populated) => {
- refreshUnifiedCounts(populated);
+ buildPopulatedUnifiedAccounts().then((built) => {
+ const hasGroupEntry = built.some((b) => b.isShared);
+ if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
+ refreshUnifiedCounts(built);
});
- }, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
+ }, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=` (specific email it built the toast from) or
@@ -1656,8 +1631,7 @@ export default function Home() {
setTabletListVisible(true);
}
- const built = buildUnifiedAccounts();
- const populated = await populateUnifiedAccountMailboxes(built);
+ const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
refreshUnifiedCounts(populated);
return;
@@ -2004,8 +1978,7 @@ export default function Home() {
if (isUnifiedView) {
const role = useEmailStore.getState().unifiedRole;
if (role) {
- const built = buildUnifiedAccounts();
- const populated = await populateUnifiedAccountMailboxes(built);
+ const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
}
return;
@@ -2180,7 +2153,7 @@ export default function Home() {
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
- const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
+ const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
// Handle email selection with mobile view switching
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
index 69f6091d..638e9fee 100644
--- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx
+++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState(() => value.map(entryToDraft));
+ const lastEmittedRef = useRef(value);
useEffect(() => {
- // Re-sync from props when the underlying config value changes (e.g. revert,
- // initial load). Skip when drafts already represent the same array to avoid
- // clobbering in-progress edits.
- setDrafts((prev) => {
- if (prev.length === value.length) {
- const same = prev.every((d, i) => {
- const e = value[i];
- return d.id === e.id && d.url === e.url && d.label === e.label;
- });
- if (same) return prev;
- }
- return value.map(entryToDraft);
- });
+ if (value === lastEmittedRef.current) return;
+ setDrafts(value.map(entryToDraft))
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
- const entries: JmapServerEntry[] = [];
- for (const d of next) {
- const e = draftToEntry(d);
- if (e) entries.push(e);
- }
+ const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
+ lastEmittedRef.current = entries;
onChange(entries);
}
diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx
index a792703a..19fd9020 100644
--- a/app/(main)/admin/layout.tsx
+++ b/app/(main)/admin/layout.tsx
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
-import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
+import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
// Single-page tab navigation: clicks update a Zustand store. The URL stays
// at /admin so React doesn't fire a route transition on every tab switch -
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
- const logoUrl = resolvedTheme === 'dark'
+ const logoUrl = withBasePath(resolvedTheme === 'dark'
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
- : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
+ : (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
// Match the navigation rail: red for security/deprecated, amber for normal.
const hasUpdate = useUpdateStore(selectHasUpdate);
diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx
index 9f57f3b3..018777d4 100644
--- a/app/(main)/admin/login/page.tsx
+++ b/app/(main)/admin/login/page.tsx
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
import { Shield } from 'lucide-react';
import { useConfig } from '@/hooks/use-config';
import { useThemeStore } from '@/stores/theme-store';
-import { apiFetch } from '@/lib/browser-navigation';
+import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export default function AdminLoginPage() {
const router = useRouter();
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
const [loading, setLoading] = useState(false);
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
- const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
+ const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx
index b98b064c..ac5c3b42 100644
--- a/app/(main)/layout.tsx
+++ b/app/(main)/layout.tsx
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
import { configManager } from "@/lib/admin/config-manager";
+import { withBasePath } from "@/lib/browser-navigation";
import "../globals.css";
const geistSans = Geist({
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise {
formatDetection: {
telephone: false,
},
- icons: { icon: faviconUrl },
+ icons: { icon: withBasePath(faviconUrl) },
};
}
diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx
index 686bb202..c40664f5 100644
--- a/app/(main)/setup/page.tsx
+++ b/app/(main)/setup/page.tsx
@@ -3,7 +3,7 @@
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
-import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
+import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
type State = 'bootstrap' | 'configured' | 'env-managed';
@@ -1329,7 +1329,7 @@ function BrandingAsset({
}}
/>
{value ? (
-
+
) : (
click or drop
)}
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index f797f0ea..4886324c 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -157,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
- continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx
index 78de3c28..1f0e4f67 100644
--- a/components/calendar/event-detail-popover.tsx
+++ b/components/calendar/event-detail-popover.tsx
@@ -8,11 +8,11 @@ import {
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
Pencil, Trash2, Copy, Send, Check,
} from "lucide-react";
-import { format, parseISO } from "date-fns";
+import { format, isSameDay, parseISO } from "date-fns";
import { cn } from "@/lib/utils";
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
-import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
+import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
import {
isOrganizer,
getUserParticipantId,
@@ -143,6 +143,8 @@ export function EventDetailPopover({
const startDate = getEventStartDate(event);
const durationMinutes = parseDuration(event.duration);
const endDate = getEventEndDate(event);
+ const displayEndDate = getEventDisplayEndDate(event);
+ const isMultiDay = !isSameDay(startDate, displayEndDate);
const locationName = useMemo(() => {
if (!event.locations) return null;
@@ -331,16 +333,50 @@ export function EventDetailPopover({
-
- {formatEventDate(startDate)}
-
- {event.showWithoutTime ? (
-
{t("events.all_day")}
+ {isMultiDay ? (
+ event.showWithoutTime ? (
+ <>
+
+ {formatEventDate(startDate)} –
+
+
+ {formatEventDate(displayEndDate)}
+
+
{t("events.all_day")}
+ >
+ ) : (
+ <>
+
+ {formatEventDate(startDate)}
+
+ {formatTime(startDate)}
+
+
+
+ {formatEventDate(endDate)}
+
+ {formatTime(endDate)}
+
+
+
+ ({formatDurationDisplay(durationMinutes)})
+
+ >
+ )
) : (
-
- {formatTime(startDate)} – {formatTime(endDate)}
- ({formatDurationDisplay(durationMinutes)})
-
+ <>
+
+ {formatEventDate(startDate)}
+
+ {event.showWithoutTime ? (
+
{t("events.all_day")}
+ ) : (
+
+ {formatTime(startDate)} – {formatTime(endDate)}
+ ({formatDurationDisplay(durationMinutes)})
+
+ )}
+ >
)}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx
index 1d815207..c0d7c87a 100644
--- a/components/calendar/event-modal.tsx
+++ b/components/calendar/event-modal.tsx
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
-import { format, parseISO, addHours, addDays } from "date-fns";
+import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
import { parseDuration, getEventColor } from "./event-card";
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
@@ -621,14 +621,42 @@ export function EventModal({
-
- {formatEventDate(startD)}
- {!event.showWithoutTime && (
-
- {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
-
- )}
-
+ {(() => {
+ const displayEnd = getEventDisplayEndDate(event);
+ const multiDay = !isSameDay(startD, displayEnd);
+ if (multiDay && event.showWithoutTime) {
+ return (
+
+
{formatEventDate(startD)} –
+
{formatEventDate(displayEnd)}
+
+ );
+ }
+ if (multiDay) {
+ return (
+
+
+ {formatEventDate(startD)}
+ {format(startD, timeDisplayFmt)}
+
+
+ {formatEventDate(endD)}
+ {format(endD, timeDisplayFmt)}
+
+
+ );
+ }
+ return (
+
+ {formatEventDate(startD)}
+ {!event.showWithoutTime && (
+
+ {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
+
+ )}
+
+ );
+ })()}
{event.description && (
{event.description}
@@ -742,17 +770,55 @@ export function EventModal({
-
- {formatEventDate(startD)}
-
- {event.showWithoutTime ? (
-
{t("events.all_day")}
- ) : (
-
- {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
- ({formatDurationDisplay(durMin)})
-
- )}
+ {(() => {
+ const displayEnd = getEventDisplayEndDate(event);
+ const multiDay = !isSameDay(startD, displayEnd);
+ if (multiDay && event.showWithoutTime) {
+ return (
+ <>
+
{formatEventDate(startD)} –
+
{formatEventDate(displayEnd)}
+
{t("events.all_day")}
+ >
+ );
+ }
+ if (multiDay) {
+ return (
+ <>
+
+ {formatEventDate(startD)}
+
+ {format(startD, timeDisplayFmt)}
+
+
+
+ {formatEventDate(endD)}
+
+ {format(endD, timeDisplayFmt)}
+
+
+
+ ({formatDurationDisplay(durMin)})
+
+ >
+ );
+ }
+ return (
+ <>
+
+ {formatEventDate(startD)}
+
+ {event.showWithoutTime ? (
+
{t("events.all_day")}
+ ) : (
+
+ {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
+ ({formatDurationDisplay(durMin)})
+
+ )}
+ >
+ );
+ })()}
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index 2fdd4172..84813009 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
- const isFocusedMailLayout = mailLayout === 'focus';
+ const isMobile = useUIStore((state) => state.isMobile);
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
sourceMailboxId: selectedMailbox,
});
- const isMobile = useUIStore((state) => state.isMobile);
-
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 50f5aae5..80753f58 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
+import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -121,9 +122,11 @@ export function EmailList({
const density = useSettingsStore((state) => state.density);
const showPreview = useSettingsStore((state) => state.showPreview);
const mailLayout = useSettingsStore((state) => state.mailLayout);
- const isFocusedMailLayout = mailLayout === 'focus';
const footerHasMore = hasMore ?? hasMoreEmails;
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
+ const isMobile = useUIStore((state) => state.isMobile);
+ // Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index d93ab922..91c1f9b9 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
+import { withBasePath } from "@/lib/browser-navigation";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
@@ -76,6 +77,7 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
import { toast } from "@/stores/toast-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { useAuthStore } from "@/stores/auth-store";
+import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useThemeStore } from "@/stores/theme-store";
import { EmailIdentityBadge } from "./email-identity-badge";
@@ -954,6 +956,7 @@ export function EmailViewer({
const { isTablet, isMobile } = useDeviceDetection();
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
+ const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
const value = window.prompt(t('reschedule_prompt'));
if (!value) return null;
@@ -3301,9 +3304,9 @@ export function EmailViewer({
if (!email) {
if (isDemoMode) {
- const logoSrc = resolvedTheme === 'dark'
+ const logoSrc = withBasePath(resolvedTheme === 'dark'
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
- : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
+ : '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
return (
@@ -5384,10 +5387,12 @@ export function EmailViewer({
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index 68b17c40..11e84099 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -74,7 +74,9 @@ const SingleEmailItem = React.forwardRef
(
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
- const isFocusedMailLayout = mailLayout === 'focus';
+ const isMobile = useUIStore((state) => state.isMobile);
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
@@ -95,8 +97,6 @@ const SingleEmailItem = React.forwardRef(
sourceMailboxId: selectedMailbox,
});
- const isMobile = useUIStore((state) => state.isMobile);
-
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -404,7 +404,8 @@ export const ThreadListItem = React.forwardRef state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
- const isFocusedMailLayout = mailLayout === 'focus';
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index 56a721d9..fc6649ff 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
import { cn, formatFileSize } from "@/lib/utils";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
-import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
+import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { Avatar } from "@/components/ui/avatar";
interface NavItem {
@@ -444,7 +444,7 @@ export function NavigationRail({
)}
>
{(() => {
- const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
+ const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
return logoUrl ? (
s.hideAccountSwitcher) || isEmbedded;
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
+ const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
const tagCounts = useEmailStore(s => s.tagCounts);
const accounts = useAccountStore(s => s.accounts);
const connectedAccounts = accounts.filter(a => a.isConnected);
+ const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
// Pro shell treats the unified mailbox as a core part of the multi-account
- // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
- // 2+ account requirement still applies - with a single account the
- // unified counts would just duplicate that account's inbox.
- const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
+ // UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
+ // single account we still surface unified when the user has opted into
+ // merging group/shared inboxes — otherwise the counts would just duplicate
+ // the one inbox.
+ const showUnified =
+ (multiAccountMode || enableUnifiedMailbox) &&
+ (connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
const { unifiedCounts } = useEmailStore();
const t = useTranslations('sidebar');
diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx
index cb21f7e1..00c8bfb3 100644
--- a/components/pwa-install-prompt.tsx
+++ b/components/pwa-install-prompt.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { X, Download } from "lucide-react";
import { useConfig } from "@/hooks/use-config";
+import { withBasePath } from "@/lib/browser-navigation";
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise
;
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
return null;
}
- const logoSrc = appLogoLightUrl || faviconUrl;
+ const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
+ const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return (
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
)}
{logoSrc && (
diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx
index b8e655f8..4154f17b 100644
--- a/components/settings/calendar-settings.tsx
+++ b/components/settings/calendar-settings.tsx
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
-import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
+import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
export function CalendarSettings() {
const t = useTranslations('calendar.settings');
const tViews = useTranslations('calendar.views');
- const tDays = useTranslations('calendar.days');
const { viewMode, setViewMode } = useCalendarStore();
const {
- timeFormat,
- firstDayOfWeek,
showTimeInMonthView,
showWeekNumbers,
enableCalendarTasks,
@@ -40,28 +37,6 @@ export function CalendarSettings() {
/>
-
- updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
- options={[
- { value: '1', label: tDays('monday') },
- { value: '0', label: tDays('sunday') },
- ]}
- />
-
-
-
- updateSetting('timeFormat', value as '12h' | '24h')}
- options={[
- { value: '12h', label: t('time_format_12h') },
- { value: '24h', label: t('time_format_24h') },
- ]}
- />
-
-
{settings.showThumbnails && file.thumbnailUrl ? (
-
+
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
-
+
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
diff --git a/components/settings/language-settings.tsx b/components/settings/language-settings.tsx
index c8e22725..1a5cdf9d 100644
--- a/components/settings/language-settings.tsx
+++ b/components/settings/language-settings.tsx
@@ -1,17 +1,100 @@
"use client";
+import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
-import { SettingsSection, SettingItem } from './settings-section';
+import { useLocaleStore } from '@/stores/locale-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
+import { formatDate } from '@/lib/utils';
+import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
export function LanguageSettings() {
- const t = useTranslations('settings.appearance');
+ const t = useTranslations('settings.language_region');
+ const tDays = useTranslations('calendar.days');
+
+ const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
+
+ // Subscribe to locale changes so the preview re-renders on language switch
+ // (formatDate reads it via getState() and would otherwise stay stale).
+ const locale = useLocaleStore((s) => s.locale);
+
+ const preview = useMemo(() => {
+ // Build sample timestamps for each bucket so users see what their pick
+ // will look like in practice. Use offsets relative to "now" so the
+ // bucketing is stable even though the wall-clock keeps moving.
+ void locale; void dateFormat; void timeFormat;
+ const now = new Date();
+ const today = new Date(now);
+ today.setHours(15, 31, 0, 0);
+ const thisWeek = new Date(now);
+ thisWeek.setDate(now.getDate() - 2);
+ thisWeek.setHours(15, 31, 0, 0);
+ const older = new Date(now);
+ older.setMonth(now.getMonth() - 2);
+ older.setHours(15, 31, 0, 0);
+ return {
+ today: formatDate(today),
+ thisWeek: formatDate(thisWeek),
+ older: formatDate(older),
+ };
+ }, [locale, dateFormat, timeFormat]);
return (
-
+
+
+
+
+
updateSetting('dateFormat', value as DateFormat)}
+ options={[
+ { value: 'smart', label: t('date_format.smart') },
+ { value: 'relative', label: t('date_format.relative') },
+ { value: 'full', label: t('date_format.full') },
+ ]}
+ />
+
+
+ {t('date_format.preview_today')}
+ {preview.today}
+
+
+ {t('date_format.preview_this_week')}
+ {preview.thisWeek}
+
+
+ {t('date_format.preview_older')}
+ {preview.older}
+
+
+
+
+
+
+ updateSetting('timeFormat', value as TimeFormat)}
+ options={[
+ { value: '12h', label: t('time_format.12h') },
+ { value: '24h', label: t('time_format.24h') },
+ ]}
+ />
+
+
+
+ updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
+ options={[
+ { value: '1', label: tDays('monday') },
+ { value: '0', label: tDays('sunday') },
+ ]}
+ />
+
);
}
diff --git a/components/settings/layout-settings.tsx b/components/settings/layout-settings.tsx
index a3ac41c6..b4d08c4f 100644
--- a/components/settings/layout-settings.tsx
+++ b/components/settings/layout-settings.tsx
@@ -1,11 +1,13 @@
"use client";
+import { useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import { usePolicyStore } from '@/stores/policy-store';
import { useAccountStore } from '@/stores/account-store';
+import { useEmailStore } from '@/stores/email-store';
const MAIL_LAYOUT_PREVIEW_ROWS = [
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
@@ -115,9 +117,11 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
- const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
+ const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
+ const mailboxes = useEmailStore(s => s.mailboxes);
+ const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
return (
@@ -177,10 +181,11 @@ export function LayoutSettings() {
/>
- {accounts.length > 1 && (
+ {(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
)}
+ {enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
+
+
+ updateSetting('includeGroupInUnified', v)}
+ />
+
+
+ )}
+
mb.id === sourceMailboxId);
- if (sourceMb?.accountId !== mailbox.accountId) {
- return false;
- }
- }
-
return true;
- }, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
+ }, [isDragging, mailbox, sourceMailboxId]);
const handleDragOver = useCallback((e: DragEvent) => {
e.preventDefault();
@@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
bySource.get(srcAccountId)!.push(id);
}
+ // Decide whether to route via the cross-account (blob copy + import)
+ // pipeline. Two cases require it:
+ // 1. Destination is a delegated/shared mailbox whose owner JMAP
+ // account differs from the source mailbox's JMAP account. There's
+ // no atomic Email/set across accounts, even via the same client.
+ // 2. Destination is a primary mailbox on a different connected local
+ // account than the source — the historical multi-account case.
+ const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
+ const sourceJmapAccountId = sourceMb?.accountId;
+ const destJmapAccountId = mailbox.accountId;
const sourceAccountIds = Array.from(bySource.keys());
- const isCrossAccount =
- !!destAccountId &&
+
+ const isJmapCrossAccount =
+ !!sourceJmapAccountId &&
+ !!destJmapAccountId &&
+ sourceJmapAccountId !== destJmapAccountId;
+ const isLocalCrossAccount =
!mailbox.isShared &&
+ !!destAccountId &&
sourceAccountIds.some((src) => src !== destAccountId);
+ const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
if (isCrossAccount) {
- // JMAP can't natively move an email between primary accounts, so the
- // store reuploads each source blob into the destination account and
- // then deletes the original.
+ if (!destAccountId) {
+ throw new Error('Could not resolve destination account');
+ }
+ // For a shared destination there is no separately-connected client
+ // for the owner; we reuse the viewing user's client but tell the
+ // import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id;
- await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
+ const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
+ await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id);
diff --git a/i18n/routing.ts b/i18n/routing.ts
index 8bea497e..ad420514 100644
--- a/i18n/routing.ts
+++ b/i18n/routing.ts
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
+const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
+
+// Fallback locale used when the visitor's Accept-Language header does not
+// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
+// deployments without having every user change their preference manually.
+const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
+const resolvedDefaultLocale =
+ envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
+ ? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
+ : 'en';
+
export const routing = defineRouting({
- locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
- defaultLocale: 'en',
+ locales: SUPPORTED_LOCALES,
+ defaultLocale: resolvedDefaultLocale,
localePrefix
});
diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts
index 5df6ff91..15369273 100644
--- a/lib/browser-navigation.ts
+++ b/lib/browser-navigation.ts
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise {
return fetch(input, init);
}
+/**
+ * Mount-prefix-aware wrapper for URL strings used in ` `, ` `,
+ * `window.location.*`, etc. — anything the browser resolves itself, where
+ * `apiFetch` can't help.
+ *
+ * Idempotent: passing an already-prefixed value, an external URL, a
+ * protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
+ * safe to wrap admin-configurable values that might be either a local path
+ * (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
+ */
+export function withBasePath(url: string | null | undefined): string {
+ if (!url) return url ?? '';
+ if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
+ if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
+ const prefix = getPathPrefix();
+ if (!prefix) return url;
+ if (url === prefix || url.startsWith(prefix + '/')) return url;
+ return prefix + url;
+}
+
/**
* Extracts the locale from the current URL, skipping any mount prefix.
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 7bd23ba1..ccac56ec 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -283,6 +283,6 @@ export interface IJMAPClient {
copyFileNode(id: string, newName: string, parentId: string | null): Promise;
// ── S/MIME raw-email helpers ──────────────────────────────────
- importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record): Promise;
+ importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise;
submitEmail(emailId: string, identityId: string): Promise;
}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index ed1f6b55..d06b7f41 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2871,7 +2871,7 @@ export class JMAPClient implements IJMAPClient {
}
}
- async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
+ async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> {
if (!this.session) {
throw new Error('Not connected. Call connect() first.');
}
@@ -2881,7 +2881,8 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Upload URL not available');
}
- const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
+ const targetAccountId = accountId || this.accountId;
+ const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
const response = await this.authenticatedFetch(finalUploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
@@ -2911,7 +2912,7 @@ export class JMAPClient implements IJMAPClient {
}
// Nested format: { [accountId]: { blobId, type, size } }
- const blobInfo = result[this.accountId];
+ const blobInfo = result[targetAccountId];
if (blobInfo?.blobId) {
return {
blobId: blobInfo.blobId,
@@ -5464,20 +5465,29 @@ export class JMAPClient implements IJMAPClient {
return response.arrayBuffer();
}
- /** Import a raw MIME message blob into the account. */
+ /**
+ * Import a raw MIME message blob into the account. Pass `accountId` to
+ * target a delegated account the caller has rights on (e.g. importing into
+ * a shared mailbox owned by another user). When omitted, falls back to the
+ * client's own primary account.
+ */
async importRawEmail(
blob: Blob,
mailboxIds: Record,
keywords?: Record,
+ accountId?: string,
): Promise {
- // First upload the blob
+ const targetAccountId = accountId || this.accountId;
+ // First upload the blob. Blob uploads are scoped to an account too —
+ // when importing into a delegated account, upload there so the resulting
+ // blobId is visible to Email/import on that account.
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
- const { blobId } = await this.uploadBlob(file);
+ const { blobId } = await this.uploadBlob(file, targetAccountId);
// Then import via Email/import
const response = await this.request([
['Email/import', {
- accountId: this.accountId,
+ accountId: targetAccountId,
emails: {
'smime-import': {
blobId,
diff --git a/lib/unified-mailbox.ts b/lib/unified-mailbox.ts
index f1f38649..1b3a1f8e 100644
--- a/lib/unified-mailbox.ts
+++ b/lib/unified-mailbox.ts
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
+ // When true, this entry represents a group/shared account owned by
+ // `accountId` but accessed through someone else's `client`. JMAP requests
+ // must use the mailbox's `originalId` and explicitly target this accountId
+ // so the server routes to the owner's data.
+ isShared?: boolean;
}
export interface UnifiedFetchResult {
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
const mailbox = findMailboxByRole(account.mailboxes, role);
if (!mailbox) return null;
+ const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
try {
const result = await account.client.getEmails(
- mailbox.id,
- undefined,
+ jmapMailboxId,
+ jmapAccountId,
limit,
position,
);
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
position: number,
): Promise {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
- return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
+ const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
+ return account.client.searchEmails(query, jmapMailboxId, jmapAccountId, limit, position);
});
}
@@ -149,10 +156,31 @@ export async function advancedSearchUnifiedEmails(
position: number,
): Promise {
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
- return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
+ const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
+ return account.client.advancedSearchEmails(filterFor(jmapMailboxId), jmapAccountId, limit, position);
});
}
+/**
+ * Resolves the JMAP-side mailbox id and accountId for a mailbox living inside
+ * a UnifiedAccountClient. For personal-account entries we use the JMAP id as
+ * returned by the primary client; for shared-owner entries the mailbox id is
+ * namespaced (`${ownerId}:${origId}`) so we must use `originalId` and pass the
+ * owner's accountId through the request.
+ */
+function resolveJmapTarget(
+ account: UnifiedAccountClient,
+ mailbox: Mailbox,
+): { jmapMailboxId: string; jmapAccountId: string | undefined } {
+ if (account.isShared) {
+ return {
+ jmapMailboxId: mailbox.originalId ?? mailbox.id,
+ jmapAccountId: account.accountId,
+ };
+ }
+ return { jmapMailboxId: mailbox.id, jmapAccountId: undefined };
+}
+
async function fanOutUnifiedQuery(
accounts: UnifiedAccountClient[],
role: UnifiedMailboxRole,
diff --git a/lib/utils.ts b/lib/utils.ts
index d6bd9371..9c623d27 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
+import { useLocaleStore } from "@/stores/locale-store";
+import { useSettingsStore } from "@/stores/settings-store";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -40,24 +42,86 @@ export function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
+/**
+ * Formats a received-at date for the email list. The output style is
+ * controlled by the `dateFormat` user setting:
+ *
+ * - `smart` (default) — locale-aware, age-bucketed:
+ * today → time only ("15:31" or "3:31 PM")
+ * last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
+ * older → full locale date ("28.04.2026", "04/28/2026")
+ * - `relative` — legacy en-US relative format ("1h ago", "2d ago").
+ * - `full` — always the full locale date+time.
+ *
+ * Both the locale (from the language picker) and 12h/24h preference are
+ * read via `getState()` so this stays SSR-safe.
+ */
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
- const diff = now.getTime() - d.getTime();
- const minutes = Math.floor(diff / 60000);
- const hours = Math.floor(diff / 3600000);
- const days = Math.floor(diff / 86400000);
+ const localeRaw = useLocaleStore.getState().locale;
+ const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
+ // `en` alone resolves to en-US in Intl; everything else uses the language
+ // subtag as-is and lets the runtime pick a sensible default region.
+ const intlLocale = locale === "en" ? "en-US" : locale;
+ const { dateFormat, timeFormat } = useSettingsStore.getState();
+ const hour12 = timeFormat === "12h";
- if (minutes < 1) return "Just now";
- if (minutes < 60) return `${minutes}m ago`;
- if (hours < 24) return `${hours}h ago`;
- if (days < 7) return `${days}d ago`;
+ if (dateFormat === "relative") {
+ const diff = now.getTime() - d.getTime();
+ const minutes = Math.floor(diff / 60000);
+ const hours = Math.floor(diff / 3600000);
+ const days = Math.floor(diff / 86400000);
+ if (minutes < 1) return "Just now";
+ if (minutes < 60) return `${minutes}m ago`;
+ if (hours < 24) return `${hours}h ago`;
+ if (days < 7) return `${days}d ago`;
+ return d.toLocaleDateString(intlLocale, {
+ month: "short",
+ day: "numeric",
+ year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
+ });
+ }
- return d.toLocaleDateString("en-US", {
- month: "short",
- day: "numeric",
- year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
+ if (dateFormat === "full") {
+ return d.toLocaleString(intlLocale, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12,
+ });
+ }
+
+ // 'smart' (default)
+ const timeStr = d.toLocaleTimeString(intlLocale, {
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12,
+ });
+
+ const isSameDay =
+ d.getFullYear() === now.getFullYear() &&
+ d.getMonth() === now.getMonth() &&
+ d.getDate() === now.getDate();
+ if (isSameDay) return timeStr;
+
+ const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
+ if (daysAgo < 7) {
+ // German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
+ // strip it so the result reads cleanly next to the time.
+ const weekday = d
+ .toLocaleDateString(intlLocale, { weekday: "short" })
+ .replace(/\.$/, "");
+ return `${weekday} ${timeStr}`;
+ }
+
+ return d.toLocaleDateString(intlLocale, {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
});
}
diff --git a/lib/webdav/client.ts b/lib/webdav/client.ts
index 2772d37d..05486798 100644
--- a/lib/webdav/client.ts
+++ b/lib/webdav/client.ts
@@ -4,6 +4,7 @@
*/
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
+import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export interface WebDAVResource {
href: string;
@@ -32,7 +33,7 @@ export class WebDAVClient {
...options?.headers,
};
- return fetch(this.proxyUrl, {
+ return apiFetch(this.proxyUrl, {
method: 'POST',
headers,
body: options?.body,
@@ -118,7 +119,7 @@ export class WebDAVClient {
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
- xhr.open('POST', this.proxyUrl);
+ xhr.open('POST', withBasePath(this.proxyUrl));
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders();
diff --git a/locales/cs/common.json b/locales/cs/common.json
index 4b0db34f..6a989409 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Pokračovat v úpravách",
"tabs": {
"appearance": "Vzhled",
- "language": "Jazyk a region",
+ "language": "Jazyk, region a čas",
"email": "Chování e-mailu",
"composer": "Psaní zpráv",
"privacy": "Soukromí a bezpečnost",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Sjednocená schránka",
- "description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů"
+ "description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
+ "include_group": {
+ "label": "Zahrnout skupinové schránky",
+ "description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
+ }
},
"colorful_sidebar_icons": {
"label": "Barevné ikony postranního panelu",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Jazyk a region",
- "description": "Nakonfigurujte jazykové a místní předvolby",
+ "title": "Jazyk, region a čas",
+ "description": "Jazyk, formát data, formát času a další regionální předvolby",
"language": {
"label": "Jazyk",
"description": "Vyberte preferovaný jazyk",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Formát data",
- "description": "Jak se mají zobrazovat data",
- "regional": "Místní",
- "iso": "ISO 8601",
- "custom": "Vlastní"
+ "description": "Jak se zobrazují data v seznamu e-mailů",
+ "smart": "Chytrý (regionální)",
+ "relative": "Relativní (před 1 h, před 2 d)",
+ "full": "Vždy úplné datum",
+ "preview_today": "Dnes:",
+ "preview_this_week": "Tento týden:",
+ "preview_older": "Starší:"
},
"time_format": {
"label": "Formát času",
diff --git a/locales/da/common.json b/locales/da/common.json
index 4990ccf8..5adbe4f2 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -785,7 +785,7 @@
"search_no_results": "Ingen match i indstillinger",
"tabs": {
"appearance": "Udseende",
- "language": "Sprog & region",
+ "language": "Sprog, region & tid",
"email": "E-mail-adfærd",
"composer": "Komponist",
"privacy": "Privatliv & sikkerhed",
@@ -877,7 +877,11 @@
},
"unified_mailbox": {
"label": "Samlet postkasse",
- "description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti"
+ "description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
+ "include_group": {
+ "label": "Inkluder gruppepostkasser",
+ "description": "Inkluder også delte/gruppepostkasser i den samlede visning."
+ }
},
"colorful_sidebar_icons": {
"label": "Farverige sidepane-ikoner",
@@ -961,8 +965,8 @@
}
},
"language_region": {
- "title": "Sprog & region",
- "description": "Konfigurér sprog og regionale præferencer",
+ "title": "Sprog, region & tid",
+ "description": "Sprog, datoformat, tidsformat og andre regionale indstillinger",
"language": {
"label": "Sprog",
"description": "Vælg dit foretrukne sprog",
@@ -971,10 +975,13 @@
},
"date_format": {
"label": "Datoformat",
- "description": "Hvordan datoer skal vises",
- "regional": "Regionalt",
- "iso": "ISO 8601",
- "custom": "Brugerdefineret"
+ "description": "Sådan vises datoer på e-mail-listen",
+ "smart": "Smart (regionalt)",
+ "relative": "Relativ (for 1 t siden, for 2 d siden)",
+ "full": "Altid fuld dato",
+ "preview_today": "I dag:",
+ "preview_this_week": "Denne uge:",
+ "preview_older": "Ældre:"
},
"time_format": {
"label": "Tidsformat",
diff --git a/locales/de/common.json b/locales/de/common.json
index 7b0c2304..e80b5bd3 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Weiter bearbeiten",
"tabs": {
"appearance": "Darstellung",
- "language": "Sprache & Region",
+ "language": "Sprache, Region & Zeit",
"email": "E-Mail-Verhalten",
"composer": "Editor",
"privacy": "Datenschutz & Sicherheit",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Gemeinsames Postfach",
- "description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
+ "description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
+ "include_group": {
+ "label": "Gruppenpostfächer einbeziehen",
+ "description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
+ }
},
"colorful_sidebar_icons": {
"label": "Farbige Seitenleistensymbole",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Sprache & Region",
- "description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
+ "title": "Sprache, Region & Zeit",
+ "description": "Sprache, Datums- und Zeitformat sowie weitere regionale Einstellungen",
"language": {
"label": "Sprache",
"description": "Wählen Sie Ihre bevorzugte Sprache",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Datumsformat",
- "description": "Wie Daten angezeigt werden sollen",
- "regional": "Regional",
- "iso": "ISO 8601",
- "custom": "Benutzerdefiniert"
+ "description": "Wie Daten in der E-Mail-Liste angezeigt werden",
+ "smart": "Intelligent (gebietsschemaabhängig)",
+ "relative": "Relativ (vor 1 Std., vor 2 Tagen)",
+ "full": "Immer vollständiges Datum",
+ "preview_today": "Heute:",
+ "preview_this_week": "Diese Woche:",
+ "preview_older": "Älter:"
},
"time_format": {
"label": "Zeitformat",
diff --git a/locales/en/common.json b/locales/en/common.json
index 573d7a7d..a7d3a38c 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -785,7 +785,7 @@
"search_no_results": "No matching settings",
"tabs": {
"appearance": "Appearance",
- "language": "Language & Region",
+ "language": "Language, Region & Time",
"email": "Email Behavior",
"composer": "Composer",
"privacy": "Privacy & Security",
@@ -877,7 +877,11 @@
},
"unified_mailbox": {
"label": "Unified Mailbox",
- "description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
+ "description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts",
+ "include_group": {
+ "label": "Include group inboxes",
+ "description": "Also merge shared/group inboxes into the unified view."
+ }
},
"colorful_sidebar_icons": {
"label": "Colorful Sidebar Icons",
@@ -961,8 +965,8 @@
}
},
"language_region": {
- "title": "Language & Region",
- "description": "Configure language and regional preferences",
+ "title": "Language, Region & Time",
+ "description": "Language, date format, time format, and other regional preferences",
"language": {
"label": "Language",
"description": "Choose your preferred language",
@@ -971,10 +975,13 @@
},
"date_format": {
"label": "Date Format",
- "description": "How dates should be displayed",
- "regional": "Regional",
- "iso": "ISO 8601",
- "custom": "Custom"
+ "description": "How dates are shown in the email list",
+ "smart": "Smart (locale-aware)",
+ "relative": "Relative (1h ago, 2d ago)",
+ "full": "Always full date",
+ "preview_today": "Today:",
+ "preview_this_week": "This week:",
+ "preview_older": "Older:"
},
"time_format": {
"label": "Time Format",
diff --git a/locales/es/common.json b/locales/es/common.json
index 575f0ac5..e81aaccf 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Seguir editando",
"tabs": {
"appearance": "Apariencia",
- "language": "Idioma y Región",
+ "language": "Idioma, Región y Hora",
"email": "Comportamiento del Correo",
"composer": "Editor",
"privacy": "Privacidad y Seguridad",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Buzón unificado",
- "description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
+ "description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
+ "include_group": {
+ "label": "Incluir buzones de grupo",
+ "description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
+ }
},
"colorful_sidebar_icons": {
"label": "Iconos de barra lateral a color",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Idioma y Región",
- "description": "Configure las preferencias de idioma y región",
+ "title": "Idioma, Región y Hora",
+ "description": "Idioma, formato de fecha, formato de hora y otras preferencias regionales",
"language": {
"label": "Idioma",
"description": "Elija su idioma preferido",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Formato de Fecha",
- "description": "Cómo se deben mostrar las fechas",
- "regional": "Regional",
- "iso": "ISO 8601",
- "custom": "Personalizado"
+ "description": "Cómo se muestran las fechas en la lista de correos",
+ "smart": "Inteligente (según región)",
+ "relative": "Relativo (hace 1 h, hace 2 d)",
+ "full": "Fecha completa siempre",
+ "preview_today": "Hoy:",
+ "preview_this_week": "Esta semana:",
+ "preview_older": "Más antiguo:"
},
"time_format": {
"label": "Formato de Hora",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index db258bd6..8b26ae0c 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Continuer l'édition",
"tabs": {
"appearance": "Apparence",
- "language": "Langue et région",
+ "language": "Langue, région et heure",
"email": "Comportement email",
"composer": "Compositeur",
"privacy": "Confidentialité et sécurité",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
- "description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
+ "description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
+ "include_group": {
+ "label": "Inclure les boîtes de groupe",
+ "description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
+ }
},
"colorful_sidebar_icons": {
"label": "Icônes colorées dans la barre latérale",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Langue et région",
- "description": "Configurez vos préférences linguistiques et régionales",
+ "title": "Langue, région et heure",
+ "description": "Langue, format de date, format d'heure et autres préférences régionales",
"language": {
"label": "Langue",
"description": "Choisissez votre langue préférée",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Format de date",
- "description": "Comment les dates doivent être affichées",
- "regional": "Régional",
- "iso": "ISO 8601",
- "custom": "Personnalisé"
+ "description": "Comment les dates apparaissent dans la liste des e-mails",
+ "smart": "Intelligent (adapté à la région)",
+ "relative": "Relatif (il y a 1 h, il y a 2 j)",
+ "full": "Toujours la date complète",
+ "preview_today": "Aujourd'hui :",
+ "preview_this_week": "Cette semaine :",
+ "preview_older": "Plus ancien :"
},
"time_format": {
"label": "Format d'heure",
diff --git a/locales/it/common.json b/locales/it/common.json
index 83e6ae38..4eaa318b 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Continua a modificare",
"tabs": {
"appearance": "Aspetto",
- "language": "Lingua e regione",
+ "language": "Lingua, regione e ora",
"email": "Comportamento email",
"composer": "Editor",
"privacy": "Privacy e sicurezza",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Casella di posta unificata",
- "description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
+ "description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
+ "include_group": {
+ "label": "Includi le caselle di gruppo",
+ "description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
+ }
},
"colorful_sidebar_icons": {
"label": "Icone colorate nella barra laterale",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Lingua e regione",
- "description": "Configura le preferenze di lingua e regionali",
+ "title": "Lingua, regione e ora",
+ "description": "Lingua, formato data, formato ora e altre preferenze regionali",
"language": {
"label": "Lingua",
"description": "Scegli la tua lingua preferita",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Formato data",
- "description": "Come devono essere visualizzate le date",
- "regional": "Regionale",
- "iso": "ISO 8601",
- "custom": "Personalizzato"
+ "description": "Come vengono visualizzate le date nell'elenco delle e-mail",
+ "smart": "Intelligente (in base alla regione)",
+ "relative": "Relativo (1 h fa, 2 g fa)",
+ "full": "Sempre data completa",
+ "preview_today": "Oggi:",
+ "preview_this_week": "Questa settimana:",
+ "preview_older": "Più vecchio:"
},
"time_format": {
"label": "Formato ora",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 340ba1d9..6aa07403 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -784,7 +784,7 @@
"keep_editing": "編集を続ける",
"tabs": {
"appearance": "外観",
- "language": "言語と地域",
+ "language": "言語、地域、時刻",
"email": "メール動作",
"composer": "作成",
"privacy": "プライバシーとセキュリティ",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "統合メールボックス",
- "description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
+ "description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
+ "include_group": {
+ "label": "グループ受信トレイを含める",
+ "description": "共有/グループ受信トレイも統合ビューに含めます。"
+ }
},
"colorful_sidebar_icons": {
"label": "カラフルなサイドバーアイコン",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "言語と地域",
- "description": "言語と地域の設定を構成",
+ "title": "言語、地域、時刻",
+ "description": "言語、日付形式、時刻形式、その他の地域設定",
"language": {
"label": "言語",
"description": "お好みの言語を選択",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "日付形式",
- "description": "日付の表示形式",
- "regional": "地域設定",
- "iso": "ISO 8601",
- "custom": "カスタム"
+ "description": "メール一覧での日付の表示方法",
+ "smart": "スマート(地域に合わせる)",
+ "relative": "相対表示(1時間前、2日前)",
+ "full": "常に完全な日付",
+ "preview_today": "今日:",
+ "preview_this_week": "今週:",
+ "preview_older": "それ以前:"
},
"time_format": {
"label": "時刻形式",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index c7f808f9..5a45aa98 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -784,7 +784,7 @@
"keep_editing": "계속 수정하기",
"tabs": {
"appearance": "화면 설정",
- "language": "언어 및 지역",
+ "language": "언어, 지역 및 시간",
"email": "메일 동작",
"composer": "메일 쓰기",
"privacy": "개인정보 및 보안",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "통합 메일함",
- "description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
+ "description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
+ "include_group": {
+ "label": "그룹 받은편지함 포함",
+ "description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
+ }
},
"colorful_sidebar_icons": {
"label": "컬러풀한 사이드바 아이콘",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "언어 및 지역",
- "description": "언어와 지역 형식을 설정해 주세요",
+ "title": "언어, 지역 및 시간",
+ "description": "언어, 날짜 형식, 시간 형식 및 기타 지역 환경설정",
"language": {
"label": "언어",
"description": "사용할 언어를 선택해 주세요",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "날짜 형식",
- "description": "날짜가 표시되는 방식을 설정해요",
- "regional": "지역 설정",
- "iso": "ISO 8601",
- "custom": "사용자 지정"
+ "description": "이메일 목록에 날짜가 표시되는 방식",
+ "smart": "스마트 (지역에 맞춤)",
+ "relative": "상대 시간 (1시간 전, 2일 전)",
+ "full": "항상 전체 날짜",
+ "preview_today": "오늘:",
+ "preview_this_week": "이번 주:",
+ "preview_older": "이전:"
},
"time_format": {
"label": "시간 형식",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index 2c8b16bd..ddc7e420 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Turpināt rediģēšanu",
"tabs": {
"appearance": "Izskats",
- "language": "Valoda un reģions",
+ "language": "Valoda, reģions un laiks",
"email": "Pasta darbība",
"composer": "Redaktors",
"privacy": "Privātums un drošība",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Apvienotā pastkaste",
- "description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
+ "description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
+ "include_group": {
+ "label": "Iekļaut grupas pastkastes",
+ "description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
+ }
},
"colorful_sidebar_icons": {
"label": "Krāsainas sānjoslas ikonas",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Valoda un reģions",
- "description": "Iestatiet valodas un reģionālās preferences",
+ "title": "Valoda, reģions un laiks",
+ "description": "Valoda, datuma formāts, laika formāts un citas reģionālās preferences",
"language": {
"label": "Valoda",
"description": "Izvēlieties vēlamo valodu",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Datuma formāts",
- "description": "Kā attēlot datumus",
- "regional": "Reģionālais",
- "iso": "ISO 8601",
- "custom": "Pielāgots"
+ "description": "Kā datumi tiek attēloti e-pasta sarakstā",
+ "smart": "Gudrs (atbilstoši reģionam)",
+ "relative": "Relatīvs (pirms 1 st., pirms 2 d.)",
+ "full": "Vienmēr pilns datums",
+ "preview_today": "Šodien:",
+ "preview_this_week": "Šajā nedēļā:",
+ "preview_older": "Vecāks:"
},
"time_format": {
"label": "Laika formāts",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index f83cb15b..351b56d7 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Doorgaan met bewerken",
"tabs": {
"appearance": "Uiterlijk",
- "language": "Taal & Regio",
+ "language": "Taal, Regio & Tijd",
"email": "E-mailgedrag",
"composer": "Opstellen",
"privacy": "Privacy & Beveiliging",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Gecombineerd postvak",
- "description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
+ "description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
+ "include_group": {
+ "label": "Groepspostvakken meenemen",
+ "description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
+ }
},
"colorful_sidebar_icons": {
"label": "Gekleurde zijbalkpictogrammen",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Taal & Regio",
- "description": "Configureer taal- en regiovoorkeuren",
+ "title": "Taal, Regio & Tijd",
+ "description": "Taal, datumnotatie, tijdnotatie en andere regionale voorkeuren",
"language": {
"label": "Taal",
"description": "Kies je voorkeurstaal",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Datumnotatie",
- "description": "Hoe datums moeten worden weergegeven",
- "regional": "Regionaal",
- "iso": "ISO 8601",
- "custom": "Aangepast"
+ "description": "Hoe datums worden weergegeven in de e-maillijst",
+ "smart": "Slim (regionaal)",
+ "relative": "Relatief (1 u geleden, 2 d geleden)",
+ "full": "Altijd volledige datum",
+ "preview_today": "Vandaag:",
+ "preview_this_week": "Deze week:",
+ "preview_older": "Ouder:"
},
"time_format": {
"label": "Tijdnotatie",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 340bdada..bc336752 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Kontynuuj edycję",
"tabs": {
"appearance": "Wygląd",
- "language": "Język i region",
+ "language": "Język, region i czas",
"email": "Zachowanie poczty e-mail",
"composer": "Redagowanie",
"privacy": "Prywatność i bezpieczeństwo",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Wspólna skrzynka",
- "description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
+ "description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
+ "include_group": {
+ "label": "Uwzględnij skrzynki grupowe",
+ "description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
+ }
},
"colorful_sidebar_icons": {
"label": "Kolorowe ikony paska bocznego",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Język i region",
- "description": "Skonfiguruj preferencje językowe i regionalne",
+ "title": "Język, region i czas",
+ "description": "Język, format daty, format godziny i inne preferencje regionalne",
"language": {
"label": "Język",
"description": "Wybierz preferowany język",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Format daty",
- "description": "Jak mają być wyświetlane daty",
- "regional": "Regionalny",
- "iso": "ISO 8601",
- "custom": "Niestandardowy"
+ "description": "Jak daty są wyświetlane na liście e-maili",
+ "smart": "Inteligentny (regionalny)",
+ "relative": "Względny (1 g temu, 2 d temu)",
+ "full": "Zawsze pełna data",
+ "preview_today": "Dzisiaj:",
+ "preview_this_week": "W tym tygodniu:",
+ "preview_older": "Starsze:"
},
"time_format": {
"label": "Format czasu",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 3dff895f..9d2f65bf 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Continuar editando",
"tabs": {
"appearance": "Aparência",
- "language": "Idioma e Região",
+ "language": "Idioma, Região e Hora",
"email": "Comportamento de E-mail",
"composer": "Editor",
"privacy": "Privacidade e Segurança",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Caixa de correio unificada",
- "description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
+ "description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
+ "include_group": {
+ "label": "Incluir caixas de grupo",
+ "description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
+ }
},
"colorful_sidebar_icons": {
"label": "Ícones coloridos na barra lateral",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Idioma e Região",
- "description": "Configure preferências de idioma e região",
+ "title": "Idioma, Região e Hora",
+ "description": "Idioma, formato de data, formato de hora e outras preferências regionais",
"language": {
"label": "Idioma",
"description": "Escolha seu idioma preferido",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Formato de Data",
- "description": "Como as datas devem ser exibidas",
- "regional": "Regional",
- "iso": "ISO 8601",
- "custom": "Personalizado"
+ "description": "Como as datas são mostradas na lista de e-mails",
+ "smart": "Inteligente (regional)",
+ "relative": "Relativo (há 1 h, há 2 d)",
+ "full": "Sempre data completa",
+ "preview_today": "Hoje:",
+ "preview_this_week": "Esta semana:",
+ "preview_older": "Mais antigo:"
},
"time_format": {
"label": "Formato de Hora",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 1411ecd2..a2205d4d 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Продолжить редактирование",
"tabs": {
"appearance": "Внешний вид",
- "language": "Язык и регион",
+ "language": "Язык, регион и время",
"email": "Поведение почты",
"composer": "Редактор",
"privacy": "Конфиденциальность и безопасность",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Общий почтовый ящик",
- "description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
+ "description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
+ "include_group": {
+ "label": "Включать групповые ящики",
+ "description": "Также объединять общие/групповые ящики в едином представлении."
+ }
},
"colorful_sidebar_icons": {
"label": "Цветные значки боковой панели",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Язык и регион",
- "description": "Настройте языковые и региональные предпочтения",
+ "title": "Язык, регион и время",
+ "description": "Язык, формат даты, формат времени и другие региональные настройки",
"language": {
"label": "Язык",
"description": "Выберите предпочтительный язык",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Формат даты",
- "description": "Как отображать даты",
- "regional": "Региональный",
- "iso": "ISO 8601",
- "custom": "Пользовательский"
+ "description": "Как отображаются даты в списке писем",
+ "smart": "Умный (по региону)",
+ "relative": "Относительный (1 ч назад, 2 д назад)",
+ "full": "Всегда полная дата",
+ "preview_today": "Сегодня:",
+ "preview_this_week": "На этой неделе:",
+ "preview_older": "Старее:"
},
"time_format": {
"label": "Формат времени",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index b5353c79..53b49aeb 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Düzenlemeye devam et",
"tabs": {
"appearance": "Görünüm",
- "language": "Dil ve Bölge",
+ "language": "Dil, Bölge ve Saat",
"email": "E-posta Davranışı",
"composer": "Yazıcı",
"privacy": "Gizlilik ve Güvenlik",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Birleşik Posta Kutusu",
- "description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster"
+ "description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
+ "include_group": {
+ "label": "Grup gelen kutularını dahil et",
+ "description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
+ }
},
"colorful_sidebar_icons": {
"label": "Renkli Kenar Çubuğu Simgeleri",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Dil ve Bölge",
- "description": "Dil ve bölgesel tercihleri yapılandırın",
+ "title": "Dil, Bölge ve Saat",
+ "description": "Dil, tarih biçimi, saat biçimi ve diğer bölgesel tercihler",
"language": {
"label": "Dil",
"description": "Tercih ettiğiniz dili seçin",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Tarih Biçimi",
- "description": "Tarihlerin nasıl görüntüleneceği",
- "regional": "Bölgesel",
- "iso": "ISO 8601",
- "custom": "Özel"
+ "description": "Tarihlerin e-posta listesinde nasıl gösterileceği",
+ "smart": "Akıllı (bölgeye duyarlı)",
+ "relative": "Göreceli (1 sa önce, 2 g önce)",
+ "full": "Her zaman tam tarih",
+ "preview_today": "Bugün:",
+ "preview_this_week": "Bu hafta:",
+ "preview_older": "Daha eski:"
},
"time_format": {
"label": "Saat Biçimi",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index a1863b4e..5ee0ba1a 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -784,7 +784,7 @@
"keep_editing": "Продовжуйте редагувати",
"tabs": {
"appearance": "Зовнішній вигляд",
- "language": "Мова та регіон",
+ "language": "Мова, регіон і час",
"email": "Поведінка електронної пошти",
"composer": "Композитор",
"privacy": "Конфіденційність і безпека",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "Спільна поштова скринька",
- "description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
+ "description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
+ "include_group": {
+ "label": "Включати групові скриньки",
+ "description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
+ }
},
"colorful_sidebar_icons": {
"label": "Кольорові значки бічної панелі",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "Мова та регіон",
- "description": "Налаштуйте мовні та регіональні параметри",
+ "title": "Мова, регіон і час",
+ "description": "Мова, формат дати, формат часу та інші регіональні налаштування",
"language": {
"label": "Мова",
"description": "Виберіть бажану мову",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "Формат дати",
- "description": "Як мають відображатися дати",
- "regional": "Регіональний",
- "iso": "ISO 8601",
- "custom": "Довільний"
+ "description": "Як відображаються дати у списку листів",
+ "smart": "Розумний (за регіоном)",
+ "relative": "Відносний (1 год тому, 2 дн тому)",
+ "full": "Завжди повна дата",
+ "preview_today": "Сьогодні:",
+ "preview_this_week": "Цього тижня:",
+ "preview_older": "Старіше:"
},
"time_format": {
"label": "Формат часу",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 3344df95..804bb0aa 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -784,7 +784,7 @@
"keep_editing": "继续编辑",
"tabs": {
"appearance": "外观",
- "language": "语言及地区",
+ "language": "语言、地区与时间",
"email": "邮件行为",
"composer": "邮件撰写",
"privacy": "隐私与安全",
@@ -876,7 +876,11 @@
},
"unified_mailbox": {
"label": "统一邮箱",
- "description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
+ "description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
+ "include_group": {
+ "label": "包含群组收件箱",
+ "description": "在统一视图中也合并共享/群组收件箱。"
+ }
},
"colorful_sidebar_icons": {
"label": "彩色侧边栏图标",
@@ -960,8 +964,8 @@
}
},
"language_region": {
- "title": "语言及地区",
- "description": "配置语言和区域首选项",
+ "title": "语言、地区与时间",
+ "description": "语言、日期格式、时间格式以及其他区域设置",
"language": {
"label": "语言",
"description": "选择您的首选语言",
@@ -970,10 +974,13 @@
},
"date_format": {
"label": "日期格式",
- "description": "日期应如何显示",
- "regional": "区域格式",
- "iso": "ISO 8601",
- "custom": "自定义"
+ "description": "电子邮件列表中日期的显示方式",
+ "smart": "智能(按地区)",
+ "relative": "相对时间(1 小时前、2 天前)",
+ "full": "始终完整日期",
+ "preview_today": "今天:",
+ "preview_this_week": "本周:",
+ "preview_older": "更早:"
},
"time_format": {
"label": "时间格式",
diff --git a/proxy.ts b/proxy.ts
index 814abbd5..26fc1dcb 100644
--- a/proxy.ts
+++ b/proxy.ts
@@ -97,8 +97,8 @@ export async function proxy(request: NextRequest) {
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
const frameSrc =
pluginFrameOrigins.length > 0
- ? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
- : `frame-src 'self'`;
+ ? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
+ : `frame-src 'self' blob:`;
const csp = [
`default-src 'self'`,
@@ -108,7 +108,7 @@ export async function proxy(request: NextRequest) {
`font-src 'self'`,
`connect-src ${connectSrc}`,
frameSrc,
- `object-src 'none'`,
+ `object-src 'self' blob:`,
`base-uri 'self'`,
`form-action 'self'`,
`frame-ancestors ${frameAncestors}`,
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 4bf38165..c0a28280 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -147,11 +147,16 @@ interface EmailStore {
* pass the active account's id explicitly (no `__default__` sentinel).
* `destMailboxId` is the raw JMAP id on the destination server (not the
* `accountId:mailboxId` namespace used for shared folders).
+ * `destJmapAccountId` overrides the destination client's primary account
+ * for the import — used when dropping into a delegated/shared mailbox that
+ * is owned by a different JMAP account but accessed through the same
+ * client (i.e. there is no separate connected client for the owner).
*/
crossAccountMoveEmails: (
emailIdsBySource: Map,
destAccountId: string,
destMailboxId: string,
+ destJmapAccountId?: string,
) => Promise;
searchEmails: (client: IJMAPClient, query: string) => Promise;
advancedSearch: (client: IJMAPClient) => Promise;
@@ -308,8 +313,16 @@ function resolveActionMailboxes(): Mailbox[] {
* fresh mailbox list so the helpers can resolve the role mailbox per account.
* Accounts whose mailbox fetch fails are skipped - the unified result will
* surface that in its per-account error map.
+ *
+ * When `includeGroup` is true, also emits one synthetic entry per shared
+ * owner account reachable through each logged-in client. The shared entries
+ * are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
+ * requests via `originalId` + owner accountId.
*/
-async function buildUnifiedAccountClients(): Promise {
+export async function buildUnifiedAccountClients(
+ opts: { includeGroup?: boolean } = {},
+): Promise {
+ const { includeGroup = false } = opts;
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
@@ -317,8 +330,31 @@ async function buildUnifiedAccountClients(): Promise {
const c = allClients.get(a.id);
if (!c) continue;
try {
- const mailboxes = await c.getMailboxes();
- built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
+ const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes();
+ const ownMailboxes = includeGroup
+ ? mailboxes.filter((m) => !m.isShared)
+ : mailboxes;
+ built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false });
+
+ if (includeGroup) {
+ const sharedByOwner = new Map();
+ for (const m of mailboxes) {
+ if (!m.isShared || !m.accountId || m.accountId === a.id) continue;
+ const list = sharedByOwner.get(m.accountId) ?? [];
+ list.push(m);
+ sharedByOwner.set(m.accountId, list);
+ }
+ for (const [ownerId, ownerMailboxes] of sharedByOwner) {
+ const label = ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId;
+ built.push({
+ accountId: ownerId,
+ accountLabel: label,
+ client: c,
+ mailboxes: ownerMailboxes,
+ isShared: true,
+ });
+ }
+ }
} catch {
/* skip account on mailbox fetch failure */
}
@@ -662,8 +698,9 @@ export const useEmailStore = create((set, get) => ({
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
+ const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const position = emails.length;
- const built = await buildUnifiedAccountClients();
+ const built = await buildUnifiedAccountClients({ includeGroup });
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
const result = hasFilters
@@ -1194,7 +1231,7 @@ export const useEmailStore = create((set, get) => ({
}
},
- crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
+ crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
if (emailIdsBySource.size === 0) return;
set({ isLoading: true, error: null });
try {
@@ -1227,7 +1264,7 @@ export const useEmailStore = create((set, get) => ({
}
const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record = { ...(full.keywords ?? {}) };
- await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
+ await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
await sourceClient.deleteEmail(emailId);
return emailId;
}),
@@ -1360,7 +1397,8 @@ export const useEmailStore = create((set, get) => ({
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
- const built = await buildUnifiedAccountClients();
+ const includeGroup = useSettingsStore.getState().includeGroupInUnified;
+ const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
@@ -1426,7 +1464,8 @@ export const useEmailStore = create((set, get) => ({
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && unifiedRole) {
- const built = await buildUnifiedAccountClients();
+ const includeGroup = useSettingsStore.getState().includeGroupInUnified;
+ const built = await buildUnifiedAccountClients({ includeGroup });
const result = await advancedSearchUnifiedEmails(
built,
unifiedRole,
diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts
index 8db0ef96..0178f370 100644
--- a/stores/pro-tab-store.ts
+++ b/stores/pro-tab-store.ts
@@ -353,6 +353,14 @@ export const useProTabStore = create()(
const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return;
+ // Mirror the moveTabToPane guard: never let a cross-pane reorder
+ // empty the main pane, which would otherwise leave the layout with
+ // a blank main pane next to a populated split pane.
+ if (dragged.paneId === 'main' && target.paneId !== 'main') {
+ const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== draggedId);
+ if (otherMainTabs.length === 0) return;
+ }
+
const next = state.tabs.filter((t) => t.id !== draggedId);
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const reassigned: ProTab = dragged.paneId === target.paneId
@@ -504,6 +512,15 @@ export const useProTabStore = create()(
state.loadedTabIds = [HOME_TAB.id];
return;
}
+ // Heal broken state where every tab ended up on the split pane:
+ // collapse the split so the surviving tabs return to main, otherwise
+ // the layout would render an empty main pane next to the split.
+ if (!state.tabs.some((t) => t.paneId === 'main')) {
+ state.tabs = state.tabs.map((t) => ({ ...t, paneId: 'main' as const }));
+ state.activeSplitTabId = null;
+ state.splitOrientation = null;
+ state.focusedPaneId = 'main';
+ }
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
}
diff --git a/stores/settings-store.ts b/stores/settings-store.ts
index 5a419dc3..bfc5010a 100644
--- a/stores/settings-store.ts
+++ b/stores/settings-store.ts
@@ -31,7 +31,7 @@ export type ListDensity = Density;
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';
+export type DateFormat = 'smart' | 'relative' | 'full';
export type TimeFormat = '12h' | '24h';
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
@@ -204,6 +204,7 @@ interface SettingsState {
// Unified Mailbox
enableUnifiedMailbox: boolean;
+ includeGroupInUnified: boolean;
// Email Display
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
@@ -301,7 +302,7 @@ const DEFAULT_SETTINGS = {
animationsEnabled: true,
// Language & Region
- dateFormat: 'regional' as DateFormat,
+ dateFormat: 'smart' as DateFormat,
timeFormat: '24h' as TimeFormat,
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
@@ -378,6 +379,7 @@ const DEFAULT_SETTINGS = {
// Unified Mailbox
enableUnifiedMailbox: false,
+ includeGroupInUnified: false,
// Email Display
disableThreading: false,
@@ -547,6 +549,7 @@ export const useSettingsStore = create()(
// proInterface is intentionally omitted - it's a per-device choice
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
enableUnifiedMailbox: state.enableUnifiedMailbox,
+ includeGroupInUnified: state.includeGroupInUnified,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
colorfulSidebarIcons: state.colorfulSidebarIcons,
@@ -765,7 +768,7 @@ export const useSettingsStore = create()(
}),
{
name: 'settings-storage',
- version: 3,
+ version: 4,
migrate: (persisted, version) => {
const state = persisted as Record;
if (version < 2 && state.listDensity) {
@@ -779,6 +782,12 @@ export const useSettingsStore = create()(
state.protocolOpenMode = state.protocolMailtoOpenMode;
}
delete state.protocolMailtoOpenMode;
+ // v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to
+ // 'smart'|'relative'|'full'. The old setting was never read anywhere,
+ // so every persisted value maps to the new default.
+ if (version < 4) {
+ state.dateFormat = 'smart';
+ }
return state as unknown as SettingsState;
},
onRehydrateStorage: () => {