Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/email/email-list.tsx
#	components/email/email-viewer.tsx
This commit is contained in:
Lucas Gaitzsch
2026-05-26 18:30:45 +02:00
53 changed files with 864 additions and 358 deletions
+16
View File
@@ -234,6 +234,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
# your own directory (e.g. http://localhost:3001 for local development).
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
# =============================================================================
# Internationalization
# =============================================================================
# These are build-time variables - to change them with the published Docker
# image, rebuild it with --build-arg (see README "Default UI locale").
#
# Fallback UI locale used when the visitor's Accept-Language header does not
# match any supported locale. Defaults to "en".
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
# Locale prefix mode for URLs. Recommended "always" when proxying under a
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
# Values: never (default) | always | as-needed
# NEXT_PUBLIC_LOCALE_PREFIX=always
# =============================================================================
# Legacy Build-time Variables (still supported as fallback)
# =============================================================================
+5
View File
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
# at build time, so it cannot be changed without rebuilding.
ARG NEXT_PUBLIC_BASE_PATH=
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
# Accept-Language header does not match any supported locale. Baked in at
# build time because next-intl wires it into client-side routing too.
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
# `git rev-parse` inside the build can't find it - CI must pass it in.
ARG GIT_COMMIT=unknown
+3 -3
View File
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useThemeStore } from "@/stores/theme-store";
import { useShallow } from "zustand/react/shallow";
import { useConfig } from "@/hooks/use-config";
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
import { cn } from "@/lib/utils";
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
@@ -722,7 +722,7 @@ export default function LoginPage() {
<div className="px-8 pt-12 pb-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-20 max-h-20 object-contain"
/>
@@ -872,7 +872,7 @@ export default function LoginPage() {
<div className="px-8 pt-10 pb-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
<img
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
alt={appName}
className="max-w-16 max-h-16 object-contain"
/>
+17 -44
View File
@@ -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<UnifiedAccountClient[]> => {
return buildUnifiedAccountClients({
includeGroup: useSettingsStore.getState().includeGroupInUnified,
});
}, []);
const populateUnifiedAccountMailboxes = useCallback(
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
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=<id>` (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
@@ -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<RowDraft[]>(() => 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);
}
+3 -3
View File
@@ -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);
+2 -2
View File
@@ -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();
+2 -1
View File
@@ -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<Metadata> {
formatDetection: {
telephone: false,
},
icons: { icon: faviconUrl },
icons: { icon: withBasePath(faviconUrl) },
};
}
+2 -2
View File
@@ -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 ? (
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
)}
-1
View File
@@ -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",
+47 -11
View File
@@ -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({
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
{isMultiDay ? (
event.showWithoutTime ? (
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
</div>
<div className="font-medium text-foreground">
{formatEventDate(displayEndDate)}
</div>
<div className="text-muted-foreground">{t("events.all_day")}</div>
</>
) : (
<>
<div className="font-medium text-foreground">
{formatEventDate(startDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatTime(startDate)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endDate)}
<span className="ml-1.5 font-normal text-muted-foreground">
{formatTime(endDate)}
</span>
</div>
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durationMinutes)})
</div>
</>
)
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
<>
<span className="font-medium text-foreground">
{formatEventDate(startDate)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{formatTime(startDate)} {formatTime(endDate)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
</div>
)}
</>
)}
</div>
</div>
+86 -20
View File
@@ -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({
</div>
</div>
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
</div>
{(() => {
const displayEnd = getEventDisplayEndDate(event);
const multiDay = !isSameDay(startD, displayEnd);
if (multiDay && event.showWithoutTime) {
return (
<div className="text-sm">
<div className="font-medium">{formatEventDate(startD)} </div>
<div className="font-medium">{formatEventDate(displayEnd)}</div>
</div>
);
}
if (multiDay) {
return (
<div className="text-sm">
<div>
<span className="font-medium">{formatEventDate(startD)}</span>
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
</div>
<div>
<span className="font-medium">{formatEventDate(endD)}</span>
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
</div>
</div>
);
}
return (
<div className="text-sm">
<span className="font-medium">{formatEventDate(startD)}</span>
{!event.showWithoutTime && (
<span className="text-muted-foreground ml-2">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
</span>
)}
</div>
);
})()}
{event.description && (
<p className="text-sm text-muted-foreground">{event.description}</p>
@@ -742,17 +770,55 @@ export function EventModal({
<div className="flex items-start gap-2.5">
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
<div className="text-sm">
<span className="font-medium text-foreground">
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
{(() => {
const displayEnd = getEventDisplayEndDate(event);
const multiDay = !isSameDay(startD, displayEnd);
if (multiDay && event.showWithoutTime) {
return (
<>
<div className="font-medium text-foreground">{formatEventDate(startD)} </div>
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
<div className="text-muted-foreground">{t("events.all_day")}</div>
</>
);
}
if (multiDay) {
return (
<>
<div className="font-medium text-foreground">
{formatEventDate(startD)}
<span className="ml-1.5 font-normal text-muted-foreground">
{format(startD, timeDisplayFmt)}
</span>
</div>
<div className="font-medium text-foreground">
{formatEventDate(endD)}
<span className="ml-1.5 font-normal text-muted-foreground">
{format(endD, timeDisplayFmt)}
</span>
</div>
<div className="text-muted-foreground text-xs">
({formatDurationDisplay(durMin)})
</div>
</>
);
}
return (
<>
<span className="font-medium text-foreground">
{formatEventDate(startD)}
</span>
{event.showWithoutTime ? (
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
) : (
<div className="text-muted-foreground">
{format(startD, timeDisplayFmt)} {format(endD, timeDisplayFmt)}
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
</div>
)}
</>
);
})()}
</div>
</div>
+3 -3
View File
@@ -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?.(
+4 -1
View File
@@ -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) {
+9 -4
View File
@@ -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 (
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
<div className="text-center p-8 max-w-md">
@@ -5384,10 +5387,12 @@ export function EmailViewer({
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
<div className="flex-shrink-0">
<Avatar
name={currentUserName || "You"}
email={currentUserEmail || ""}
name={activeAccount?.displayName || currentUserName || "You"}
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
size="lg"
className="shadow-sm w-10 h-10"
disableFavicon
fallbackColor={activeAccount?.avatarColor}
/>
</div>
<div className="flex-1 min-w-0 space-y-3">
+5 -4
View File
@@ -74,7 +74,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, SingleEmailItemProps>(
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<HTMLDivElement, ThreadListItemPro
const showAvatarsInJunk = useSettingsStore((state) => 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
+2 -2
View File
@@ -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 ? (
<div className="flex items-center justify-center py-3 px-1">
<img
+10 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, ReactNode } from "react";
import { useState, useEffect, useMemo, ReactNode } from "react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
@@ -736,15 +736,20 @@ export function Sidebar({
// pane.
const hideAccountSwitcher = useSettingsStore(s => 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');
+4 -2
View File
@@ -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<void>;
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
return null;
}
const logoSrc = appLogoLightUrl || faviconUrl;
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
return (
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
)}
{logoSrc && (
<img
src={appLogoDarkUrl || faviconUrl}
src={darkLogoSrc}
alt={appName}
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
/>
+1 -26
View File
@@ -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() {
/>
</SettingItem>
<SettingItem label={t('week_starts_on')}>
<Select
value={firstDayOfWeek.toString()}
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
options={[
{ value: '1', label: tDays('monday') },
{ value: '0', label: tDays('sunday') },
]}
/>
</SettingItem>
<SettingItem label={t('time_format')}>
<RadioGroup
value={timeFormat}
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
options={[
{ value: '12h', label: t('time_format_12h') },
{ value: '24h', label: t('time_format_24h') },
]}
/>
</SettingItem>
<SettingItem
label={t('show_time_in_month_view')}
description={t('show_time_in_month_view_desc')}
+3 -2
View File
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
import { cn } from "@/lib/utils";
import { withBasePath } from "@/lib/browser-navigation";
interface SampleFile {
name: string;
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
+86 -3
View File
@@ -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 (
<SettingsSection title={t('language.label')} description={t('language.description')}>
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('language.label')} description={t('language.description')}>
<LanguageSwitcher />
</SettingItem>
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
<div className="flex flex-col items-end gap-2">
<Select
value={dateFormat}
onChange={(value) => 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') },
]}
/>
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
<div>
<span className="opacity-70">{t('date_format.preview_today')} </span>
<span className="text-foreground/90">{preview.today}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
<span className="text-foreground/90">{preview.thisWeek}</span>
</div>
<div>
<span className="opacity-70">{t('date_format.preview_older')} </span>
<span className="text-foreground/90">{preview.older}</span>
</div>
</div>
</div>
</SettingItem>
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
<RadioGroup
value={timeFormat}
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
options={[
{ value: '12h', label: t('time_format.12h') },
{ value: '24h', label: t('time_format.24h') },
]}
/>
</SettingItem>
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
<Select
value={firstDayOfWeek.toString()}
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
options={[
{ value: '1', label: tDays('monday') },
{ value: '0', label: tDays('sunday') },
]}
/>
</SettingItem>
</SettingsSection>
);
}
+22 -2
View File
@@ -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 (
<SettingsSection title={t('title')} description={t('description')}>
@@ -177,10 +181,11 @@ export function LayoutSettings() {
/>
</SettingItem>
{accounts.length > 1 && (
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
<SettingItem
label={t('unified_mailbox.label')}
description={t('unified_mailbox.description')}
locked={isSettingLocked('enableUnifiedMailbox')}
>
<ToggleSwitch
checked={enableUnifiedMailbox}
@@ -189,6 +194,21 @@ export function LayoutSettings() {
</SettingItem>
)}
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
<SettingItem
label={t('unified_mailbox.include_group.label')}
description={t('unified_mailbox.include_group.description')}
locked={isSettingLocked('includeGroupInUnified')}
>
<ToggleSwitch
checked={includeGroupInUnified}
onChange={(v) => updateSetting('includeGroupInUnified', v)}
/>
</SettingItem>
</div>
)}
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
<ToggleSwitch
checked={proInterface}
+2 -1
View File
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
import { useConfig } from "@/hooks/use-config";
import { avatarHooks } from "@/lib/plugin-hooks";
import { withBasePath } from "@/lib/browser-navigation";
const IS_DEV = process.env.NODE_ENV !== "production";
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
+27 -19
View File
@@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// Shared (delegated) mailboxes still require the source to belong to the
// same delegating account. Real cross-account moves between primary
// accounts go through the cross-account path further down, but the
// shared-folder semantics here are about ACLs rather than transport, so
// they remain disallowed.
if (mailbox.isShared && draggedEmails[0]) {
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
}
return true;
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
}, [isDragging, mailbox, sourceMailboxId]);
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
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);
+14 -2
View File
@@ -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
});
+20
View File
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
return fetch(input, init);
}
/**
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
* `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.
+1 -1
View File
@@ -283,6 +283,6 @@ export interface IJMAPClient {
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
// ── S/MIME raw-email helpers ──────────────────────────────────
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
submitEmail(emailId: string, identityId: string): Promise<void>;
}
+17 -7
View File
@@ -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<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
): Promise<string> {
// 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,
+32 -4
View File
@@ -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<UnifiedFetchResult> {
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<UnifiedFetchResult> {
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,
+76 -12
View File
@@ -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",
});
}
+3 -2
View File
@@ -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();
+15 -8
View File
@@ -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ál)",
"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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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": "時刻形式",
+15 -8
View File
@@ -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": "시간 형식",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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": "Формат времени",
+15 -8
View File
@@ -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",
+15 -8
View File
@@ -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": "Формат часу",
+15 -8
View File
@@ -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": "时间格式",
+3 -3
View File
@@ -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}`,
+47 -8
View File
@@ -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<string, string[]>,
destAccountId: string,
destMailboxId: string,
destJmapAccountId?: string,
) => Promise<void>;
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
advancedSearch: (client: IJMAPClient) => Promise<void>;
@@ -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<UnifiedAccountClient[]> {
export async function buildUnifiedAccountClients(
opts: { includeGroup?: boolean } = {},
): Promise<UnifiedAccountClient[]> {
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<UnifiedAccountClient[]> {
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<string, Mailbox[]>();
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<EmailStore>((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<EmailStore>((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<EmailStore>((set, get) => ({
}
const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record<string, boolean> = { ...(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<EmailStore>((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<EmailStore>((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,
+17
View File
@@ -353,6 +353,14 @@ export const useProTabStore = create<ProTabState>()(
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<ProTabState>()(
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;
}
+12 -3
View File
@@ -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<SettingsState>()(
// 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<SettingsState>()(
}),
{
name: 'settings-storage',
version: 3,
version: 4,
migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) {
@@ -779,6 +782,12 @@ export const useSettingsStore = create<SettingsState>()(
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: () => {