Merge branch 'main' into feature/scheduled-send

# Conflicts:
#	app/(main)/[locale]/page.tsx
#	components/layout/sidebar.tsx
#	stores/email-store.ts
#	stores/settings-store.ts
This commit is contained in:
Lucas Gaitzsch
2026-05-22 12:31:06 +02:00
155 changed files with 4702 additions and 869 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ import { createContext, useContext } from "react";
* read this to hide their own NavigationRail and let the shell own the
* chrome.
*
* Provided via context by the Pro shell no URL coupling, no iframe.
* Provided via context by the Pro shell - no URL coupling, no iframe.
*/
export const EmbeddedContext = createContext<boolean>(false);
+79 -13
View File
@@ -1,13 +1,47 @@
"use client";
import { useCallback, useState, DragEvent } from "react";
import { Mailbox } from "@/lib/jmap/types";
import { Mailbox, Email } from "@/lib/jmap/types";
import { useEmailStore } from "@/stores/email-store";
import { useAuthStore } from "@/stores/auth-store";
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { toast } from "@/stores/toast-store";
import { getMailboxPath } from "@/lib/utils";
/**
* Returns the source accountId for an email being dragged. In unified view
* each email carries its own `accountId`; otherwise everything in the view
* belongs to whichever account is currently being viewed (Pro shell's
* Thunderbird-style sidebar) or the globally-active account.
*/
function resolveSourceAccountId(email: Email | undefined): string | null {
if (email?.accountId) return email.accountId;
const viewingId = useEmailStore.getState().viewingAccountId;
if (viewingId) return viewingId;
return useAuthStore.getState().activeAccountId;
}
/**
* Returns the local accountId ("user@host") that owns the destination
* mailbox. `mailbox.accountId` is the JMAP server's opaque account id, but
* `clients`, `activeAccountId`, and `email.accountId` all live in the local
* namespace. We map back by matching the JMAP id against each connected
* client's `getAccountId()`. Falls back to the viewing/active account so
* single-account flows (no connected clients map entry yet, in-memory edits,
* etc.) still resolve correctly.
*/
function resolveDestAccountId(mailbox: Mailbox): string | null {
const jmapId = mailbox.accountId;
if (jmapId) {
const clients = useAuthStore.getState().getAllConnectedClients();
for (const [localId, client] of clients) {
if (client.getAccountId() === jmapId) return localId;
}
}
return useEmailStore.getState().viewingAccountId
?? useAuthStore.getState().activeAccountId;
}
interface UseMailboxDropOptions {
mailbox: Mailbox;
onDropComplete?: () => void;
@@ -31,7 +65,7 @@ interface UseMailboxDropReturn {
export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }: UseMailboxDropOptions): UseMailboxDropReturn {
const [isOver, setIsOver] = useState(false);
const { client } = useAuthStore();
const { moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { moveEmailsToMailbox, crossAccountMoveEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, mailboxes } = useEmailStore();
const { isDragging, sourceMailboxId, draggedEmails, endDrag } = useDragDropContext();
// Determine if this is a valid drop target
@@ -47,13 +81,13 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
// Virtual nodes (shared folder headers) cannot be drop targets
if (mailbox.id.startsWith("shared-")) return false;
// For shared mailboxes, check account compatibility
// 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]) {
// Get the source mailbox's account ID from the store
const mailboxes = useEmailStore.getState().mailboxes;
const sourceMb = mailboxes.find(mb => mb.id === sourceMailboxId);
// Cross-account moves are not supported
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
if (sourceMb?.accountId !== mailbox.accountId) {
return false;
}
@@ -107,16 +141,48 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
const emailIds: string[] = JSON.parse(emailIdsJson);
// Move in a single bulk JMAP request (store handles counter updates).
await moveEmailsToMailbox(client, emailIds, mailbox.id);
// Group dragged emails by source account. In single-account flows this
// collapses to one bucket; in unified view or the Pro multi-account
// sidebar a single drag can mix sources.
const destAccountId = resolveDestAccountId(mailbox);
const idToEmail = new Map(draggedEmails.map((em) => [em.id, em]));
const bySource = new Map<string, string[]>();
for (const id of emailIds) {
const srcAccountId = resolveSourceAccountId(idToEmail.get(id));
if (!srcAccountId) continue;
if (!bySource.has(srcAccountId)) bySource.set(srcAccountId, []);
bySource.get(srcAccountId)!.push(id);
}
const sourceAccountIds = Array.from(bySource.keys());
const isCrossAccount =
!!destAccountId &&
!mailbox.isShared &&
sourceAccountIds.some((src) => src !== destAccountId);
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.
const jmapDestId = mailbox.originalId || mailbox.id;
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
} else {
// Single-account or same-account-shared move: bulk JMAP request.
await moveEmailsToMailbox(client, emailIds, mailbox.id);
}
// Clear selection if any selected emails were moved
if (emailIds.some(id => selectedEmailIds.has(id))) {
clearSelection();
}
// Refresh the current mailbox view (honors active search/filters)
await refreshCurrentMailbox(client);
// Refresh the current mailbox view (honors active search/filters).
// Skip for cross-account moves: the store already dropped the moved
// rows from the in-memory list and refreshed both accounts' folder
// caches in the background.
if (!isCrossAccount) {
await refreshCurrentMailbox(client);
}
const mailboxPath = getMailboxPath(mailbox, mailboxes);
@@ -144,7 +210,7 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
} finally {
endDrag();
}
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
}, [client, mailbox, mailboxes, isValidTarget, moveEmailsToMailbox, crossAccountMoveEmails, draggedEmails, selectedEmailIds, clearSelection, refreshCurrentMailbox, endDrag, onDropComplete, onSuccess, onError]);
const valid = isValidTarget();
+2 -2
View File
@@ -42,7 +42,7 @@ export function useMediaQuery(query: string): boolean {
/**
* When the Pro shell renders a page inside a (possibly split) pane, that pane
* publishes its measured width via `PaneSizeContext`. Inner pages should
* branch their layout against the pane width not the full viewport so a
* branch their layout against the pane width - not the full viewport - so a
* narrow pane gets the mobile/tablet layout instead of overflowing.
*
* Returns `null` when no pane size is published, signalling the caller to
@@ -63,7 +63,7 @@ function classifyPane(paneWidth: number | null) {
*
* When invoked inside a Pro pane, the returned values reflect the pane's
* width instead of the window's. The global UI store is NOT updated in that
* case two split panes would otherwise fight to write conflicting values,
* case - two split panes would otherwise fight to write conflicting values,
* and the store is meant to mirror the actual viewport for callers that read
* it directly (mobile navigation helpers etc.).
*/
+1 -1
View File
@@ -4,7 +4,7 @@ import { createContext, useContext } from "react";
/**
* Width of the pane that's hosting the current subtree, in CSS pixels.
* `null` means "no pane is providing a size" fall back to viewport-based
* `null` means "no pane is providing a size" - fall back to viewport-based
* media queries. Set by the Pro shell on each split pane via ResizeObserver.
*/
export const PaneSizeContext = createContext<number | null>(null);
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useMemo } from "react";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useCalendarStore, type CalendarAccountClient } from "@/stores/calendar-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
/**
* When the Pro shell is the active interface, aggregate calendars from
* every connected account so the calendar sidebar lists them all - the
* same way [[use-pro-multi-account-mailboxes]] does for mail folders.
*
* Returns the resolved list of `{ localAccountId, client }` pairs so the
* caller (calendar page) can fetch events the same way without
* re-deriving the set.
*/
export function useProMultiAccountCalendars(start: string | null, end: string | null): {
enabled: boolean;
accountClients: CalendarAccountClient[];
} {
const isEmbedded = useIsEmbedded();
const proInterface = useSettingsStore((s) => s.proInterface);
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const fetchAllAccountsCalendars = useCalendarStore((s) => s.fetchAllAccountsCalendars);
const fetchAllAccountsEvents = useCalendarStore((s) => s.fetchAllAccountsEvents);
const enabled = proInterface || isEmbedded;
const accountClients = useMemo(() => {
if (!enabled) return [];
const getClientForAccount = useAuthStore.getState().getClientForAccount;
const pairs: CalendarAccountClient[] = [];
for (const account of accounts) {
if (!account.isConnected) continue;
const client = getClientForAccount(account.id);
if (!client || !client.supportsCalendars()) continue;
pairs.push({ localAccountId: account.id, client });
}
return pairs;
// accounts identity changes whenever the connected set or login states
// change, so this is the only dependency we need.
}, [enabled, accounts]);
// Fetch calendars whenever the set of connected calendar-capable accounts
// changes. Skips when there isn't an active account yet (auth still
// bootstrapping).
useEffect(() => {
if (!enabled || !activeAccountId || accountClients.length === 0) return;
void fetchAllAccountsCalendars(accountClients, activeAccountId);
}, [enabled, activeAccountId, accountClients, fetchAllAccountsCalendars]);
// Fetch events for the current visible date range across all accounts.
useEffect(() => {
if (!enabled || !activeAccountId || accountClients.length === 0) return;
if (!start || !end) return;
void fetchAllAccountsEvents(accountClients, activeAccountId, start, end);
}, [enabled, activeAccountId, accountClients, start, end, fetchAllAccountsEvents]);
return { enabled, accountClients };
}
+48
View File
@@ -0,0 +1,48 @@
"use client";
import { useEffect, useMemo } from "react";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useContactStore, type ContactAccountClient } from "@/stores/contact-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
/**
* Pro-shell counterpart to [[useProMultiAccountCalendars]] - aggregates
* contacts and address books from every connected JMAP account so the
* contacts sidebar lists them all, grouped by local account.
*/
export function useProMultiAccountContacts(): {
enabled: boolean;
accountClients: ContactAccountClient[];
} {
const isEmbedded = useIsEmbedded();
const proInterface = useSettingsStore((s) => s.proInterface);
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const fetchAllAccountsAddressBooks = useContactStore((s) => s.fetchAllAccountsAddressBooks);
const fetchAllAccountsContacts = useContactStore((s) => s.fetchAllAccountsContacts);
const enabled = proInterface || isEmbedded;
const accountClients = useMemo(() => {
if (!enabled) return [];
const getClientForAccount = useAuthStore.getState().getClientForAccount;
const pairs: ContactAccountClient[] = [];
for (const account of accounts) {
if (!account.isConnected) continue;
const client = getClientForAccount(account.id);
if (!client || !client.supportsContacts()) continue;
pairs.push({ localAccountId: account.id, client });
}
return pairs;
}, [enabled, accounts]);
useEffect(() => {
if (!enabled || !activeAccountId || accountClients.length === 0) return;
void fetchAllAccountsAddressBooks(accountClients, activeAccountId);
void fetchAllAccountsContacts(accountClients, activeAccountId);
}, [enabled, activeAccountId, accountClients, fetchAllAccountsAddressBooks, fetchAllAccountsContacts]);
return { enabled, accountClients };
}
+133
View File
@@ -0,0 +1,133 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import type { Identity } from "@/lib/jmap/types";
interface AccountIdentityGroup {
localAccountId: string;
accountLabel: string;
identities: Identity[];
}
const CROSS_ACCOUNT_IDENTITY_DELIMITER = '::';
/** Cross-account identity IDs are namespaced to avoid collisions between
* JMAP servers that happen to issue the same opaque ID. The active
* account's IDs are left untouched so existing single-account code paths
* (reply-identity resolution, S/MIME bindings) keep working unchanged.
*/
export function isCrossAccountIdentityId(id: string): boolean {
return id.includes(CROSS_ACCOUNT_IDENTITY_DELIMITER);
}
export function stripCrossAccountIdentityPrefix(id: string): { localAccountId: string | null; rawId: string } {
const idx = id.indexOf(CROSS_ACCOUNT_IDENTITY_DELIMITER);
if (idx < 0) return { localAccountId: null, rawId: id };
return {
localAccountId: id.slice(0, idx),
rawId: id.slice(idx + CROSS_ACCOUNT_IDENTITY_DELIMITER.length),
};
}
/**
* Pro shell only: load identities from every connected account and group
* them by local account so the composer's From dropdown can render an
* <optgroup> per account - mirrors [[useProMultiAccountCalendars]] and
* [[useProMultiAccountContacts]].
*
* Outside Pro / embedded mode the hook returns `enabled: false` and the
* caller falls back to the active account's identities from
* [[useIdentityStore]].
*/
export function useProMultiAccountIdentities(): {
enabled: boolean;
groups: AccountIdentityGroup[];
/** Flat list across all accounts, useful for lookup-by-id. */
allIdentities: Identity[];
} {
const isEmbedded = useIsEmbedded();
const proInterface = useSettingsStore((s) => s.proInterface);
const accounts = useAccountStore((s) => s.accounts);
const activeAccountId = useAuthStore((s) => s.activeAccountId);
const activeIdentities = useIdentityStore((s) => s.identities);
const enabled = (proInterface || isEmbedded) && accounts.filter(a => a.isConnected).length > 1;
const [remoteIdentities, setRemoteIdentities] = useState<Record<string, Identity[]>>({});
// Cache identities fetched per non-active account. Active account's
// identities come live from useIdentityStore so signature/alias edits
// there are reflected immediately without an extra round-trip.
useEffect(() => {
if (!enabled) {
setRemoteIdentities({});
return;
}
let cancelled = false;
const getClientForAccount = useAuthStore.getState().getClientForAccount;
(async () => {
const next: Record<string, Identity[]> = {};
await Promise.all(
accounts
.filter((a) => a.isConnected && a.id !== activeAccountId)
.map(async (account) => {
const client = getClientForAccount(account.id);
if (!client) return;
try {
const list = await client.getIdentities();
if (!cancelled) next[account.id] = list;
} catch {
// Skip accounts that fail to load identities - one bad
// account shouldn't blank the whole dropdown.
}
}),
);
if (!cancelled) setRemoteIdentities(next);
})();
return () => { cancelled = true; };
}, [enabled, accounts, activeAccountId]);
const groups = useMemo<AccountIdentityGroup[]>(() => {
if (!enabled) return [];
const out: AccountIdentityGroup[] = [];
if (activeAccountId) {
const active = accounts.find((a) => a.id === activeAccountId);
const label = active?.label || active?.email || active?.username || activeAccountId;
out.push({
localAccountId: activeAccountId,
accountLabel: label,
identities: activeIdentities.map((id) => ({
...id,
localAccountId: activeAccountId,
accountName: label,
})),
});
}
for (const account of accounts) {
if (!account.isConnected || account.id === activeAccountId) continue;
const list = remoteIdentities[account.id];
if (!list || list.length === 0) continue;
const label = account.label || account.email || account.username;
out.push({
localAccountId: account.id,
accountLabel: label,
identities: list.map((id) => ({
...id,
id: `${account.id}${CROSS_ACCOUNT_IDENTITY_DELIMITER}${id.id}`,
localAccountId: account.id,
accountName: label,
})),
});
}
return out;
}, [enabled, accounts, activeAccountId, activeIdentities, remoteIdentities]);
const allIdentities = useMemo(() => groups.flatMap((g) => g.identities), [groups]);
return { enabled, groups, allIdentities };
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { useEffect } from "react";
import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
/**
* Keeps `useEmailStore.accountMailboxes` populated with one entry per
* connected account while the Pro shell is the active interface. The Pro
* sidebar reads this cache to render a Thunderbird-style per-account folder
* tree (see [[project_pro_mode]]). Outside Pro the cache stays empty.
*
* Refetches whenever the set of connected accounts changes, so adding or
* removing an account in another tab is reflected without a reload.
*/
export function useProMultiAccountMailboxes(): void {
const isEmbedded = useIsEmbedded();
const proInterface = useSettingsStore((s) => s.proInterface);
const accounts = useAccountStore((s) => s.accounts);
useEffect(() => {
if (!proInterface && !isEmbedded) return;
const connected = accounts.filter((a) => a.isConnected);
if (connected.length === 0) return;
const fetchAccountMailboxes = useEmailStore.getState().fetchAccountMailboxes;
const getClientForAccount = useAuthStore.getState().getClientForAccount;
for (const account of connected) {
const client = getClientForAccount(account.id);
if (!client) continue;
void fetchAccountMailboxes(client, account.id);
}
}, [proInterface, isEmbedded, accounts]);
}