@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
)}
{logoSrc && (

diff --git a/components/settings/files-settings.tsx b/components/settings/files-settings.tsx
index 1bec3ddd..c686684a 100644
--- a/components/settings/files-settings.tsx
+++ b/components/settings/files-settings.tsx
@@ -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 ? (
-

+
})
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "sm")
) : null}
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
)}
>
{settings.showThumbnails && file.thumbnailUrl ? (
-

+
})
) : settings.showIcons ? (
getPreviewIcon(file, settings.coloredIcons, "lg")
) : (
diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx
index 13ac7c20..315aa53e 100644
--- a/components/ui/avatar.tsx
+++ b/components/ui/avatar.tsx
@@ -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;
diff --git a/lib/browser-navigation.ts b/lib/browser-navigation.ts
index 5df6ff91..15369273 100644
--- a/lib/browser-navigation.ts
+++ b/lib/browser-navigation.ts
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise
{
return fetch(input, init);
}
+/**
+ * Mount-prefix-aware wrapper for URL strings used in `
`, ``,
+ * `window.location.*`, etc. — anything the browser resolves itself, where
+ * `apiFetch` can't help.
+ *
+ * Idempotent: passing an already-prefixed value, an external URL, a
+ * protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
+ * safe to wrap admin-configurable values that might be either a local path
+ * (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
+ */
+export function withBasePath(url: string | null | undefined): string {
+ if (!url) return url ?? '';
+ if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
+ if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
+ const prefix = getPathPrefix();
+ if (!prefix) return url;
+ if (url === prefix || url.startsWith(prefix + '/')) return url;
+ return prefix + url;
+}
+
/**
* Extracts the locale from the current URL, skipping any mount prefix.
diff --git a/lib/webdav/client.ts b/lib/webdav/client.ts
index 2772d37d..05486798 100644
--- a/lib/webdav/client.ts
+++ b/lib/webdav/client.ts
@@ -4,6 +4,7 @@
*/
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
+import { apiFetch, withBasePath } from '@/lib/browser-navigation';
export interface WebDAVResource {
href: string;
@@ -32,7 +33,7 @@ export class WebDAVClient {
...options?.headers,
};
- return fetch(this.proxyUrl, {
+ return apiFetch(this.proxyUrl, {
method: 'POST',
headers,
body: options?.body,
@@ -118,7 +119,7 @@ export class WebDAVClient {
// Use XMLHttpRequest for progress tracking
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
- xhr.open('POST', this.proxyUrl);
+ xhr.open('POST', withBasePath(this.proxyUrl));
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
xhr.setRequestHeader('X-WebDAV-Path', path);
const slotHeaders = getActiveAccountSlotHeaders();
From 956acb69cee39f3a5885d6dd1ba2b7ea242e1ec6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 17:01:52 +0200
Subject: [PATCH 07/13] feat: add NEXT_PUBLIC_DEFAULT_LOCALE for fallback UI
locale #243
---
.env.example | 16 ++++++++++++++++
Dockerfile | 5 +++++
i18n/routing.ts | 16 ++++++++++++++--
3 files changed, 35 insertions(+), 2 deletions(-)
diff --git a/.env.example b/.env.example
index 99da0cb0..5f21140e 100644
--- a/.env.example
+++ b/.env.example
@@ -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)
# =============================================================================
diff --git a/Dockerfile b/Dockerfile
index 8e3a3e68..2b7c21ae 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -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
diff --git a/i18n/routing.ts b/i18n/routing.ts
index 8bea497e..ad420514 100644
--- a/i18n/routing.ts
+++ b/i18n/routing.ts
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
| 'always'
| 'as-needed';
+const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
+
+// Fallback locale used when the visitor's Accept-Language header does not
+// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
+// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
+// deployments without having every user change their preference manually.
+const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
+const resolvedDefaultLocale =
+ envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
+ ? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
+ : 'en';
+
export const routing = defineRouting({
- locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
- defaultLocale: 'en',
+ locales: SUPPORTED_LOCALES,
+ defaultLocale: resolvedDefaultLocale,
localePrefix
});
From 3c0faba837bcf24bcf843a38b4f70c08d9a3ee04 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 18:05:01 +0200
Subject: [PATCH 08/13] fix: collapse focus mail layout to multi-line on mobile
---
components/email/email-list-item.tsx | 6 +++---
components/email/email-list.tsx | 5 ++++-
components/email/thread-list-item.tsx | 9 +++++----
3 files changed, 12 insertions(+), 8 deletions(-)
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx
index 2fdd4172..84813009 100644
--- a/components/email/email-list-item.tsx
+++ b/components/email/email-list-item.tsx
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
- const isFocusedMailLayout = mailLayout === 'focus';
+ const isMobile = useUIStore((state) => state.isMobile);
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
sourceMailboxId: selectedMailbox,
});
- const isMobile = useUIStore((state) => state.isMobile);
-
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx
index 17f11b3d..70245be7 100644
--- a/components/email/email-list.tsx
+++ b/components/email/email-list.tsx
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useSettingsStore } from "@/stores/settings-store";
+import { useUIStore } from "@/stores/ui-store";
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
@@ -107,7 +108,9 @@ 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 isMobile = useUIStore((state) => state.isMobile);
+ // Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const estimateSize = useCallback(() => {
if (isFocusedMailLayout) {
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx
index ca940644..d0e845c0 100644
--- a/components/email/thread-list-item.tsx
+++ b/components/email/thread-list-item.tsx
@@ -73,7 +73,9 @@ const SingleEmailItem = React.forwardRef(
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
- const isFocusedMailLayout = mailLayout === 'focus';
+ const isMobile = useUIStore((state) => state.isMobile);
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
@@ -91,8 +93,6 @@ const SingleEmailItem = React.forwardRef(
sourceMailboxId: selectedMailbox,
});
- const isMobile = useUIStore((state) => state.isMobile);
-
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
useCallback((pos) => {
onContextMenu?.(
@@ -377,7 +377,8 @@ export const ThreadListItem = React.forwardRef state.showAvatarsInJunk);
const isMobile = useUIStore((state) => state.isMobile);
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
- const isFocusedMailLayout = mailLayout === 'focus';
+ // The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
+ const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
From 62ebe443f9ba448ddeb062ba56aceb749822da7b Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 18:13:57 +0200
Subject: [PATCH 09/13] fix: align continued multi-week events with week's left
edge
---
components/calendar/event-card.tsx | 1 -
1 file changed, 1 deletion(-)
diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx
index f797f0ea..4886324c 100644
--- a/components/calendar/event-card.tsx
+++ b/components/calendar/event-card.tsx
@@ -157,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
"hover:opacity-90 transition-opacity cursor-pointer",
continuesAfter && "rounded-r-sm",
- continuesBefore && "-ml-0.5",
continuesAfter && "pr-2",
isSelected && "ring-2 ring-primary",
isBeingDragged && "opacity-50",
From f8e7cce85ac69476bb945895651e1994925806b6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 18:25:55 +0200
Subject: [PATCH 10/13] fix: prevent empty main pane when reordering tabs
across panes
---
stores/pro-tab-store.ts | 17 +++++++++++++++++
1 file changed, 17 insertions(+)
diff --git a/stores/pro-tab-store.ts b/stores/pro-tab-store.ts
index 8db0ef96..0178f370 100644
--- a/stores/pro-tab-store.ts
+++ b/stores/pro-tab-store.ts
@@ -353,6 +353,14 @@ export const useProTabStore = create()(
const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return;
+ // Mirror the moveTabToPane guard: never let a cross-pane reorder
+ // empty the main pane, which would otherwise leave the layout with
+ // a blank main pane next to a populated split pane.
+ if (dragged.paneId === 'main' && target.paneId !== 'main') {
+ const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== draggedId);
+ if (otherMainTabs.length === 0) return;
+ }
+
const next = state.tabs.filter((t) => t.id !== draggedId);
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const reassigned: ProTab = dragged.paneId === target.paneId
@@ -504,6 +512,15 @@ export const useProTabStore = create()(
state.loadedTabIds = [HOME_TAB.id];
return;
}
+ // Heal broken state where every tab ended up on the split pane:
+ // collapse the split so the surviving tabs return to main, otherwise
+ // the layout would render an empty main pane next to the split.
+ if (!state.tabs.some((t) => t.paneId === 'main')) {
+ state.tabs = state.tabs.map((t) => ({ ...t, paneId: 'main' as const }));
+ state.activeSplitTabId = null;
+ state.splitOrientation = null;
+ state.focusedPaneId = 'main';
+ }
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
}
From 3a8daf8bff9d9a562f5e25eb0aece76a9e6508c0 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 18:53:54 +0200
Subject: [PATCH 11/13] feat: allow drag-and-drop into shared mailboxes
---
hooks/use-mailbox-drop.ts | 46 +++++++++++++++++++++---------------
lib/jmap/client-interface.ts | 2 +-
lib/jmap/client.ts | 24 +++++++++++++------
stores/email-store.ts | 9 +++++--
4 files changed, 52 insertions(+), 29 deletions(-)
diff --git a/hooks/use-mailbox-drop.ts b/hooks/use-mailbox-drop.ts
index 32026c7f..39bd7608 100644
--- a/hooks/use-mailbox-drop.ts
+++ b/hooks/use-mailbox-drop.ts
@@ -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) => {
e.preventDefault();
@@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
bySource.get(srcAccountId)!.push(id);
}
+ // Decide whether to route via the cross-account (blob copy + import)
+ // pipeline. Two cases require it:
+ // 1. Destination is a delegated/shared mailbox whose owner JMAP
+ // account differs from the source mailbox's JMAP account. There's
+ // no atomic Email/set across accounts, even via the same client.
+ // 2. Destination is a primary mailbox on a different connected local
+ // account than the source — the historical multi-account case.
+ const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
+ const sourceJmapAccountId = sourceMb?.accountId;
+ const destJmapAccountId = mailbox.accountId;
const sourceAccountIds = Array.from(bySource.keys());
- const isCrossAccount =
- !!destAccountId &&
+
+ const isJmapCrossAccount =
+ !!sourceJmapAccountId &&
+ !!destJmapAccountId &&
+ sourceJmapAccountId !== destJmapAccountId;
+ const isLocalCrossAccount =
!mailbox.isShared &&
+ !!destAccountId &&
sourceAccountIds.some((src) => src !== destAccountId);
+ const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
if (isCrossAccount) {
- // JMAP can't natively move an email between primary accounts, so the
- // store reuploads each source blob into the destination account and
- // then deletes the original.
+ if (!destAccountId) {
+ throw new Error('Could not resolve destination account');
+ }
+ // For a shared destination there is no separately-connected client
+ // for the owner; we reuse the viewing user's client but tell the
+ // import call to target the owner's JMAP account.
const jmapDestId = mailbox.originalId || mailbox.id;
- await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
+ const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
+ await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id);
diff --git a/lib/jmap/client-interface.ts b/lib/jmap/client-interface.ts
index 5ec9ec0e..1cfc8b56 100644
--- a/lib/jmap/client-interface.ts
+++ b/lib/jmap/client-interface.ts
@@ -274,7 +274,7 @@ export interface IJMAPClient {
copyFileNode(id: string, newName: string, parentId: string | null): Promise;
// ── S/MIME raw-email helpers ──────────────────────────────────
- importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record): Promise;
+ importRawEmail(blob: Blob, mailboxIds: Record, keywords?: Record, accountId?: string): Promise;
submitEmail(emailId: string, identityId: string): Promise;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string): Promise;
}
diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts
index ced1c366..03524cca 100644
--- a/lib/jmap/client.ts
+++ b/lib/jmap/client.ts
@@ -2788,7 +2788,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.');
}
@@ -2798,7 +2798,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' },
@@ -2828,7 +2829,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,
@@ -5302,20 +5303,29 @@ export class JMAPClient implements IJMAPClient {
return response.arrayBuffer();
}
- /** Import a raw MIME message blob into the account. */
+ /**
+ * Import a raw MIME message blob into the account. Pass `accountId` to
+ * target a delegated account the caller has rights on (e.g. importing into
+ * a shared mailbox owned by another user). When omitted, falls back to the
+ * client's own primary account.
+ */
async importRawEmail(
blob: Blob,
mailboxIds: Record,
keywords?: Record,
+ accountId?: string,
): Promise {
- // First upload the blob
+ const targetAccountId = accountId || this.accountId;
+ // First upload the blob. Blob uploads are scoped to an account too —
+ // when importing into a delegated account, upload there so the resulting
+ // blobId is visible to Email/import on that account.
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
- const { blobId } = await this.uploadBlob(file);
+ const { blobId } = await this.uploadBlob(file, targetAccountId);
// Then import via Email/import
const response = await this.request([
['Email/import', {
- accountId: this.accountId,
+ accountId: targetAccountId,
emails: {
'smime-import': {
blobId,
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 44ee2d00..4e7d7a86 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -126,11 +126,16 @@ interface EmailStore {
* pass the active account's id explicitly (no `__default__` sentinel).
* `destMailboxId` is the raw JMAP id on the destination server (not the
* `accountId:mailboxId` namespace used for shared folders).
+ * `destJmapAccountId` overrides the destination client's primary account
+ * for the import — used when dropping into a delegated/shared mailbox that
+ * is owned by a different JMAP account but accessed through the same
+ * client (i.e. there is no separate connected client for the owner).
*/
crossAccountMoveEmails: (
emailIdsBySource: Map,
destAccountId: string,
destMailboxId: string,
+ destJmapAccountId?: string,
) => Promise;
searchEmails: (client: IJMAPClient, query: string) => Promise;
advancedSearch: (client: IJMAPClient) => Promise;
@@ -1127,7 +1132,7 @@ export const useEmailStore = create((set, get) => ({
}
},
- crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
+ crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
if (emailIdsBySource.size === 0) return;
set({ isLoading: true, error: null });
try {
@@ -1160,7 +1165,7 @@ export const useEmailStore = create((set, get) => ({
}
const blob = await sourceClient.fetchBlob(full.blobId);
const keywords: Record = { ...(full.keywords ?? {}) };
- await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
+ await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
await sourceClient.deleteEmail(emailId);
return emailId;
}),
From ad80aa23ca4850f38d5b19558d88517bef6fae4e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Mon, 25 May 2026 19:07:27 +0200
Subject: [PATCH 12/13] fix: keep empty viewer pane visible in Pro split layout
---
app/(main)/[locale]/page.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx
index 1e1c601e..526a74f8 100644
--- a/app/(main)/[locale]/page.tsx
+++ b/app/(main)/[locale]/page.tsx
@@ -1954,7 +1954,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
From e86183b44aec65dc7e46b7a4cec5d8358def19a9 Mon Sep 17 00:00:00 2001
From: Chuyen Nguyen
Date: Tue, 26 May 2026 09:36:57 +0700
Subject: [PATCH 13/13] Fix bug where editing any field closed the form
---
.../admin/_tabs/_jmap-servers-section.tsx | 25 +++++--------------
1 file changed, 6 insertions(+), 19 deletions(-)
diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
index 69f6091d..638e9fee 100644
--- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx
+++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState(() => value.map(entryToDraft));
+ const lastEmittedRef = useRef(value);
useEffect(() => {
- // Re-sync from props when the underlying config value changes (e.g. revert,
- // initial load). Skip when drafts already represent the same array to avoid
- // clobbering in-progress edits.
- setDrafts((prev) => {
- if (prev.length === value.length) {
- const same = prev.every((d, i) => {
- const e = value[i];
- return d.id === e.id && d.url === e.url && d.label === e.label;
- });
- if (same) return prev;
- }
- return value.map(entryToDraft);
- });
+ if (value === lastEmittedRef.current) return;
+ setDrafts(value.map(entryToDraft))
}, [value]);
function commit(next: RowDraft[]) {
setDrafts(next);
- const entries: JmapServerEntry[] = [];
- for (const d of next) {
- const e = draftToEntry(d);
- if (e) entries.push(e);
- }
+ const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
+ lastEmittedRef.current = entries;
onChange(entries);
}