feat: cross-account "All accounts" views + full group/shared-account support

Add cross-account aggregate mail views and make group/shared (delegated)
accounts first-class in every aggregate view. (The unified mailbox, the "All
Mail" view, and "include group inboxes" already exist on main; this branch adds
the cross-account views and the shared-account correctness work.)

New views (admin-gated + per-user toggle, nested under Unified Mailbox):
- Cross-account "All accounts": All unread / All starred / All mail across every
  connected account, including shared/group folders. Each list labels the source
  folder of every message.

Source reference on aggregated emails (the core of the shared-account work):
- Replace the overloaded `accountId` with two explicit, always-set fields:
  `sourceClientAccountId` (the login the mail is reachable through) and
  `sourceAccountId` (the owning JMAP account). `accountId` stays display-only.
- Resolution is branch-free everywhere: pick the client by sourceClientAccountId,
  pass sourceAccountId as the JMAP accountId (no-op for personal), read the
  owner's mailbox list cached by JMAP id. No capability scan.

Shared/group-account correctness across all aggregate views:
- Route open (click + auto-fetch), thread/conversation open + reply-refresh,
  mark read, star, move, delete (account-scoped trash), archive (owner-routed
  createMailbox / fetchAccountMailboxes), and spam + undo via the source ref.
- Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails /
  createMailbox where missing.
- Fix local unread/total counter math for shared folders via emailInMailbox()
  (matches namespaced shared ids and bare own ids).
- Keep the unified/cross virtual selection on background mailbox refresh (no
  jump back to inbox after deleting in All Drafts/Junk).

Junk UX:
- In "All Junk" the spam action becomes "not spam" in the viewer, context menu,
  and list hover icons; undo routes shared mail back to its own inbox.

Admin:
- Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching
  per-user toggle (allMailViewEnabled clarified too).

i18n / docs / tests:
- locales (19): cross-view labels + descriptions and hover not_spam, translated
  in all shipped languages.
- FEATURES.md + README.md document the new views and group-account support.
- Tests for shared-account routing (single + batch + undoSpam), decoration, and
  unified-selection preservation.
This commit is contained in:
Stefan Hildebrandt
2026-06-23 19:16:22 +02:00
parent 3dd596ba50
commit a29c33b50a
41 changed files with 1491 additions and 226 deletions
+5 -1
View File
@@ -4,7 +4,9 @@
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
- Gmail-style threading with inline expansion and an optional conversation toggle
- Unified mailbox view across all connected accounts
- Unified mailbox view across all connected accounts combined Inbox, Sent, Drafts, Junk, Archive, and Trash, with group/shared accounts optionally merged in
- Cross-account "All accounts" views All unread, All starred, and All mail spanning every account (including shared/group folders); each aggregate list labels the source folder of every message
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
- Three selectable mail layouts: split (three-pane), focused list, and reading pane at bottom
- Draft auto-save with identity preservation, persisted HTML body, and proper `In-Reply-To` / `References` headers on replies
- Attachment upload, download, drag-out to local file system, and inline preview images, inline PDF on desktop and mobile, composer attachments (click to open), and `.eml` (`message/rfc822`) attachments rendered like an email; image thumbnails and forgotten-attachment warning
@@ -115,6 +117,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Configurable signature position (above or below quoted text)
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
- Shared folders across accounts
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the unified and "All accounts" views ("Include group inboxes"); their messages are fully actionable there open, mark read, spam / not-spam, move, delete, and archive with folder unread counts kept in sync
- Multiple JMAP servers per deployment with optional auto-pick by email domain
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
@@ -122,6 +125,7 @@ Automatic browser detection with persistent preference. Configurable locale URL
- Web setup wizard for first launch guides through JMAP server(s), OAuth/OIDC, session secret, logging, branding (with file upload), and admin password; persists to the admin config dir, no `.env.local` editing required
- Stalwart admin dashboard with dedicated policy sections, collapsed into a single tabbed page
- Admin policy gates for the aggregate mail views enable or disable the "All Mail" and the cross-account "All unread / starred / all" entries org-wide; each gated view still respects the user's own toggle
- Split admin storage: `ADMIN_CONFIG_DIR` (operator-authored, mountable read-only after setup) and `ADMIN_STATE_DIR` (runtime audit log and login timestamps)
- File-based secrets for JSON config: `passwordHashFile` (admin password), `sessionSecretFile`, and `oauthClientSecretFile` for Docker/Kubernetes secret mounts
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
+1 -1
View File
@@ -81,7 +81,7 @@ The wizard writes to `ADMIN_CONFIG_DIR` (`./data/admin` by default). Setting `JM
Bulwark is a full webmail suite, not just an inbox. It bundles the four apps most self-hosters end up wanting on the same login:
- **Mail** threading, unified inbox, full-text search, Sieve filters, S/MIME, templates
- **Mail** threading, unified inbox, cross-account "All accounts" views, full-text search, Sieve filters, S/MIME, templates
- **Calendar** month/week/day/agenda, recurring events, iMIP invitations, CalDAV subscriptions
- **Contacts** multiple address books, groups, vCard import/export
- **Files** Stalwart's JMAP FileNode storage with previews and folder upload
+139 -44
View File
@@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
import { MobileHeader } from "@/components/layout/mobile-header";
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { useAccountStore } from "@/stores/account-store";
import { usePolicyStore } from "@/stores/policy-store";
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
@@ -319,7 +319,10 @@ export default function Home() {
clearPendingUndoSend,
pendingUndoSend,
fetchUnifiedEmails: fetchUnifiedEmailsAction,
fetchCrossView: fetchCrossViewAction,
refreshUnifiedCounts,
refreshCrossCounts,
crossUnreadCount,
exitUnifiedView,
emptyMailbox,
markMailboxAsRead,
@@ -347,6 +350,19 @@ export default function Home() {
const delayedSendSupported = client?.hasDelayedSend() ?? true;
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
// they require Unified Mailbox to be enabled, plus the admin gate and the
// per-user setting. Hooks are called unconditionally.
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
const activeEmails = isScheduledView ? scheduledEmails : emails;
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
@@ -986,14 +1002,16 @@ export default function Home() {
// recounting happened"). The Pro shell always renders the unified mailbox
// regardless of the user setting, so refresh when embedded too.
useEffect(() => {
if (!enableUnifiedMailbox && !isEmbedded) return;
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
if (!isAuthenticated || !client) return;
buildPopulatedUnifiedAccounts().then((built) => {
const hasGroupEntry = built.some((b) => b.isShared);
if (anyCross) refreshCrossCounts(built);
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
// System-notification click handler. The push SW navigates the user back
// here with `?email=<id>` (specific email it built the toast from) or
@@ -1039,8 +1057,8 @@ export default function Home() {
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
// calling selectEmail on the stub), to avoid a duplicate request.
if (!selectedEmail.bodyValues && !isLoadingEmail) {
const perAccountClient = isUnifiedView && selectedEmail.accountId
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
: undefined;
const fetchClient = perAccountClient ?? client;
setLoadingEmail(true);
@@ -1177,8 +1195,15 @@ export default function Home() {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
// Route to the email's own account so shared/group threads refresh
// from the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
@@ -1414,10 +1439,11 @@ export default function Home() {
if (!client || !emailToDelete) return;
// In unified view the trash destination and current-folder check must come
// from the email's own account, not the active one. (#281)
// from the email's own account, not the active one. The owning account's
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
const actionMailboxes =
isUnifiedView && emailToDelete.accountId
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
isUnifiedView && emailToDelete.sourceAccountId
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
: mailboxes;
// Check if we're currently in the trash or junk folder. In unified view the
@@ -1446,11 +1472,17 @@ export default function Home() {
console.error("Failed to permanently delete email:", error);
}
} else {
// Not in trash: always move to trash (in the email's own account).
// Not in trash: always move to trash (in the email's own account). Scope
// the trash lookup to the email's account: for a shared/group source every
// mailbox in the list is `isShared`, so we match by accountId instead of
// excluding shared (otherwise no trash is found and the delete fails). (#281)
const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined;
const matchesScope = (m: Mailbox) =>
sourceAccountId ? m.accountId === sourceAccountId : !m.isShared;
const trashMailbox =
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
actionMailboxes.find(m => {
if (m.isShared) return false;
if (!matchesScope(m)) return false;
const lower = m.name.toLowerCase();
return lower.includes('trash') || lower.includes('deleted');
});
@@ -1473,11 +1505,14 @@ export default function Home() {
if (!client || !emailToArchive) return;
// In unified view the archive folder (and any year/month subfolders we
// create) must live in the email's own account, reached through that
// account's client. (#281)
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
const archiveClient = archiveAccountId
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
// create) must live in the email's own account, reached through the login it
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
// account (`sourceAccountId`). For personal sources these resolve to the
// account itself, so behavior is unchanged. (#281)
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
const archiveClient = archiveClientId
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
: client;
// Read fresh mailboxes from the store batch archive calls this in a loop,
// and each iteration needs to see folders created by prior iterations.
@@ -1512,7 +1547,7 @@ export default function Home() {
m => m.name === year && m.parentId === archiveId
);
if (!yearMailbox) {
yearMailbox = await archiveClient.createMailbox(year, archiveId);
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
await refreshMailboxes();
}
@@ -1525,7 +1560,7 @@ export default function Home() {
m => m.name === month && m.parentId === yearId
);
if (!monthMailbox) {
monthMailbox = await archiveClient.createMailbox(month, yearId);
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
await refreshMailboxes();
}
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
@@ -1618,7 +1653,7 @@ export default function Home() {
});
} else {
const jmapKey = `$label:${color}`;
if (keywords[jmapKey] === true) {
if (keywords[jmapKey]) {
// Toggle off if already active
keywords[jmapKey] = false;
} else {
@@ -1714,6 +1749,28 @@ export default function Home() {
return;
}
if (isCrossViewId(mailboxId)) {
setScheduledView(false);
const view = CROSS_VIEW_BY_ID[mailboxId];
if (!view) return;
selectMailbox(mailboxId);
selectEmail(null);
if (isMobile) {
setSidebarOpen(false);
setActiveView("list");
}
if (isTablet) {
setTabletListVisible(true);
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchCrossViewAction(populated, view);
refreshCrossCounts(populated);
return;
}
if (isUnifiedView) {
exitUnifiedView();
}
@@ -2227,8 +2284,15 @@ export default function Home() {
const emailState = useEmailStore.getState();
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
const accountId = client.getAccountId();
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
// Route to the email's own account so shared/group threads refresh from
// the right server, not the active one. (#281)
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
: client;
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
? repliedEmail.sourceAccountId
: client.getAccountId();
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
if (fullEmails.length > 0) {
useEmailStore.setState((state) => {
const c = new Map(state.threadEmailsCache);
@@ -2292,21 +2356,25 @@ export default function Home() {
// Fetch the full content
try {
// In unified view each email carries its own accountId. Use that
// account's client so we fetch from the server that actually owns it.
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
const perAccountClient = emailAccountId
? useAuthStore.getState().getClientForAccount(emailAccountId)
// In unified view each email carries its source reference: the login it is
// reachable through (`sourceClientAccountId`) and its owning JMAP account
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
// owns it — works uniformly for personal and shared/group sources, since for
// personal the owning account equals the client's primary (no-op). (#281)
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
const perAccountClient = sourceClientId
? useAuthStore.getState().getClientForAccount(sourceClientId)
: undefined;
const fetchClient = perAccountClient ?? client;
// For shared folders on the primary client, we still need to pass the
// shared account's id. In unified view we use the per-account client
// directly, so no explicit accountId is needed.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = perAccountClient
? undefined
: mailbox?.isShared ? mailbox.accountId : undefined;
const accountId = isUnifiedView
? listEmail?.sourceAccountId
: (() => {
// Non-unified: shared folders on the active client still need their
// owner accountId passed explicitly.
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
return mailbox?.isShared ? mailbox.accountId : undefined;
})();
const fullEmail = await fetchClient.getEmail(email.id, accountId);
if (fullEmail) {
@@ -2318,9 +2386,13 @@ export default function Home() {
fullEmail.isScheduled = true;
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
}
if (emailAccountId) {
fullEmail.accountId = emailAccountId;
fullEmail.accountLabel = listEmail?.accountLabel;
// Re-stamp the source reference so later actions on the open email
// resolve to the right account (the fetched object lacks these).
if (isUnifiedView && listEmail) {
fullEmail.accountId = listEmail.accountId;
fullEmail.accountLabel = listEmail.accountLabel;
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
fullEmail.sourceAccountId = listEmail.sourceAccountId;
}
selectEmail(fullEmail);
// Mark-as-read logic is now handled by useEffect
@@ -2369,8 +2441,27 @@ export default function Home() {
setActiveView("viewer");
try {
// Fetch complete thread emails
const emails = await client.getThreadEmails(thread.threadId);
// In unified/aggregate views the thread may belong to another (possibly
// shared/group) account. Route the fetch to the login it's reachable
// through (`sourceClientAccountId`) and pass its owning JMAP account
// (`sourceAccountId`) so the thread loads from the right server instead of
// the active one (which doesn't have it → empty/body-less). (#281)
const ref = thread.emails?.[0];
const threadClient = isUnifiedView && ref?.sourceClientAccountId
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
: client;
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
// Re-stamp the source reference so conversation actions (reply/move/…)
// resolve to the right account; the fetched objects don't carry it.
if (isUnifiedView && ref) {
for (const e of emails) {
e.accountId = ref.accountId;
e.accountLabel = ref.accountLabel;
e.sourceClientAccountId = ref.sourceClientAccountId;
e.sourceAccountId = ref.sourceAccountId;
}
}
setConversationEmails(emails);
} catch (error) {
console.error('Failed to fetch thread emails:', error);
@@ -2518,6 +2609,10 @@ export default function Home() {
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
crossUnreadCount={crossUnreadCount}
onMailboxSelect={handleMailboxSelect}
onTagSelect={handleTagSelect}
onUnreadFilterClick={handleUnreadFilterClick}
@@ -2685,19 +2780,19 @@ export default function Home() {
icon={<Paperclip className="w-3.5 h-3.5" />}
label={t("advanced_search.has_attachment")}
value={searchFilters.hasAttachment}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={<Star className="w-3.5 h-3.5" />}
label={t("advanced_search.starred")}
value={searchFilters.isStarred}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
/>
<ToggleChip
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
value={searchFilters.isUnread}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
/>
</div>
<div className="flex items-center gap-1">
@@ -3142,7 +3237,7 @@ export default function Home() {
}}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
onMoveToMailbox={async (mailboxId) => {
+4 -1
View File
@@ -21,7 +21,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included.' },
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an accounts folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
};
const RESTRICTABLE_SETTINGS = [
+20 -5
View File
@@ -4,7 +4,7 @@ import { Email } from "@/lib/jmap/types";
import { useSettingsStore } from "@/stores/settings-store";
import type { HoverAction } from "@/stores/settings-store";
import { cn } from "@/lib/utils";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert } from "lucide-react";
import { Trash2, Star, Mail, MailOpen, Archive, Tag, ShieldAlert, ShieldCheck } from "lucide-react";
import { useTranslations } from "next-intl";
import { useIsMobile } from "@/hooks/use-media-query";
@@ -17,6 +17,10 @@ interface EmailHoverActionsProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
// When the email lives in a junk folder (incl. the aggregate "All Junk" view)
// the spam quick-action flips to "not spam".
isInJunk?: boolean;
onUndoSpam?: () => void;
}
const ACTION_CONFIG: Record<HoverAction, {
@@ -72,6 +76,8 @@ export function EmailHoverActions({
onArchive,
onSetColorTag,
onMarkAsSpam,
isInJunk = false,
onUndoSpam,
}: EmailHoverActionsProps) {
const hoverActions = useSettingsStore((state) => state.hoverActions);
const hoverActionsMode = useSettingsStore((state) => state.hoverActionsMode);
@@ -106,7 +112,8 @@ export function EmailHoverActions({
onSetColorTag?.(null);
break;
case "spam":
onMarkAsSpam?.();
if (isInJunk) onUndoSpam?.();
else onMarkAsSpam?.();
break;
}
};
@@ -116,20 +123,28 @@ export function EmailHoverActions({
if (!config) return null;
const Icon = config.icon;
// In a junk context the spam action becomes "not spam".
const isNotSpam = actionId === "spam" && isInJunk;
const DisplayIcon = actionId === "markRead"
? (isUnread ? MailOpen : Mail)
: actionId === "star" && isStarred
? Star
: Icon;
: isNotSpam
? ShieldCheck
: Icon;
const title = isNotSpam ? t("not_spam") : t(config.titleKey);
const className = isNotSpam
? "hover:text-green-600 dark:hover:text-green-400"
: config.className;
return (
<button
key={actionId}
onClick={(e) => handleAction(e, actionId)}
title={t(config.titleKey)}
title={title}
className={cn(
"p-1.5 rounded-md transition-colors duration-100 text-muted-foreground hover:bg-black/5 dark:hover:bg-white/10",
config.className,
className,
)}
>
<DisplayIcon
+10 -4
View File
@@ -29,11 +29,12 @@ interface EmailListItemProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }: EmailListItemProps) {
export function EmailListItem({ email, selected, onClick, onDoubleClick, onContextMenu, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }: EmailListItemProps) {
const t = useTranslations('email_viewer');
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection } = useEmailStore();
const { selectedEmailIds, toggleEmailSelection, selectRangeEmails, selectedMailbox, mailboxes, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showPreview = useSettingsStore((state) => state.showPreview);
const density = useSettingsStore((state) => state.density);
const mailLayout = useSettingsStore((state) => state.mailLayout);
@@ -46,8 +47,11 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
const isImportant = email.keywords?.["$important"];
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me").
// In aggregate role-views the selected mailbox is virtual → fall back to the
// unified role so junk-contextual UI (spam ↔ not-spam) and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const isMobile = useUIStore((state) => state.isMobile);
@@ -321,6 +325,8 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/>
</div>
);
+12 -1
View File
@@ -108,8 +108,18 @@ export function EmailList({
clearSearchFilters,
advancedSearch,
searchQuery,
isUnifiedView,
unifiedRole,
} = useEmailStore();
// In aggregate role-views (e.g. "All Junk") the selected mailbox is virtual, so
// there is no concrete mailbox to read the role from. Fall back to the unified
// role so contextual actions (e.g. mark-as-spam ↔ not-spam) behave as if inside
// that role's folder.
const effectiveMailboxRole =
mailboxes.find(m => m.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const disableThreading = useSettingsStore((state) => state.disableThreading);
const threadGroups = useMemo(() => {
@@ -499,6 +509,7 @@ export function EmailList({
onArchive={onArchive ? (email) => onArchive(email) : undefined}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam ? (email) => onMarkAsSpam(email) : undefined}
onUndoSpam={onUndoSpam ? (email) => onUndoSpam(email) : undefined}
/>
</div>
);
@@ -532,7 +543,7 @@ export function EmailList({
menuRef={menuRef}
mailboxes={mailboxes}
selectedMailbox={selectedMailbox}
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
currentMailboxRole={effectiveMailboxRole}
isMultiSelect={selectedEmailIds.has(contextMenu.data.id)}
selectedCount={selectedEmailIds.size}
onReply={() => onReply?.(contextMenu.data!)}
+47 -10
View File
@@ -2,10 +2,10 @@
import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { Avatar } from "@/components/ui/avatar";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock } from "lucide-react";
import { Paperclip, Star, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
import { useSettingsStore, KEYWORD_PALETTE } from "@/stores/settings-store";
import { useUIStore } from "@/stores/ui-store";
import { useEmailStore } from "@/stores/email-store";
@@ -17,6 +17,23 @@ import { ThreadEmailItem } from "./thread-email-item";
import { EmailHoverActions } from "./email-hover-actions";
import { useTranslations } from "next-intl";
/**
* Small chip showing the originating folder of a message, rendered in the
* aggregate "All …" views (All Mail / unified / cross-account) where rows come
* from different folders. `email.sourceFolder` is stamped at fetch time.
*/
function SourceFolderTag({ name }: { name: string }) {
return (
<span
className="inline-flex max-w-[8rem] shrink-0 items-center gap-1 truncate rounded-full border border-border bg-muted/40 px-1.5 py-0.5 text-[11px] text-muted-foreground"
title={name}
>
<Folder className="h-3 w-3 shrink-0" />
<span className="truncate">{name}</span>
</span>
);
}
interface ThreadListItemProps {
thread: ThreadGroup;
isExpanded: boolean;
@@ -35,6 +52,7 @@ interface ThreadListItemProps {
onArchive?: (email: Email) => void;
onSetColorTag?: (emailId: string, color: string | null) => void;
onMarkAsSpam?: (email: Email) => void;
onUndoSpam?: (email: Email) => void;
}
interface SingleEmailItemProps {
@@ -51,18 +69,22 @@ interface SingleEmailItemProps {
onArchive?: () => void;
onSetColorTag?: (color: string | null) => void;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
}
const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam }, ref) {
function SingleEmailItem({ email, selected, onClick, onDoubleClick, onContextMenu, showPreview, colorTag, onToggleStar, onMarkAsRead, onDelete, onArchive, onSetColorTag, onMarkAsSpam, onUndoSpam }, ref) {
const t = useTranslations('email_viewer');
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isAnswered = email.keywords?.$answered;
const isForwarded = email.keywords?.$forwarded;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). In aggregate role-views the selected mailbox is virtual → fall back
// to the unified role so junk-contextual UI and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
const emailKeywords = useSettingsStore((state) => state.emailKeywords);
@@ -71,7 +93,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const timeFormat = useSettingsStore((state) => state.timeFormat);
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
const isUnifiedView = useEmailStore((state) => state.isUnifiedView);
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
@@ -246,6 +269,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{resolvedKeywordDefs.map((kd) => (
<span key={kd.id} className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[kd.color]?.dot || 'bg-gray-400')} />
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -314,6 +338,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
{kd.label}
</span>
))}
{showSourceFolder && <SourceFolderTag name={email.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -370,6 +395,8 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
onArchive={onArchive}
onSetColorTag={onSetColorTag}
onMarkAsSpam={onMarkAsSpam}
onUndoSpam={onUndoSpam}
isInJunk={currentMailboxRole === 'junk'}
/>
)}
</div>
@@ -396,6 +423,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive,
onSetColorTag,
onMarkAsSpam,
onUndoSpam,
}, ref) {
const t = useTranslations('threads');
const tEmailViewer = useTranslations('email_viewer');
@@ -414,11 +442,15 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
? formatDateTime(latestEmail.scheduledSendAt, timeFormat)
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView } = useEmailStore();
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
// In Sent/Drafts folders, show recipient instead of sender (which is always "me")
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
// In Sent/Drafts folders, show recipient instead of sender (which is always
// "me"). Aggregate role-views use a virtual selected mailbox → fall back to
// the unified role so junk-contextual UI and avatar hiding work.
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
const displayNames = showRecipient
? Array.from(new Set(
@@ -472,6 +504,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
/>
);
}
@@ -682,6 +715,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef && (
<span className={cn('h-2.5 w-2.5 rounded-full', KEYWORD_PALETTE[keywordDef.color]?.dot || 'bg-gray-400')} />
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-xs font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -762,6 +796,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
{keywordDef.label}
</span>
)}
{showSourceFolder && <SourceFolderTag name={latestEmail.sourceFolder!} />}
{scheduledSendLabel ? (
<span
className="inline-flex max-w-[11rem] shrink-0 items-center gap-1 truncate rounded-full border border-sky-500/20 bg-sky-500/10 px-2 py-0.5 text-[11px] font-medium tabular-nums text-sky-700 dark:text-sky-300"
@@ -818,6 +853,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
onArchive={onArchive ? () => onArchive(latestEmail) : undefined}
onSetColorTag={onSetColorTag ? (color) => onSetColorTag(latestEmail.id, color) : undefined}
onMarkAsSpam={onMarkAsSpam ? () => onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
/>
)}
</div>
+34 -3
View File
@@ -34,13 +34,14 @@ import {
CalendarClock,
BellOff,
Mails,
MailOpen,
} from "lucide-react";
import { cn, buildMailboxTree, MailboxNode } from "@/lib/utils";
import { Mailbox } from "@/lib/jmap/types";
import { useContextMenu } from "@/hooks/use-context-menu";
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
import { useAccountStore } from '@/stores/account-store';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
import type { UnifiedMailboxRole } from '@/lib/jmap/types';
import { useDragDropContext } from "@/contexts/drag-drop-context";
import { useMailboxDrop } from "@/hooks/use-mailbox-drop";
@@ -79,6 +80,12 @@ interface SidebarProps {
showScheduledMailbox?: boolean;
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
showAllMailMailbox?: boolean;
/** Gated cross-account views in the "All accounts" section. */
showCrossUnread?: boolean;
showCrossStarred?: boolean;
showCrossAll?: boolean;
/** Unread total across all cross-view folders (badge for unread/all). */
crossUnreadCount?: number;
className?: string;
/**
* Multi-account (Pro) mode props. When `multiAccountMode` is true, the
@@ -681,6 +688,10 @@ export function Sidebar({
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
crossUnreadCount = 0,
className,
multiAccountMode = false,
accountMailboxes,
@@ -991,7 +1002,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
/>
)}
{showUnified && (
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div>
<SidebarSectionHeader
label={t("all_accounts")}
@@ -1002,7 +1013,7 @@ export function Sidebar({
/>
{((unifiedExpanded && !isCollapsed) || isCollapsed) && (
<>
{unifiedCounts.map((count) => {
{showUnified && unifiedCounts.map((count) => {
const unifiedId = UNIFIED_MAILBOX_IDS[count.role];
const Icon = getUnifiedIcon(count.role);
const isSelected = !selectedKeyword && selectedMailbox === unifiedId;
@@ -1020,6 +1031,26 @@ export function Sidebar({
/>
);
})}
{[
{ show: showCrossUnread, id: CROSS_VIEW_IDS.unread, Icon: MailOpen, label: t('unified_all_unread'), unread: crossUnreadCount },
{ show: showCrossStarred, id: CROSS_VIEW_IDS.starred, Icon: Star, label: t('unified_all_starred'), unread: undefined as number | undefined },
{ show: showCrossAll, id: CROSS_VIEW_IDS.all, Icon: Mails, label: t('unified_all_mail'), unread: crossUnreadCount },
].map(({ show, id, Icon, label, unread }) => {
if (!show) return null;
const isSelected = !selectedKeyword && selectedMailbox === id;
return (
<SidebarRow
key={id}
icon={<Icon className={getIconClass(isSelected, false, colorfulSidebarIcons)} />}
label={label}
depth={0}
isSelected={isSelected}
unread={unread}
onClick={() => onMailboxSelect?.(id)}
isCollapsed={isCollapsed}
/>
);
})}
</>
)}
</div>
+27 -1
View File
@@ -118,12 +118,18 @@ function MailLayoutPreview({
export function LayoutSettings() {
const t = useTranslations('settings.appearance');
const tEmail = useTranslations('settings.email_behavior');
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const mailboxes = useEmailStore(s => s.mailboxes);
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
// Cross-account "All accounts" views, each gated independently by the admin.
const crossViews = [
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
{ setting: 'enableCrossStarredView', value: enableCrossStarredView, allowed: isFeatureEnabled('crossStarredViewEnabled'), labelKey: 'cross_starred.label', descKey: 'cross_starred.description' },
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
] as const;
// Own (non-shared) folders and the current All Mail selection. `null` =
// never configured, which defaults to all non-special (no-role) folders.
@@ -226,6 +232,26 @@ export function LayoutSettings() {
</div>
)}
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2 space-y-2">
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
<SettingItem
key={setting}
label={t(labelKey)}
description={t(descKey)}
locked={isSettingLocked(setting)}
>
<ToggleSwitch
checked={value}
onChange={(v) => updateSetting(setting, v)}
/>
</SettingItem>
)
))}
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
+169
View File
@@ -0,0 +1,169 @@
import { describe, it, expect, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import {
getCrossIncludedMailboxes,
buildCrossFilter,
getCrossUnreadTotal,
fetchCrossViewEmails,
resolveSourceFolderName,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
const mb = (id: string, role: string | undefined, unread = 0, originalId?: string): Mailbox =>
({ id, name: id, role, unreadEmails: unread, totalEmails: 0, originalId } as unknown as Mailbox);
const makeAccount = (
over: Partial<UnifiedAccountClient> & { accountId: string },
clientImpl: Partial<IJMAPClient> = {},
): UnifiedAccountClient => ({
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
clientAccountId: over.accountId,
jmapAccountId: over.accountId,
...over,
});
describe('getCrossIncludedMailboxes', () => {
it('excludes junk/sent/archive/trash/drafts, keeps inbox + custom folders', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [
mb('inbox', 'inbox'),
mb('projects', undefined),
mb('junk', 'junk'),
mb('sent', 'sent'),
mb('archive', 'archive'),
mb('trash', 'trash'),
mb('drafts', 'drafts'),
],
});
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'projects']);
});
});
describe('buildCrossFilter', () => {
it('all → single inMailbox for one folder', () => {
expect(buildCrossFilter('all', ['m1'])).toEqual({ inMailbox: 'm1' });
});
it('all → OR of inMailbox for multiple folders', () => {
expect(buildCrossFilter('all', ['m1', 'm2'])).toEqual({
operator: 'OR',
conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }],
});
});
it('unread → AND(membership, notKeyword $seen)', () => {
expect(buildCrossFilter('unread', ['m1', 'm2'])).toEqual({
operator: 'AND',
conditions: [
{ operator: 'OR', conditions: [{ inMailbox: 'm1' }, { inMailbox: 'm2' }] },
{ notKeyword: '$seen' },
],
});
});
it('starred → AND(membership, hasKeyword $flagged)', () => {
expect(buildCrossFilter('starred', ['m1'])).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'm1' }, { hasKeyword: '$flagged' }],
});
});
});
describe('getCrossUnreadTotal', () => {
it('sums unread across included folders of every account, ignoring excluded roles', () => {
const a = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox', 3), mb('proj', undefined, 2), mb('junk', 'junk', 50)],
});
const b = makeAccount({
accountId: 'b',
mailboxes: [mb('inbox', 'inbox', 5), mb('sent', 'sent', 99)],
});
expect(getCrossUnreadTotal([a, b])).toBe(10);
});
});
describe('resolveSourceFolderName', () => {
const emailIn = (ids: string[]): Email =>
({ mailboxIds: Object.fromEntries(ids.map((id) => [id, true])) } as unknown as Email);
it('returns the name of the folder the email is in (personal account)', () => {
const boxes = [mb('inbox', 'inbox'), mb('proj', undefined)];
expect(resolveSourceFolderName(emailIn(['proj']), boxes)).toBe('proj');
});
it('matches shared mailboxes by originalId (email keyed by owner-side id)', () => {
// shared mailbox: namespaced store id, but email.mailboxIds uses originalId
const shared = { id: 'owner:inbox', role: 'inbox', unreadEmails: 0, totalEmails: 0, originalId: 'orig-inbox', name: 'Team Inbox' } as unknown as Mailbox;
expect(resolveSourceFolderName(emailIn(['orig-inbox']), [shared])).toBe('Team Inbox');
});
it('returns undefined when no known folder contains the email', () => {
expect(resolveSourceFolderName(emailIn(['unknown']), [mb('inbox', 'inbox')])).toBeUndefined();
});
});
describe('fetchCrossViewEmails', () => {
it('merges + date-sorts across accounts and stamps account info', async () => {
const clientA = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'a1', receivedAt: '2026-01-01T10:00:00Z' } as Email],
total: 1,
hasMore: false,
}),
};
const clientB = {
advancedSearchEmails: vi.fn().mockResolvedValue({
emails: [{ id: 'b1', receivedAt: '2026-01-02T10:00:00Z' } as Email],
total: 1,
hasMore: true,
}),
};
const a = makeAccount({ accountId: 'a', accountLabel: 'A', mailboxes: [mb('inbox', 'inbox')] }, clientA);
const b = makeAccount({ accountId: 'b', accountLabel: 'B', mailboxes: [mb('inbox', 'inbox')] }, clientB);
const result = await fetchCrossViewEmails([a, b], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['b1', 'a1']); // newest first
expect(result.emails[0].accountId).toBe('b');
expect(result.emails[1].accountLabel).toBe('A');
expect(result.total).toBe(2);
expect(result.hasMore).toBe(true);
});
it('resolves shared folders via originalId + owner accountId', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const shared = makeAccount(
{ accountId: 'owner-1', accountLabel: 'Shared', isShared: true, mailboxes: [mb('ns:inbox', 'inbox', 0, 'orig-inbox')] },
{ advancedSearchEmails },
);
await fetchCrossViewEmails([shared], 'unread', 50, 0);
const [filter, accountId] = advancedSearchEmails.mock.calls[0];
expect(accountId).toBe('owner-1');
// filter membership uses the originalId, not the namespaced id
expect(JSON.stringify(filter)).toContain('orig-inbox');
expect(JSON.stringify(filter)).not.toContain('ns:inbox');
});
it('collects per-account errors without failing the whole fan-out', async () => {
const ok = makeAccount(
{ accountId: 'ok', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockResolvedValue({ emails: [{ id: 'x', receivedAt: '2026-01-01T00:00:00Z' } as Email], total: 1, hasMore: false }) },
);
const bad = makeAccount(
{ accountId: 'bad', mailboxes: [mb('inbox', 'inbox')] },
{ advancedSearchEmails: vi.fn().mockRejectedValue(new Error('boom')) },
);
const result = await fetchCrossViewEmails([ok, bad], 'all', 50, 0);
expect(result.emails.map((e) => e.id)).toEqual(['x']);
expect(result.errors.get('bad')).toBe('boom');
});
});
+4
View File
@@ -28,6 +28,8 @@ function makeAccount(
accountLabel: over.accountId,
mailboxes: [],
client: clientImpl as unknown as IJMAPClient,
clientAccountId: over.accountId,
jmapAccountId: over.accountId,
...over,
};
}
@@ -71,6 +73,8 @@ describe('fetchUnifiedEmails', () => {
const a2 = result.emails.find((e) => e.id === 'a2')!;
expect(a2.accountId).toBe('A');
expect(a2.accountLabel).toBe('Account A');
expect(a2.sourceClientAccountId).toBe('A');
expect(a2.sourceAccountId).toBe('A');
// getEmails called with (mailboxId, accountId=undefined for personal, limit, position)
expect(acc1.client.getEmails).toHaveBeenCalledWith('a-in', undefined, 20, 0);
});
+6
View File
@@ -60,6 +60,9 @@ export interface FeatureGates {
filesEnabled: boolean;
contactsEnabled: boolean;
allMailViewEnabled: boolean;
crossUnreadViewEnabled: boolean;
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -81,6 +84,9 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
filesEnabled: true,
contactsEnabled: true,
allMailViewEnabled: false,
crossUnreadViewEnabled: false,
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
};
export interface ThemePolicy {
+4 -4
View File
@@ -121,7 +121,7 @@ export class DemoJMAPClient implements IJMAPClient {
async getMailboxes(_accountId?: string): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async getAllMailboxes(): Promise<Mailbox[]> { return [...this.data.mailboxes]; }
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
async createMailbox(name: string, parentId?: string, _accountId?: string): Promise<Mailbox> {
const mb: Mailbox = {
id: generateDemoId('mailbox'),
name,
@@ -222,7 +222,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
async batchMarkAsRead(emailIds: string[], read: boolean = true, _accountId?: string): Promise<void> {
for (const id of emailIds) {
const email = this.data.emails.find(e => e.id === id);
if (email) {
@@ -233,7 +233,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async toggleStar(emailId: string, starred: boolean): Promise<void> {
async toggleStar(emailId: string, starred: boolean, _accountId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
if (starred) email.keywords.$flagged = true;
@@ -275,7 +275,7 @@ export class DemoJMAPClient implements IJMAPClient {
this.recalcMailboxCounts();
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
async batchDeleteEmails(emailIds: string[], _accountId?: string): Promise<void> {
const idSet = new Set(emailIds);
this.data.emails = this.data.emails.filter(e => !idSet.has(e.id));
this.recalcMailboxCounts();
+4 -4
View File
@@ -73,7 +73,7 @@ export interface IJMAPClient {
// ── Mailboxes ─────────────────────────────────────────────────
getMailboxes(accountId?: string): Promise<Mailbox[]>;
getAllMailboxes(): Promise<Mailbox[]>;
createMailbox(name: string, parentId?: string): Promise<Mailbox>;
createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox>;
updateMailbox(mailboxId: string, changes: { name?: string; parentId?: string | null; role?: string | null; sortOrder?: number }): Promise<void>;
deleteMailbox(mailboxId: string): Promise<void>;
@@ -92,14 +92,14 @@ export interface IJMAPClient {
// ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean): Promise<void>;
toggleStar(emailId: string, starred: boolean): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
setKeyword(emailId: string, keyword: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchDeleteEmails(emailIds: string[]): Promise<void>;
batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void>;
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
batchArchiveEmails(
emails: Array<{ id: string; receivedAt: string }>,
+8 -8
View File
@@ -1273,19 +1273,19 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchMarkAsRead(emailIds: string[], read: boolean = true): Promise<void> {
async batchMarkAsRead(emailIds: string[], read: boolean = true, accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
const updates = Object.fromEntries(emailIds.map(id => [id, { "keywords/$seen": read }]));
await this.request([
["Email/set", { accountId: this.accountId, update: updates }, "0"],
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
]);
}
async toggleStar(emailId: string, starred: boolean): Promise<void> {
async toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
update: {
[emailId]: {
"keywords/$flagged": starred,
@@ -1391,12 +1391,12 @@ export class JMAPClient implements IJMAPClient {
]);
}
async batchDeleteEmails(emailIds: string[]): Promise<void> {
async batchDeleteEmails(emailIds: string[], accountId?: string): Promise<void> {
if (emailIds.length === 0) return;
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
destroy: emailIds,
}, "0"],
]);
@@ -1709,7 +1709,7 @@ export class JMAPClient implements IJMAPClient {
]);
}
async createMailbox(name: string, parentId?: string): Promise<Mailbox> {
async createMailbox(name: string, parentId?: string, accountId?: string): Promise<Mailbox> {
const createId = `new-${Date.now()}`;
const createData: Record<string, unknown> = { name };
if (parentId) {
@@ -1718,7 +1718,7 @@ export class JMAPClient implements IJMAPClient {
const response = await this.request([
["Mailbox/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
create: { [createId]: createData },
}, "0"],
]);
+54 -1
View File
@@ -58,9 +58,27 @@ export interface Email {
// S/MIME support
blobId?: string;
bodyStructure?: EmailBodyPart;
// Unified mailbox support - set when displaying emails from multiple accounts
// Unified mailbox support - set when displaying emails from multiple accounts.
// `accountId` is a DISPLAY-only reference (avatar color / label / badge) and may
// hold either an AccountEntry.id (personal) or the JMAP owner id (shared). For
// resolving the client + JMAP routing use the two dedicated fields below, which
// are always set on aggregated emails and unambiguous.
accountId?: string;
accountLabel?: string;
// AccountEntry.id of the logged-in client through which this email is reachable.
// Always a real login key → `useAuthStore.getClientForAccount(...)` resolves it.
// For personal sources this equals the account itself; for shared/group sources
// it is the delegating login (the shared account has no own login).
sourceClientAccountId?: string;
// JMAP account id of the email's owning account (personal: the client's primary;
// shared/group: the owner account). Always safe to pass as the JMAP `accountId`
// argument — equal to the client's primary for personal sources, so it is a no-op
// there, and triggers owner-scoped routing + mailbox-id namespacing for shared.
sourceAccountId?: string;
// Name of the email's originating folder, stamped for the aggregate "All …"
// views (All Mail, unified, cross-account) so the list can show where each
// message lives. Transient/client-only, not part of the JMAP object.
sourceFolder?: string;
// Client-only scheduled-send metadata, populated from EmailSubmission/query.
scheduledSendAt?: string;
emailSubmissionId?: string;
@@ -879,3 +897,38 @@ export function isUnifiedMailboxId(id: string): boolean {
* included is a per-user setting (see `allMailFolderIds`).
*/
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
/**
* Cross-account "All …" views shown in the unified ("All accounts") section.
* Each merges messages across EVERY account (including shared/group folders),
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
* one date-sorted list. Distinct from the per-role unified ids (one role across
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
export const CROSS_ALL = '__cross_all__';
export type CrossView = 'unread' | 'starred' | 'all';
export const CROSS_VIEW_IDS: Record<CrossView, string> = {
unread: CROSS_UNREAD,
starred: CROSS_STARRED,
all: CROSS_ALL,
};
export const CROSS_VIEW_BY_ID: Record<string, CrossView> = Object.fromEntries(
Object.entries(CROSS_VIEW_IDS).map(([view, id]) => [id, view as CrossView])
) as Record<string, CrossView>;
export function isCrossViewId(id: string): boolean {
return id in CROSS_VIEW_BY_ID;
}
/**
* Mailbox roles excluded from the cross-account views. Everything else (inbox
* and custom/no-role folders) is included.
*/
export const CROSS_EXCLUDED_ROLES: ReadonlySet<string> = new Set([
'junk', 'sent', 'archive', 'trash', 'drafts',
]);
+170 -1
View File
@@ -1,11 +1,22 @@
import type { Email, Mailbox, UnifiedMailboxRole } from '@/lib/jmap/types';
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
export interface UnifiedAccountClient {
// Display reference (avatar color / label). For personal entries this is the
// AccountEntry.id; for shared entries it is the JMAP owner id (see Email.accountId).
accountId: string;
accountLabel: string;
client: IJMAPClient;
mailboxes: Mailbox[];
// AccountEntry.id of the logged-in client this entry uses (`getClientForAccount`
// key). Stamped onto each email as `sourceClientAccountId` so single-email and
// batch actions can resolve the reaching client without scanning capabilities.
clientAccountId: string;
// JMAP account id of the data this entry reads (personal: the client's primary;
// shared: the owner id). Stamped onto each email as `sourceAccountId` and passed
// as the JMAP `accountId` for owner-scoped routing + mailbox-id namespacing.
jmapAccountId: string;
// 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
@@ -30,6 +41,19 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
'inbox', 'sent', 'drafts', 'trash', 'archive', 'junk',
];
/**
* Resolves the display name of the folder an email lives in, for the aggregate
* "All …" views. Matches the email's mailbox membership against the account's
* mailbox list (originalId for shared/namespaced mailboxes). Returns the first
* match, or undefined if none of the account's known folders contain it.
*/
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
for (const m of mailboxes) {
if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name;
}
return undefined;
}
/**
* Finds the first mailbox matching the given role.
*/
@@ -99,6 +123,9 @@ export async function fetchUnifiedEmails(
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
email.sourceClientAccountId = account.clientAccountId;
email.sourceAccountId = account.jmapAccountId;
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
}
mergedEmails = mergedEmails.concat(result.emails);
@@ -223,6 +250,9 @@ async function fanOutUnifiedQuery(
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
email.sourceClientAccountId = account.clientAccountId;
email.sourceAccountId = account.jmapAccountId;
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
@@ -269,6 +299,145 @@ export function fetchUnifiedMailboxCounts(
return counts;
}
// ─── Cross-account views (unread / starred / all) ─────────────────────────────
//
// These merge messages across EVERY account (including shared) and across all
// folders except the CROSS_EXCLUDED_ROLES (junk, sent, archive, trash, drafts),
// i.e. inbox + custom folders, into one date-sorted list. Unlike the per-role
// unified fan-out above, the query spans many mailboxes per account, so the
// filter is built from each account's included-mailbox ids.
/**
* Mailboxes of an account included in the cross-account views: everything whose
* role is not excluded (inbox + custom/no-role folders).
*/
export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] {
return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
}
/**
* Builds the JMAP Email/query filter for a cross-account view over the given
* JMAP-side mailbox ids. `all` is just the mailbox membership; `unread` and
* `starred` AND a keyword condition onto it.
*/
export function buildCrossFilter(
view: CrossView,
jmapMailboxIds: string[],
): Record<string, unknown> {
const inAny: Record<string, unknown> = jmapMailboxIds.length === 1
? { inMailbox: jmapMailboxIds[0] }
: { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) };
if (view === 'all') return inAny;
const keyword = view === 'unread' ? { notKeyword: '$seen' } : { hasKeyword: '$flagged' };
return { operator: 'AND', conditions: [inAny, keyword] };
}
/**
* Total unread count across every account's included cross-view mailboxes. Used
* for the unread badge on the "All unread" and "All mail" entries. Mirrors the
* unified count behaviour (sum of per-mailbox unread metadata, no extra query).
*/
export function getCrossUnreadTotal(accounts: UnifiedAccountClient[]): number {
let unread = 0;
for (const account of accounts) {
for (const m of getCrossIncludedMailboxes(account)) unread += m.unreadEmails;
}
return unread;
}
async function fanOutCrossQuery(
accounts: UnifiedAccountClient[],
run: (
account: UnifiedAccountClient,
jmapAccountId: string | undefined,
includedJmapIds: string[],
) => Promise<{ emails: Email[]; total: number; hasMore: boolean }>,
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
type AccountResult = {
account: UnifiedAccountClient;
result: { emails: Email[]; total: number; hasMore: boolean };
} | null;
const promises = accounts.map(async (account): Promise<AccountResult> => {
const included = getCrossIncludedMailboxes(account);
if (included.length === 0) return null;
const jmapAccountId = account.isShared ? account.accountId : undefined;
const includedJmapIds = included.map((m) => account.isShared ? (m.originalId ?? m.id) : m.id);
try {
const result = await run(account, jmapAccountId, includedJmapIds);
return { account, result };
} catch (err) {
errors.set(account.accountId, err instanceof Error ? err.message : String(err));
return null;
}
});
const results = await Promise.allSettled(promises);
let mergedEmails: Email[] = [];
let totalSum = 0;
let anyHasMore = false;
for (const outcome of results) {
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
const { account, result } = outcome.value;
for (const email of result.emails) {
email.accountId = account.accountId;
email.accountLabel = account.accountLabel;
email.sourceClientAccountId = account.clientAccountId;
email.sourceAccountId = account.jmapAccountId;
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
}
mergedEmails = mergedEmails.concat(result.emails);
totalSum += result.total;
if (result.hasMore) anyHasMore = true;
}
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
return { emails: mergedEmails, total: totalSum, hasMore: anyHasMore, errors };
}
/**
* Fetches a cross-account view (browse), merging and date-sorting across all
* accounts. Per-account failures are collected in the errors map.
*/
export async function fetchCrossViewEmails(
accounts: UnifiedAccountClient[],
view: CrossView,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) =>
account.client.advancedSearchEmails(buildCrossFilter(view, ids), jmapAccountId, limit, position));
}
/**
* Text search within a cross-account view: the view filter AND a free-text
* condition, fanned out across accounts.
*/
export async function searchCrossViewEmails(
accounts: UnifiedAccountClient[],
view: CrossView,
query: string,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) =>
account.client.advancedSearchEmails(
{ operator: 'AND', conditions: [buildCrossFilter(view, ids), { text: query }] },
jmapAccountId,
limit,
position,
));
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "Pošta",
"nav_label": "Navigace",
"add_app": "Aplikace",
"scheduled": "Naplánováno"
"scheduled": "Naplánováno",
"unified_all_unread": "Vše nepřečtené",
"unified_all_starred": "Vše s hvězdičkou",
"unified_all_mail": "Veškerá pošta"
},
"protocol_handlers": {
"title": "Výchozí aplikace",
@@ -930,6 +933,18 @@
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
"open_label": "Otevřít Pro rozhraní",
"back_to_standard": "Zpět na standard"
},
"cross_unread": {
"label": "Všechny účty: Nepřečtené",
"description": "Zobrazí v sekci Všechny účty položku s nepřečtenou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
},
"cross_starred": {
"label": "Všechny účty: S hvězdičkou",
"description": "Zobrazí v sekci Všechny účty položku s poštou s hvězdičkou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
},
"cross_all": {
"label": "Všechny účty: Veškerá pošta",
"description": "Zobrazí v sekci Všechny účty položku s veškerou poštou ze všech účtů, napříč všemi složkami kromě spamu, odeslané, archivu, koše a konceptů."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archivovat",
"tag": "Štítek",
"spam": "Označit jako spam",
"not_spam": "Není spam",
"none_selected": "Nebyly vybrány žádné akce",
"mode_label": "Režim zobrazení",
"mode_inline": "Vložené",
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
"scheduled": "Planlagt"
"scheduled": "Planlagt",
"unified_all_unread": "Alle ulæste",
"unified_all_starred": "Alle med stjerne",
"unified_all_mail": "Al post"
},
"protocol_handlers": {
"title": "Standardapps",
@@ -933,6 +936,18 @@
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
"open_label": "Åbn Pro-grænsefladen",
"back_to_standard": "Tilbage til standard"
},
"cross_unread": {
"label": "Alle konti: Ulæste",
"description": "Viser en post i sektionen Alle konti med ulæst post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
},
"cross_starred": {
"label": "Alle konti: Med stjerne",
"description": "Viser en post i sektionen Alle konti med post med stjerne fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
},
"cross_all": {
"label": "Alle konti: Al post",
"description": "Viser en post i sektionen Alle konti med al post fra alle konti, på tværs af alle mapper undtagen spam, sendt, arkiv, papirkurv og kladder."
}
},
"keywords": {
@@ -1189,6 +1204,7 @@
"archive": "Arkivér",
"tag": "Tag",
"spam": "Markér som spam",
"not_spam": "Ikke spam",
"none_selected": "Ingen handlinger valgt",
"mode_label": "Visningstilstand",
"mode_inline": "Indlejret",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Geteilt",
"scheduled": "Geplant"
"scheduled": "Geplant",
"unified_all_unread": "Alle ungelesenen",
"unified_all_starred": "Alle markierten",
"unified_all_mail": "Alle Mails"
},
"protocol_handlers": {
"title": "Standard-Apps",
@@ -930,6 +933,18 @@
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
"open_label": "Pro-Oberfläche öffnen",
"back_to_standard": "Zurück zum Standard"
},
"cross_unread": {
"label": "Alle Konten: Ungelesen",
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit ungelesenen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
},
"cross_starred": {
"label": "Alle Konten: Markiert",
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit markierten Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
},
"cross_all": {
"label": "Alle Konten: Alle Mails",
"description": "Zeigt in der Sektion „All accounts“ einen Eintrag mit allen Mails über alle Konten, über alle Ordner außer Junk, Gesendet, Archiv, Papierkorb und Entwürfe."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archivieren",
"tag": "Label",
"spam": "Als Spam markieren",
"not_spam": "Kein Spam",
"none_selected": "Keine Aktionen ausgewählt",
"mode_label": "Anzeigemodus",
"mode_inline": "Eingebettet",
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "Mail",
"nav_label": "Navigation",
"add_app": "Apps",
"scheduled": "Scheduled"
"scheduled": "Scheduled",
"unified_all_unread": "All unread",
"unified_all_starred": "All starred",
"unified_all_mail": "All mail"
},
"protocol_handlers": {
"title": "Default apps",
@@ -933,6 +936,18 @@
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
"open_label": "Open Pro Interface",
"back_to_standard": "Back to standard"
},
"cross_unread": {
"label": "All accounts: Unread",
"description": "Show an entry in the All accounts section listing unread mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
},
"cross_starred": {
"label": "All accounts: Starred",
"description": "Show an entry in the All accounts section listing starred mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
},
"cross_all": {
"label": "All accounts: All mail",
"description": "Show an entry in the All accounts section listing all mail across every account, spanning all folders except junk, sent, archive, trash and drafts."
}
},
"keywords": {
@@ -1189,6 +1204,7 @@
"archive": "Archive",
"tag": "Tag",
"spam": "Mark as Spam",
"not_spam": "Not Spam",
"none_selected": "No actions selected",
"mode_label": "Display Mode",
"mode_inline": "Inline",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navegación",
"add_app": "Apps",
"shared": "Compartido",
"scheduled": "Programados"
"scheduled": "Programados",
"unified_all_unread": "Todo no leído",
"unified_all_starred": "Todo destacado",
"unified_all_mail": "Todo el correo"
},
"protocol_handlers": {
"title": "Aplicaciones predeterminadas",
@@ -930,6 +933,18 @@
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
"open_label": "Abrir interfaz Pro",
"back_to_standard": "Volver al estándar"
},
"cross_unread": {
"label": "Todas las cuentas: No leído",
"description": "Muestra una entrada en la sección Todas las cuentas con el correo no leído de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores."
},
"cross_starred": {
"label": "Todas las cuentas: Destacado",
"description": "Muestra una entrada en la sección Todas las cuentas con el correo destacado de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores."
},
"cross_all": {
"label": "Todas las cuentas: Todo el correo",
"description": "Muestra una entrada en la sección Todas las cuentas con todo el correo de todas las cuentas, abarcando todas las carpetas excepto spam, enviados, archivo, papelera y borradores."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archivar",
"tag": "Etiqueta",
"spam": "Marcar como spam",
"not_spam": "No es spam",
"none_selected": "No hay acciones seleccionadas",
"mode_label": "Modo de visualización",
"mode_inline": "En línea",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navigation",
"add_app": "Apps",
"shared": "Partagé",
"scheduled": "Planifiés"
"scheduled": "Planifiés",
"unified_all_unread": "Tous les non lus",
"unified_all_starred": "Tous les favoris",
"unified_all_mail": "Tout le courrier"
},
"protocol_handlers": {
"title": "Applications par défaut",
@@ -930,6 +933,18 @@
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
"open_label": "Ouvrir l'interface Pro",
"back_to_standard": "Retour au standard"
},
"cross_unread": {
"label": "Tous les comptes : Non lus",
"description": "Affiche une entrée dans la section Tous les comptes répertoriant les messages non lus de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons."
},
"cross_starred": {
"label": "Tous les comptes : Favoris",
"description": "Affiche une entrée dans la section Tous les comptes répertoriant les messages favoris de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons."
},
"cross_all": {
"label": "Tous les comptes : Tout le courrier",
"description": "Affiche une entrée dans la section Tous les comptes répertoriant tout le courrier de tous les comptes, couvrant tous les dossiers sauf indésirables, envoyés, archives, corbeille et brouillons."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archiver",
"tag": "Étiquette",
"spam": "Marquer comme spam",
"not_spam": "Non indésirable",
"none_selected": "Aucune action sélectionnée",
"mode_label": "Mode d'affichage",
"mode_inline": "En ligne",
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "Levelek",
"nav_label": "Navigáció",
"add_app": "Alkalmazások",
"scheduled": "Ütemezett"
"scheduled": "Ütemezett",
"unified_all_unread": "Összes olvasatlan",
"unified_all_starred": "Összes csillagozott",
"unified_all_mail": "Összes levél"
},
"protocol_handlers": {
"title": "Alapértelmezett alkalmazások",
@@ -933,6 +936,18 @@
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
"open_label": "Pro felület megnyitása",
"back_to_standard": "Vissza a szabványoshoz"
},
"cross_unread": {
"label": "Összes fiók: Olvasatlan",
"description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók olvasatlan leveleit listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően."
},
"cross_starred": {
"label": "Összes fiók: Csillagozott",
"description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók csillagozott leveleit listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően."
},
"cross_all": {
"label": "Összes fiók: Összes levél",
"description": "Megjelenít egy bejegyzést az Összes fiók szakaszban, amely az összes fiók összes levelét listázza, a levélszemét, elküldött, archívum, kuka és piszkozatok mappák kivételével minden mappára kiterjedően."
}
},
"keywords": {
@@ -1189,6 +1204,7 @@
"archive": "Archiválás",
"tag": "Címkézés",
"spam": "Megjelölés spamként",
"not_spam": "Nem spam",
"none_selected": "Nincs művelet kiválasztva",
"mode_label": "Megjelenítési mód",
"mode_inline": "Beágyazott",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navigazione",
"add_app": "App",
"shared": "Condiviso",
"scheduled": "Programmate"
"scheduled": "Programmate",
"unified_all_unread": "Tutti i non letti",
"unified_all_starred": "Tutti gli speciali",
"unified_all_mail": "Tutta la posta"
},
"protocol_handlers": {
"title": "App predefinite",
@@ -930,6 +933,18 @@
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
"open_label": "Apri interfaccia Pro",
"back_to_standard": "Torna allo standard"
},
"cross_unread": {
"label": "Tutti gli account: Non letti",
"description": "Mostra una voce nella sezione Tutti gli account che elenca la posta non letta di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze."
},
"cross_starred": {
"label": "Tutti gli account: Speciali",
"description": "Mostra una voce nella sezione Tutti gli account che elenca la posta speciale di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze."
},
"cross_all": {
"label": "Tutti gli account: Tutta la posta",
"description": "Mostra una voce nella sezione Tutti gli account che elenca tutta la posta di tutti gli account, in tutte le cartelle tranne posta indesiderata, inviata, archivio, cestino e bozze."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archivia",
"tag": "Etichetta",
"spam": "Segna come spam",
"not_spam": "Non spam",
"none_selected": "Nessuna azione selezionata",
"mode_label": "Modalità di visualizzazione",
"mode_inline": "In linea",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "ナビゲーション",
"add_app": "アプリ",
"shared": "共有",
"scheduled": "予約済み"
"scheduled": "予約済み",
"unified_all_unread": "すべての未読",
"unified_all_starred": "すべてのスター付き",
"unified_all_mail": "すべてのメール"
},
"protocol_handlers": {
"title": "既定のアプリ",
@@ -930,6 +933,18 @@
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
"open_label": "Pro インターフェイスを開く",
"back_to_standard": "標準に戻る"
},
"cross_unread": {
"label": "すべてのアカウント: 未読",
"description": "「すべてのアカウント」セクションに、すべてのアカウントの未読メールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。"
},
"cross_starred": {
"label": "すべてのアカウント: スター付き",
"description": "「すべてのアカウント」セクションに、すべてのアカウントのスター付きメールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。"
},
"cross_all": {
"label": "すべてのアカウント: すべてのメール",
"description": "「すべてのアカウント」セクションに、すべてのアカウントのすべてのメールを一覧表示する項目を表示します。迷惑メール、送信済み、アーカイブ、ゴミ箱、下書きを除くすべてのフォルダーが対象です。"
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "アーカイブ",
"tag": "ラベル",
"spam": "スパムとしてマーク",
"not_spam": "迷惑メールではない",
"none_selected": "アクションが選択されていません",
"mode_label": "表示モード",
"mode_inline": "インライン",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "내비게이션",
"add_app": "앱",
"shared": "공유됨",
"scheduled": "예약됨"
"scheduled": "예약됨",
"unified_all_unread": "모든 읽지 않음",
"unified_all_starred": "모든 별표",
"unified_all_mail": "모든 메일"
},
"protocol_handlers": {
"title": "기본 앱",
@@ -930,6 +933,18 @@
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
"open_label": "Pro 인터페이스 열기",
"back_to_standard": "표준으로 돌아가기"
},
"cross_unread": {
"label": "모든 계정: 읽지 않음",
"description": "모든 계정 섹션에 모든 계정의 읽지 않은 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다."
},
"cross_starred": {
"label": "모든 계정: 별표",
"description": "모든 계정 섹션에 모든 계정의 별표 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다."
},
"cross_all": {
"label": "모든 계정: 모든 메일",
"description": "모든 계정 섹션에 모든 계정의 모든 메일을 나열하는 항목을 표시합니다. 스팸, 보낸편지함, 보관함, 휴지통, 임시보관함을 제외한 모든 폴더가 대상입니다."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "보관",
"tag": "태그",
"spam": "스팸으로 표시",
"not_spam": "스팸 아님",
"none_selected": "선택된 액션 없음",
"mode_label": "표시 모드",
"mode_inline": "목록 안쪽",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navigācija",
"add_app": "Lietotnes",
"shared": "Koplietots",
"scheduled": "Ieplānots"
"scheduled": "Ieplānots",
"unified_all_unread": "Visi nelasītie",
"unified_all_starred": "Visi ar zvaigzni",
"unified_all_mail": "Visas vēstules"
},
"protocol_handlers": {
"title": "Noklusējuma lietotnes",
@@ -930,6 +933,18 @@
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
"open_label": "Atvērt Pro saskarni",
"back_to_standard": "Atpakaļ uz standartu"
},
"cross_unread": {
"label": "Visi konti: Nelasītie",
"description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita nelasītās vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus."
},
"cross_starred": {
"label": "Visi konti: Ar zvaigzni",
"description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita ar zvaigzni atzīmētās vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus."
},
"cross_all": {
"label": "Visi konti: Visas vēstules",
"description": "Rāda ierakstu sadaļā “Visi konti”, kas uzskaita visas vēstules no visiem kontiem, aptverot visas mapes, izņemot mēstules, nosūtītās, arhīvu, miskasti un melnrakstus."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Arhivēt",
"tag": "Tags",
"spam": "Mēstule",
"not_spam": "Nav mēstule",
"none_selected": "Nav atlasītu darbību",
"mode_label": "Attēlošanas režīms",
"mode_inline": "Iebūvēts",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navigatie",
"add_app": "Apps",
"shared": "Gedeeld",
"scheduled": "Gepland"
"scheduled": "Gepland",
"unified_all_unread": "Alle ongelezen",
"unified_all_starred": "Alle met ster",
"unified_all_mail": "Alle e-mail"
},
"protocol_handlers": {
"title": "Standaardapps",
@@ -930,6 +933,18 @@
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
"open_label": "Pro-interface openen",
"back_to_standard": "Terug naar standaard"
},
"cross_unread": {
"label": "Alle accounts: Ongelezen",
"description": "Toont een item in de sectie Alle accounts met ongelezen e-mail van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten."
},
"cross_starred": {
"label": "Alle accounts: Met ster",
"description": "Toont een item in de sectie Alle accounts met e-mail met ster van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten."
},
"cross_all": {
"label": "Alle accounts: Alle e-mail",
"description": "Toont een item in de sectie Alle accounts met alle e-mail van alle accounts, in alle mappen behalve ongewenst, verzonden, archief, prullenbak en concepten."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archiveren",
"tag": "Label",
"spam": "Markeer als spam",
"not_spam": "Geen spam",
"none_selected": "Geen acties geselecteerd",
"mode_label": "Weergavemodus",
"mode_inline": "Inline",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Nawigacja",
"add_app": "Aplikacje",
"shared": "Udostępnione",
"scheduled": "Zaplanowane"
"scheduled": "Zaplanowane",
"unified_all_unread": "Wszystkie nieprzeczytane",
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
"unified_all_mail": "Cała poczta"
},
"protocol_handlers": {
"title": "Aplikacje domyślne",
@@ -930,6 +933,18 @@
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
"open_label": "Otwórz interfejs Pro",
"back_to_standard": "Powrót do standardu"
},
"cross_unread": {
"label": "Wszystkie konta: Nieprzeczytane",
"description": "Pokazuje pozycję w sekcji Wszystkie konta z nieprzeczytaną pocztą ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych."
},
"cross_starred": {
"label": "Wszystkie konta: Oznaczone gwiazdką",
"description": "Pokazuje pozycję w sekcji Wszystkie konta z pocztą oznaczoną gwiazdką ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych."
},
"cross_all": {
"label": "Wszystkie konta: Cała poczta",
"description": "Pokazuje pozycję w sekcji Wszystkie konta z całą pocztą ze wszystkich kont, obejmującą wszystkie foldery oprócz spamu, wysłanych, archiwum, kosza i roboczych."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Archiwizuj",
"tag": "Etykieta",
"spam": "Oznacz jako spam",
"not_spam": "Nie spam",
"none_selected": "Nie wybrano żadnych akcji",
"mode_label": "Tryb wyświetlania",
"mode_inline": "Wbudowany",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Navegação",
"add_app": "Apps",
"shared": "Compartilhado",
"scheduled": "Agendados"
"scheduled": "Agendados",
"unified_all_unread": "Tudo não lido",
"unified_all_starred": "Tudo com estrela",
"unified_all_mail": "Todo o correio"
},
"protocol_handlers": {
"title": "Aplicativos padrão",
@@ -930,6 +933,18 @@
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
"open_label": "Abrir interface Pro",
"back_to_standard": "Voltar ao padrão"
},
"cross_unread": {
"label": "Todas as contas: Não lido",
"description": "Mostra uma entrada na seção Todas as contas listando o correio não lido de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos."
},
"cross_starred": {
"label": "Todas as contas: Com estrela",
"description": "Mostra uma entrada na seção Todas as contas listando o correio com estrela de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos."
},
"cross_all": {
"label": "Todas as contas: Todo o correio",
"description": "Mostra uma entrada na seção Todas as contas listando todo o correio de todas as contas, abrangendo todas as pastas exceto spam, enviados, arquivo, lixeira e rascunhos."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Arquivar",
"tag": "Etiqueta",
"spam": "Marcar como spam",
"not_spam": "Não é spam",
"none_selected": "Nenhuma ação selecionada",
"mode_label": "Modo de exibição",
"mode_inline": "Em linha",
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "E-mail",
"nav_label": "Navigare",
"add_app": "Aplicații",
"scheduled": "Programat"
"scheduled": "Programat",
"unified_all_unread": "Toate necitite",
"unified_all_starred": "Toate cu stea",
"unified_all_mail": "Toată poșta"
},
"protocol_handlers": {
"title": "Aplicații implicite",
@@ -933,6 +936,18 @@
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
"open_label": "Interfața Open Pro",
"back_to_standard": "Înapoi la standard"
},
"cross_unread": {
"label": "Toate conturile: Necitite",
"description": "Afișează în secțiunea Toate conturile o intrare cu poșta necitită din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne."
},
"cross_starred": {
"label": "Toate conturile: Cu stea",
"description": "Afișează în secțiunea Toate conturile o intrare cu poșta cu stea din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne."
},
"cross_all": {
"label": "Toate conturile: Toată poșta",
"description": "Afișează în secțiunea Toate conturile o intrare cu toată poșta din toate conturile, în toate folderele cu excepția spam, trimise, arhivă, coș și ciorne."
}
},
"keywords": {
@@ -1189,6 +1204,7 @@
"archive": "Arhivează",
"tag": "Etichetă",
"spam": "Marcați ca spam",
"not_spam": "Nu este spam",
"none_selected": "Nu sunt selectate acțiuni",
"mode_label": "Modul de afișare",
"mode_inline": "În text",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Навигация",
"add_app": "Приложения",
"shared": "Общие",
"scheduled": "Запланировано"
"scheduled": "Запланировано",
"unified_all_unread": "Все непрочитанные",
"unified_all_starred": "Все помеченные",
"unified_all_mail": "Вся почта"
},
"protocol_handlers": {
"title": "Приложения по умолчанию",
@@ -930,6 +933,18 @@
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
"open_label": "Открыть Pro-интерфейс",
"back_to_standard": "К стандартному"
},
"cross_unread": {
"label": "Все аккаунты: Непрочитанные",
"description": "Показывает в разделе «Все аккаунты» запись с непрочитанными письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков."
},
"cross_starred": {
"label": "Все аккаунты: Помеченные",
"description": "Показывает в разделе «Все аккаунты» запись с помеченными письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков."
},
"cross_all": {
"label": "Все аккаунты: Вся почта",
"description": "Показывает в разделе «Все аккаунты» запись со всеми письмами из всех аккаунтов, по всем папкам, кроме спама, отправленных, архива, корзины и черновиков."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Архивировать",
"tag": "Тег",
"spam": "Отметить как спам",
"not_spam": "Не спам",
"none_selected": "Действия не выбраны",
"mode_label": "Режим отображения",
"mode_inline": "Встроенный",
+17 -1
View File
@@ -134,7 +134,10 @@
"mail": "Posta",
"nav_label": "Gezinme",
"add_app": "Uygulamalar",
"scheduled": "Zamanlandı"
"scheduled": "Zamanlandı",
"unified_all_unread": "Tüm okunmamışlar",
"unified_all_starred": "Tüm yıldızlılar",
"unified_all_mail": "Tüm postalar"
},
"protocol_handlers": {
"title": "Varsayılan uygulamalar",
@@ -930,6 +933,18 @@
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
"open_label": "Pro Arayüzü Aç",
"back_to_standard": "Standarda dön"
},
"cross_unread": {
"label": "Tüm hesaplar: Okunmamış",
"description": "Tüm hesaplar bölümünde, tüm hesaplardaki okunmamış postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar."
},
"cross_starred": {
"label": "Tüm hesaplar: Yıldızlı",
"description": "Tüm hesaplar bölümünde, tüm hesaplardaki yıldızlı postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar."
},
"cross_all": {
"label": "Tüm hesaplar: Tüm postalar",
"description": "Tüm hesaplar bölümünde, tüm hesaplardaki tüm postaları listeleyen bir giriş gösterir; önemsiz, gönderilmiş, arşiv, çöp ve taslaklar dışındaki tüm klasörleri kapsar."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Arşivle",
"tag": "Etiketle",
"spam": "Spam Olarak İşaretle",
"not_spam": "Önemsiz değil",
"none_selected": "Hiç işlem seçilmedi",
"mode_label": "Görüntüleme Modu",
"mode_inline": "Satır içi",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "Навігація",
"add_app": "програми",
"shared": "Спільні",
"scheduled": "Заплановано"
"scheduled": "Заплановано",
"unified_all_unread": "Усі непрочитані",
"unified_all_starred": "Усі позначені",
"unified_all_mail": "Уся пошта"
},
"protocol_handlers": {
"title": "Програми за замовчуванням",
@@ -930,6 +933,18 @@
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
"open_label": "Відкрити Pro-інтерфейс",
"back_to_standard": "Назад до стандарту"
},
"cross_unread": {
"label": "Усі облікові записи: Непрочитані",
"description": "Показує у розділі «Усі облікові записи» запис із непрочитаними листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток."
},
"cross_starred": {
"label": "Усі облікові записи: Позначені",
"description": "Показує у розділі «Усі облікові записи» запис із позначеними листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток."
},
"cross_all": {
"label": "Усі облікові записи: Уся пошта",
"description": "Показує у розділі «Усі облікові записи» запис з усіма листами з усіх облікових записів, по всіх теках, окрім спаму, надісланих, архіву, кошика та чернеток."
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "Архів",
"tag": "Тег",
"spam": "Позначити як спам",
"not_spam": "Не спам",
"none_selected": "Дії не вибрано",
"mode_label": "Режим відображення",
"mode_inline": "Вбудований",
+17 -1
View File
@@ -134,7 +134,10 @@
"nav_label": "导航",
"add_app": "应用",
"shared": "共享",
"scheduled": "已计划"
"scheduled": "已计划",
"unified_all_unread": "全部未读",
"unified_all_starred": "全部加星标",
"unified_all_mail": "全部邮件"
},
"protocol_handlers": {
"title": "默认应用",
@@ -930,6 +933,18 @@
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
"open_label": "打开 Pro 界面",
"back_to_standard": "返回标准"
},
"cross_unread": {
"label": "所有账户:未读",
"description": "在“所有账户”部分显示一个条目,列出所有账户的未读邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。"
},
"cross_starred": {
"label": "所有账户:加星标",
"description": "在“所有账户”部分显示一个条目,列出所有账户的加星标邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。"
},
"cross_all": {
"label": "所有账户:全部邮件",
"description": "在“所有账户”部分显示一个条目,列出所有账户的全部邮件,涵盖除垃圾邮件、已发送、归档、废纸篓和草稿之外的所有文件夹。"
}
},
"keywords": {
@@ -1186,6 +1201,7 @@
"archive": "归档",
"tag": "标签",
"spam": "标记为垃圾邮件",
"not_spam": "非垃圾邮件",
"none_selected": "未选择任何操作",
"mode_label": "显示模式",
"mode_inline": "内嵌",
@@ -64,10 +64,12 @@ describe('unified-view single-email action routing (#281)', () => {
activeClient = makeClient();
accountBClient = makeClient();
// Route account-b to its own client; account-a falls back to the active one.
// Route each login by its AccountEntry.id (the `sourceClientAccountId` key).
// account-a is the active login; account-b is a second direct login; the
// active login (account-a) also delegates access to the shared owner 'owner-x'.
useAuthStore.setState({
getClientForAccount: (id: string) =>
(id === 'account-b' ? accountBClient : undefined) as never,
(id === 'account-b' ? accountBClient : id === 'account-a' ? activeClient : undefined) as never,
} as never);
useEmailStore.setState({
@@ -76,18 +78,27 @@ describe('unified-view single-email action routing (#281)', () => {
viewingAccountId: null,
selectedMailbox: '',
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
// Owner mailbox lists are cached by their JMAP id (`sourceAccountId`).
accountMailboxes: {
'account-a': [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
'account-b': [
makeMailbox({ id: 'b-inbox', role: 'inbox' }),
makeMailbox({ id: 'b-archive', name: 'Archive', role: 'archive' }),
],
// Shared owner reached through account-a's client.
'owner-x': [
makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' }),
makeMailbox({ id: 'owner-x:x-trash', originalId: 'x-trash', name: 'Trash', role: 'trash', isShared: true, accountId: 'owner-x' }),
],
},
processingReadStatus: new Set(),
selectedEmail: null,
selectedEmailIds: new Set(),
emails: [
makeEmail({ id: 'email-b', accountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }),
// Second direct login: sourceClientAccountId === sourceAccountId === 'account-b'.
makeEmail({ id: 'email-b', accountId: 'account-b', sourceClientAccountId: 'account-b', sourceAccountId: 'account-b', keywords: {}, mailboxIds: { 'b-inbox': true } }),
// Shared/group source: reached via account-a's client, owned by 'owner-x'.
makeEmail({ id: 'email-shared', accountId: 'owner-x', sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x', keywords: {}, mailboxIds: { 'owner-x:x-inbox': true } }),
],
});
});
@@ -95,24 +106,36 @@ describe('unified-view single-email action routing (#281)', () => {
it('routes markAsRead to the emails account client', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-b', true);
expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, undefined);
expect(accountBClient.markAsRead).toHaveBeenCalledWith('email-b', true, 'account-b');
expect(activeClient.markAsRead).not.toHaveBeenCalled();
});
it('routes toggleStar to the emails account client', async () => {
it('routes toggleStar to the emails account client with the owner accountId', async () => {
await useEmailStore.getState().toggleStar(activeClient, 'email-b');
expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true);
expect(accountBClient.toggleStar).toHaveBeenCalledWith('email-b', true, 'account-b');
expect(activeClient.toggleStar).not.toHaveBeenCalled();
});
it('routes moveToMailbox to the emails account client with that accounts destination', async () => {
await useEmailStore.getState().moveToMailbox(activeClient, 'email-b', 'b-archive');
expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', undefined);
expect(accountBClient.moveEmail).toHaveBeenCalledWith('email-b', 'b-archive', 'account-b');
expect(activeClient.moveEmail).not.toHaveBeenCalled();
});
it('routes a shared/group email through the delegating login client + owner accountId', async () => {
await useEmailStore.getState().markAsRead(activeClient, 'email-shared', true);
// Reached via account-a's client (the active one), targeting the owner account.
expect(activeClient.markAsRead).toHaveBeenCalledWith('email-shared', true, 'owner-x');
expect(accountBClient.markAsRead).not.toHaveBeenCalled();
});
it('stars a shared/group email via the delegating client + owner accountId', async () => {
await useEmailStore.getState().toggleStar(activeClient, 'email-shared');
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
});
it('still uses the active/passed client outside unified view', async () => {
useEmailStore.setState({
isUnifiedView: false,
@@ -1,6 +1,7 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import type { Mailbox } from '@/lib/jmap/types';
import { UNIFIED_MAILBOX_IDS } from '@/lib/jmap/types';
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
return {
@@ -266,4 +267,41 @@ describe('email-store folder management', () => {
expect(useEmailStore.getState().error).toBe('Role update failed');
});
});
// Regression: a background fetchMailboxes (e.g. push-driven after deleting
// drafts in "All Drafts") must not reset a virtual unified/cross-view selection
// to the inbox, which would jump the user out of the view they're in.
describe('fetchMailboxes selection preservation', () => {
it('keeps a unified-view selection (e.g. All Drafts) on background refresh', async () => {
useEmailStore.setState({
mailboxes: [inbox, sent, trash, custom],
selectedMailbox: UNIFIED_MAILBOX_IDS.drafts,
isUnifiedView: true,
});
// Fresh list (not initial load) that does NOT contain the virtual id.
const client = makeMockClient({
getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash, custom]),
});
await useEmailStore.getState().fetchMailboxes(client);
expect(useEmailStore.getState().selectedMailbox).toBe(UNIFIED_MAILBOX_IDS.drafts);
});
it('still falls back to inbox when a real selection no longer exists', async () => {
useEmailStore.setState({
mailboxes: [inbox, sent, trash, custom],
selectedMailbox: 'custom-1',
isUnifiedView: false,
});
// custom-1 is gone from the refreshed list.
const client = makeMockClient({
getAllMailboxes: vi.fn().mockResolvedValue([inbox, sent, trash]),
});
await useEmailStore.getState().fetchMailboxes(client);
expect(useEmailStore.getState().selectedMailbox).toBe('inbox-1');
});
});
});
+366 -111
View File
@@ -1,13 +1,13 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import type { UnifiedMailboxRole } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import type { ExternalSearchResult } from "@/lib/plugin-types";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, resolveSourceFolderName, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -77,8 +77,14 @@ interface EmailStore {
// Unified mailbox state
isUnifiedView: boolean;
unifiedRole: UnifiedMailboxRole | null;
// Cross-account view ('unread' | 'starred' | 'all') when active; null for the
// per-role unified views. Mutually exclusive with unifiedRole; both run under
// isUnifiedView.
crossView: CrossView | null;
unifiedErrors: Map<string, string>; // accountId -> error message
unifiedCounts: UnifiedMailboxCounts[];
// Unread total across the cross-view included folders (badge for unread/all).
crossUnreadCount: number;
// Scheduled send state
scheduledEmails: ScheduledEmail[];
@@ -181,9 +187,10 @@ interface EmailStore {
batchArchive: (client: IJMAPClient) => Promise<void>;
// Spam operations
// `sourceAccountId` (when set) is the unified-view email's owning account,
// used to route the undo back to the right account client. (#281)
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceAccountId?: string }>;
// For unified-view emails the undo must hit the same account: `accountId` is the
// owning JMAP account (passed to JMAP), `sourceClientAccountId` is the login the
// email is reachable through (used to pick the right client). (#281)
spamUndoCache: Map<string, { emailId: string; originalMailboxId: string; accountId?: string; sourceClientAccountId?: string }>;
markAsSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
undoSpam: (client: IJMAPClient, emailId: string) => Promise<void>;
batchMarkAsSpam: (client: IJMAPClient, emailIds: string[]) => Promise<void>;
@@ -217,6 +224,9 @@ interface EmailStore {
loadMoreUnifiedEmails: (accounts: UnifiedAccountClient[]) => Promise<void>;
refreshUnifiedCounts: (accounts: UnifiedAccountClient[]) => Promise<void>;
exitUnifiedView: () => void;
// Cross-account view operations (unread / starred / all)
fetchCrossView: (accounts: UnifiedAccountClient[], view: CrossView) => Promise<void>;
refreshCrossCounts: (accounts: UnifiedAccountClient[]) => void;
fetchScheduledEmails: (client: IJMAPClient) => Promise<void>;
loadMoreScheduledEmails: (client: IJMAPClient) => Promise<void>;
@@ -350,33 +360,39 @@ function buildAllMailFilter(jmapMailboxIds: string[]): Record<string, unknown> {
* Resolves the JMAP client, mailbox list, and JMAP accountId to use for a
* single-email action.
*
* In unified view each email carries the `accountId` of the account it came
* from. The mutation must be routed to that account's own client/session, or it
* is sent to the active account whose server doesn't know the id, so JMAP
* `Email/set` silently returns `notUpdated` and the change is lost on the next
* reload (issue #281). The per-account client already targets the owning
* account, so no explicit JMAP `accountId` override is needed, and its cached
* mailbox list (populated by `buildUnifiedAccountClients`) is used to resolve
* role-based destinations like trash/archive.
* In aggregate views each email is decorated with its source reference:
* `sourceClientAccountId` (the logged-in client it is reachable through) and
* `sourceAccountId` (the owning JMAP account). The mutation must be routed to
* that client/account, or it is sent to the active account whose server doesn't
* know the id, so JMAP `Email/set` silently returns `notUpdated` and the change
* is lost on the next reload (issue #281). We always pass `sourceAccountId` as
* the JMAP accountId: for personal sources it equals the client's primary (a
* no-op, no namespacing), for shared/group sources it targets the owner. The
* owner's mailbox list (cached by `buildUnifiedAccountClients` under that JMAP
* id) resolves role-based destinations like trash/archive.
*
* For the normal single-account / viewing-account flow this preserves the
* existing behavior exactly: the active/viewing client, its mailbox list, and
* the shared-mailbox accountId derived from the currently selected mailbox.
*/
function resolveEmailActionContext(
email: { accountId?: string },
email: { sourceClientAccountId?: string; sourceAccountId?: string },
passedClient: IJMAPClient,
): { client: IJMAPClient; mailboxes: Mailbox[]; accountId: string | undefined } {
const state = useEmailStore.getState();
if (state.isUnifiedView && email.accountId) {
const perAccountClient = useAuthStore.getState().getClientForAccount(email.accountId);
if (perAccountClient) {
return {
client: perAccountClient,
mailboxes: state.accountMailboxes[email.accountId] ?? state.mailboxes,
accountId: undefined,
};
}
// In aggregate views every email is decorated with its source reference:
// `sourceClientAccountId` (the login client it is reachable through) and
// `sourceAccountId` (the owning JMAP account). These are unambiguous across
// personal and shared/group sources, so resolution is the same three lines for
// both - no id-space guessing, no capability scan. For personal sources
// `sourceAccountId` equals the client's primary, so passing it to JMAP is a
// no-op (matches the previous `accountId: undefined` behavior exactly).
if (state.isUnifiedView && email.sourceClientAccountId && email.sourceAccountId) {
return {
client: useAuthStore.getState().getClientForAccount(email.sourceClientAccountId) ?? resolveActionClient(passedClient),
mailboxes: state.accountMailboxes[email.sourceAccountId] ?? state.mailboxes,
accountId: email.sourceAccountId,
};
}
const mailboxes = resolveActionMailboxes();
const currentMailbox = mailboxes.find((mb) => mb.id === state.selectedMailbox);
@@ -418,8 +434,16 @@ export async function buildUnifiedAccountClients(
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 });
// Primary JMAP account id of this login. Stamped onto personal emails as
// `sourceAccountId`; equals the client's primary so passing it to JMAP is a
// no-op (no namespacing) — keeps personal behavior identical while making
// resolution branch-free against shared sources.
const primaryJmapId = c.getAccountId();
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false });
fetchedMailboxes[a.id] = ownMailboxes;
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
// resolves uniformly for personal and shared sources alike.
fetchedMailboxes[primaryJmapId] = ownMailboxes;
if (includeGroup) {
const sharedByOwner = new Map<string, Mailbox[]>();
@@ -436,8 +460,14 @@ export async function buildUnifiedAccountClients(
accountLabel: label,
client: c,
mailboxes: ownerMailboxes,
clientAccountId: a.id,
jmapAccountId: ownerId,
isShared: true,
});
// Cache the owner's mailbox list keyed by its JMAP id so single-email
// and batch actions can resolve role-based destinations (trash/archive)
// in the owner account instead of falling back to the active account.
fetchedMailboxes[ownerId] = ownerMailboxes;
}
}
} catch {
@@ -475,6 +505,23 @@ async function refreshMailboxesForViewingAccount(fallbackClient: IJMAPClient): P
}
}
// Whether an email belongs to a given mailbox, for local counter math.
// Shared/group-account emails carry NAMESPACED mailboxIds (`${ownerId}:${origId}`,
// which equals the shared mailbox's `id`), while own-account emails carry bare ids
// (equal to both `id` and `originalId`). Matching `mailbox.id` covers both; the
// `originalId` fallback is restricted to non-shared mailboxes so a bare own-account
// id can't collide with another account's shared folder. (#281)
function emailInMailbox(
email: { mailboxIds?: Record<string, boolean> },
mailbox: Mailbox,
): boolean {
const ids = email.mailboxIds;
if (!ids) return false;
if (ids[mailbox.id]) return true;
if (!mailbox.isShared && mailbox.originalId) return !!ids[mailbox.originalId];
return false;
}
// Find the trash mailbox for a given account scope. Prefers JMAP role, but
// falls back to name matching ("trash" / "deleted") so users with custom or
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
@@ -538,8 +585,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Unified mailbox state
isUnifiedView: false,
unifiedRole: null,
crossView: null,
unifiedErrors: new Map(),
unifiedCounts: [],
crossUnreadCount: 0,
// Scheduled send state
scheduledEmails: [],
@@ -575,7 +624,19 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}),
fetchAccountMailboxes: async (client, accountId) => {
try {
const mailboxes = await client.getMailboxes();
// `accountId` is overloaded across callers:
// - a real login (AccountEntry.id, e.g. per-account sidebar / cross-account
// move) → `client` is that account's own login; getMailboxes() returns the
// right list.
// - a JMAP account id with no own login (a shared/group owner, used by the
// unified archive refresh) → must fetch by that JMAP id through the
// delegating client, else we'd cache the delegating account's own folders
// under the owner key.
// Distinguish by whether a directly-logged-in client exists for the id.
const hasOwnLogin = !!useAuthStore.getState().getClientForAccount(accountId);
const mailboxes = hasOwnLogin
? await client.getMailboxes()
: await client.getMailboxes(accountId);
// Re-check the cache after the await to avoid stomping a more recent
// fetch that finished while this one was in flight.
set((state) => ({
@@ -694,6 +755,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const currentSelectedMailbox = get().selectedMailbox;
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|| currentSelectedMailbox === ALL_MAIL_MAILBOX_ID
// Unified per-role views (All Inbox/Drafts/Junk/…) and cross-account views
// (All unread/starred/all) use a virtual id not present in the fetched
// list. A background refresh after a delete must not clobber it and jump
// the user back to the inbox.
|| isUnifiedMailboxId(currentSelectedMailbox)
|| isCrossViewId(currentSelectedMailbox)
|| (currentSelectedMailbox && mailboxes.some(m => m.id === currentSelectedMailbox));
const loadingPatch = isInitialLoad ? { isLoading: false } : {};
if (!selectionValid) {
@@ -761,6 +828,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const result = await resolveActionClient(client).advancedSearchEmails(
buildAllMailFilter(jmapIds), undefined, emailsPerPage, 0,
);
const allMailMailboxes = resolveActionMailboxes();
for (const email of result.emails) {
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
}
set({
emails: annotateScheduledEmails(result.emails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
@@ -814,11 +885,43 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
loadMoreEmails: async (client) => {
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole } = get();
const { isLoadingMore, hasMoreEmails, emails, selectedMailbox, searchQuery, selectedKeyword, isUnifiedView, unifiedRole, crossView } = get();
// Don't load if already loading or no more emails
if (isLoadingMore || !hasMoreEmails) return;
// Cross-account views fan out across all accounts' included folders. Paginate
// via the cross-view loader (search-aware), mirroring the unified branch.
if (isUnifiedView && crossView) {
set({ isLoadingMore: true, error: null });
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const position = emails.length;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = searchQuery
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
set({
emails: [...currentEmails, ...newEmails],
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoadingMore: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to load more cross-account emails:', error);
set({
error: error instanceof Error ? error.message : "Failed to load more emails",
isLoadingMore: false,
});
}
return;
}
// Unified view uses a different fan-out loader. When a search query or
// advanced filter is active we paginate the unified search instead of the
// unified browse, so "load more" matches what's on screen.
@@ -922,6 +1025,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined);
}
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
const allMailMailboxes = resolveActionMailboxes();
for (const email of result.emails) {
email.sourceFolder = resolveSourceFolderName(email, allMailMailboxes);
}
}
// Use fresh state when merging to avoid overwriting concurrent updates
// (e.g. refreshCurrentMailbox running during the load)
const currentEmails = get().emails;
@@ -952,15 +1062,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
fetchEmailContent: async (client, emailId) => {
try {
// Find the selected mailbox to determine accountId (for shared folders)
const selectedMailboxId = get().selectedMailbox;
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailboxId);
// Route to the owning account. In aggregate views (All Mail, unified,
// cross-account) the selected mailbox is virtual, so derive the client +
// accountId from the email itself (handles shared/group accounts); fall
// back to the selected-mailbox shared-folder logic for normal views.
const listEmail = get().emails.find(e => e.id === emailId);
let actionClient: IJMAPClient;
let accountId: string | undefined;
if (listEmail) {
({ client: actionClient, accountId } = resolveEmailActionContext(listEmail, client));
} else {
const mailbox = resolveActionMailboxes().find(mb => mb.id === get().selectedMailbox);
actionClient = resolveActionClient(client);
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
}
// Only pass accountId for shared mailboxes
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const email = await resolveActionClient(client).getEmail(emailId, accountId);
const email = await actionClient.getEmail(emailId, accountId);
if (email) {
const annotatedEmail = annotateScheduledEmail(email, get().scheduledSubmissionByEmailId);
@@ -1049,8 +1166,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// In unified view it comes from the email's own folders (matching the
// unified role), not the active account's selected mailbox.
const currentMailbox = get().isUnifiedView
? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole)
?? mailboxes.find(mb => email.mailboxIds?.[mb.id]))
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
?? mailboxes.find(mb => emailInMailbox(email, mb)))
: mailboxes.find(mb => mb.id === get().selectedMailbox);
// If in junk folder and setting is enabled, permanently delete
@@ -1079,7 +1196,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Update counters for source mailbox (email leaving)
if (email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
if (emailInMailbox(email, mailbox)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
@@ -1126,7 +1243,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// If the email was unread, decrement the unread counters
if (isUnread && email.mailboxIds) {
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
if (emailInMailbox(email, mailbox)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
@@ -1140,7 +1257,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} else if (email.mailboxIds) {
// If email was read, only decrement total counters
updatedMailboxes = state.mailboxes.map(mailbox => {
if (email.mailboxIds[mailbox.id]) {
if (emailInMailbox(email, mailbox)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
@@ -1179,7 +1296,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
// Check if already in the desired state
const isCurrentlyRead = email.keywords?.$seen === true;
const isCurrentlyRead = email.keywords?.$seen;
if (isCurrentlyRead === read) {
return; // Already in desired state
}
@@ -1205,14 +1322,16 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const emailInState = state.emails.find(e => e.id === emailId);
if (!emailInState) return { processingReadStatus: newProcessingSet };
const wasRead = emailInState.keywords?.$seen === true;
const wasRead = emailInState.keywords?.$seen;
if (wasRead === read) {
return { processingReadStatus: newProcessingSet }; // State unchanged, skip counter update
}
const updatedMailboxes = state.mailboxes.map(mailbox => {
// Check if this email belongs to this mailbox
if (emailInState.mailboxIds && emailInState.mailboxIds[mailbox.id]) {
// Check if this email belongs to this mailbox. Shared mailboxes are
// stored under a namespaced id but the email's mailboxIds are keyed by
// the owner-side JMAP id (originalId), so match on originalId first.
if (emailInMailbox(emailInState, mailbox)) {
// Adjust unread counter: -1 if marking as read, +1 if marking as unread
const delta = read ? -1 : 1;
return {
@@ -1285,7 +1404,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set((state) => {
const updatedMailboxes = state.mailboxes.map(mailbox => {
if (currentMailboxIds.includes(mailbox.id)) {
if (currentMailboxIds.includes(mailbox.id) || (!mailbox.isShared && mailbox.originalId ? currentMailboxIds.includes(mailbox.originalId) : false)) {
return {
...mailbox,
totalEmails: Math.max(0, mailbox.totalEmails - 1),
@@ -1336,17 +1455,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const affected = emails.filter(e => idSet.has(e.id));
if (isUnifiedView) {
// In unified view, emails may span accounts group and dispatch per-account.
const byAccount = new Map<string, string[]>();
// In unified view, emails may span accounts group by owning JMAP account
// and dispatch through the login client that can reach each one. The login
// client is keyed by `sourceClientAccountId` (a real AccountEntry.id); the
// owning account is `sourceAccountId` (passed for owner-scoped routing).
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const e of affected) {
const acct = e.accountId || '__default__';
if (!byAccount.has(acct)) byAccount.set(acct, []);
byAccount.get(acct)!.push(e.id);
const key = e.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: e.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(e.id);
}
await Promise.all(Array.from(byAccount.entries()).map(async ([acct, ids]) => {
const acctClient = acct === '__default__' ? client : useAuthStore.getState().getClientForAccount(acct);
await Promise.all(Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
const acctClient = sourceAccountId === '__default__'
? resolveActionClient(client)
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, jmapDestId);
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
}));
} else {
const currentMailbox = mailboxes.find(mb => mb.id === selectedMailbox);
@@ -1372,7 +1497,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
return next;
})(),
mailboxes: state.mailboxes.map(mb => {
if (sourceMailboxIds.has(mb.id)) {
if (sourceMailboxIds.has(mb.id) || (!mb.isShared && mb.originalId ? sourceMailboxIds.has(mb.originalId) : false)) {
return {
...mb,
totalEmails: Math.max(0, mb.totalEmails - movedCount),
@@ -1559,9 +1684,25 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
searchEmails: async (client, query) => {
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
try {
const { isUnifiedView, unifiedRole } = get();
const { isUnifiedView, unifiedRole, crossView } = get();
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, query, emailsPerPage, 0);
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
@@ -1611,7 +1752,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
},
advancedSearch: async (client) => {
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole } = get();
const { searchQuery, searchFilters, selectedMailbox, searchAbortController, isUnifiedView, unifiedRole, crossView } = get();
const mailboxes = resolveActionMailboxes();
if (searchAbortController) {
@@ -1631,6 +1772,23 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
const result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
if (controller.signal.aborted) return;
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query: searchQuery, filters: searchFilters });
set({
emails: result.emails,
externalSearchResults: externals,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
return;
}
if (isUnifiedView && unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
@@ -1709,9 +1867,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (!email) return;
const isFlagged = email.keywords.$flagged || false;
// In unified view route to the email's own account client. (#281)
const { client: actionClient } = resolveEmailActionContext(email, client);
await actionClient.toggleStar(emailId, !isFlagged);
// In unified view route to the email's own account client + owner accountId
// (the reaching client's primary is not the owner for shared sources). (#281)
const { client: actionClient, accountId } = resolveEmailActionContext(email, client);
await actionClient.toggleStar(emailId, !isFlagged, accountId);
// Update local state
set((state) => ({
@@ -1752,19 +1911,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const emailIdsArray = Array.from(selectedEmailIds);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
// Group by owning JMAP account; dispatch through the reaching login client.
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
const key = email?.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
const acctClient = sourceAccountId === '__default__'
? resolveActionClient(client)
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
if (!acctClient) return;
await acctClient.batchMarkAsRead(ids, read);
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
await acctClient.batchMarkAsRead(ids, read, jmapAccountId);
});
await Promise.allSettled(promises);
} else {
@@ -1783,8 +1945,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const updatedMailboxes = mailboxes.map(mailbox => {
let deltaUnread = 0;
affectedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
const wasRead = email.keywords?.$seen === true;
if (emailInMailbox(email, mailbox)) {
const wasRead = email.keywords?.$seen;
if (wasRead !== read) {
deltaUnread += read ? -1 : 1;
}
@@ -1829,46 +1991,56 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const forceDestroy = permanent || isInTrash || (isInJunk && permanentlyDeleteJunk);
const alsoMarkRead = useSettingsStore.getState().deleteAction === 'trash-and-read';
// Group emails by accountId (handles unified view and search results spanning accounts).
const emailsByAccount = new Map<string, string[]>();
// Group emails by owning JMAP account (handles unified view and search results
// spanning accounts). Each group resolves the reaching login client via
// `sourceClientAccountId` and routes JMAP via `sourceAccountId`. Undecorated
// emails (normal single-mailbox view) fall into '__default__' = active client.
const accountMailboxes = get().accountMailboxes;
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
const key = email?.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(emailId);
}
const getClient = (acctId: string) =>
acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
const getClient = (sourceAccountId: string, clientAccountId?: string) =>
sourceAccountId === '__default__'
? resolveActionClient(client)
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
const mailboxesFor = (sourceAccountId: string) =>
sourceAccountId === '__default__' ? mailboxes : (accountMailboxes[sourceAccountId] ?? mailboxes);
const jmapIdFor = (sourceAccountId: string) =>
sourceAccountId === '__default__' ? undefined : sourceAccountId;
if (forceDestroy) {
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
const acctClient = getClient(sourceAccountId, clientAccountId);
if (!acctClient) return;
await acctClient.batchDeleteEmails(ids);
await acctClient.batchDeleteEmails(ids, jmapIdFor(sourceAccountId));
});
await Promise.allSettled(promises);
} else {
// Move to trash per account.
const failedAccounts: string[] = [];
const movedEmailIds = new Set<string>();
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = getClient(acctId);
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
const acctClient = getClient(sourceAccountId, clientAccountId);
if (!acctClient) {
failedAccounts.push(acctId);
failedAccounts.push(sourceAccountId);
return;
}
const trashMailbox = findTrashMailbox(mailboxes, {
accountId: acctId === '__default__' ? undefined : acctId,
const trashMailbox = findTrashMailbox(mailboxesFor(sourceAccountId), {
accountId: jmapIdFor(sourceAccountId),
});
if (!trashMailbox) {
// No trash for this account: skip rather than silently destroying.
// The user asked to move to trash, not permanently delete.
failedAccounts.push(acctId);
failedAccounts.push(sourceAccountId);
return;
}
const trashId = trashMailbox.originalId || trashMailbox.id;
await acctClient.batchMoveEmails(ids, trashId, trashMailbox.accountId, alsoMarkRead);
await acctClient.batchMoveEmails(ids, trashId, jmapIdFor(sourceAccountId), alsoMarkRead);
ids.forEach(id => movedEmailIds.add(id));
});
await Promise.allSettled(promises);
@@ -1886,7 +2058,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let deltaTotalEmails = 0;
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
if (emailInMailbox(email, mailbox)) {
deltaTotalEmails--;
if (!email.keywords?.$seen) deltaUnreadEmails--;
}
@@ -1921,7 +2093,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
let deltaUnreadEmails = 0;
deletedEmails.forEach(email => {
if (email.mailboxIds?.[mailbox.id]) {
if (emailInMailbox(email, mailbox)) {
deltaTotalEmails--;
if (!email.keywords?.$seen) {
deltaUnreadEmails--;
@@ -1962,19 +2134,24 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const emailIdsArray = Array.from(selectedEmailIds);
if (get().isUnifiedView) {
// Group emails by accountId for cross-account operations
const emailsByAccount = new Map<string, string[]>();
// Group by owning JMAP account; dispatch through the reaching login client.
const destMailbox = resolveActionMailboxes().find(mb => mb.id === toMailboxId);
const jmapDestId = destMailbox?.originalId || toMailboxId;
const bySource = new Map<string, { clientAccountId?: string; ids: string[] }>();
for (const emailId of emailIdsArray) {
const email = emails.find(e => e.id === emailId);
const acctId = email?.accountId || '__default__';
if (!emailsByAccount.has(acctId)) emailsByAccount.set(acctId, []);
emailsByAccount.get(acctId)!.push(emailId);
const key = email?.sourceAccountId || '__default__';
if (!bySource.has(key)) bySource.set(key, { clientAccountId: email?.sourceClientAccountId, ids: [] });
bySource.get(key)!.ids.push(emailId);
}
const promises = Array.from(emailsByAccount.entries()).map(async ([acctId, ids]) => {
const acctClient = acctId === '__default__' ? client : useAuthStore.getState().getClientForAccount(acctId);
const promises = Array.from(bySource.entries()).map(async ([sourceAccountId, { clientAccountId, ids }]) => {
const acctClient = sourceAccountId === '__default__'
? resolveActionClient(client)
: (clientAccountId ? useAuthStore.getState().getClientForAccount(clientAccountId) : undefined);
if (!acctClient) return;
await acctClient.batchMoveEmails(ids, toMailboxId);
const jmapAccountId = sourceAccountId === '__default__' ? undefined : sourceAccountId;
await acctClient.batchMoveEmails(ids, jmapDestId, jmapAccountId);
});
await Promise.allSettled(promises);
} else {
@@ -2055,8 +2232,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// derive it from the email's own folders (preferring the unified role),
// otherwise the active account's selected mailbox.
const currentMailbox = get().isUnifiedView
? (mailboxes.find(mb => email.mailboxIds?.[mb.id] && mb.role === get().unifiedRole)
?? mailboxes.find(mb => email.mailboxIds?.[mb.id]))
? (mailboxes.find(mb => emailInMailbox(email, mb) && mb.role === get().unifiedRole)
?? mailboxes.find(mb => emailInMailbox(email, mb)))
: mailboxes.find(m => m.id === get().selectedMailbox);
if (!currentMailbox) return;
@@ -2064,7 +2241,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
emailId,
originalMailboxId: currentMailbox.originalId || currentMailbox.id,
accountId,
sourceAccountId: get().isUnifiedView ? email.accountId : undefined,
sourceClientAccountId: get().isUnifiedView ? email.sourceClientAccountId : undefined,
});
try {
@@ -2099,17 +2276,28 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Use cached original mailbox (more accurate for immediate undo)
targetMailboxId = cachedData.originalMailboxId;
accountId = cachedData.accountId;
if (cachedData.sourceAccountId) {
undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceAccountId) ?? undoClient;
if (cachedData.sourceClientAccountId) {
undoClient = useAuthStore.getState().getClientForAccount(cachedData.sourceClientAccountId) ?? undoClient;
}
get().spamUndoCache.delete(emailId);
} else {
// Fall back to finding Inbox (generic "not spam" button/menu)
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
// Generic "not spam" (button/menu, no undo cache). In aggregate views
// (e.g. "All Junk") route to the email's own account so shared/group
// messages move back to *their* inbox via *their* client, not the active
// account's. (#281)
const listEmail = get().emails.find(e => e.id === emailId);
let inboxMailboxes = mailboxes;
if (get().isUnifiedView && listEmail?.sourceClientAccountId && listEmail?.sourceAccountId) {
undoClient = useAuthStore.getState().getClientForAccount(listEmail.sourceClientAccountId) ?? undoClient;
accountId = listEmail.sourceAccountId;
inboxMailboxes = get().accountMailboxes[listEmail.sourceAccountId] ?? mailboxes;
} else {
const currentMailbox = mailboxes.find(m => m.id === selectedMailbox);
accountId = currentMailbox?.accountId;
}
// Find inbox in same account
const inboxMailbox = mailboxes.find(m =>
const inboxMailbox = inboxMailboxes.find(m =>
m.role === 'inbox' &&
(accountId ? m.accountId === accountId : !m.accountId)
);
@@ -2124,7 +2312,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
await undoClient.undoSpam(emailId, targetMailboxId, accountId);
// Refresh the view the user is actually looking at.
if (get().isUnifiedView && get().unifiedRole) {
if (get().isUnifiedView && get().crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const accounts = await buildUnifiedAccountClients({ includeGroup });
await get().fetchCrossView(accounts, get().crossView!);
} else if (get().isUnifiedView && get().unifiedRole) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const accounts = await buildUnifiedAccountClients({ includeGroup });
await get().fetchUnifiedEmails(accounts, get().unifiedRole!);
@@ -2474,12 +2666,34 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
set({ isLoadingThread: threadId });
try {
// Determine accountId for shared folders
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const accountId = mailbox?.isShared ? mailbox.accountId : undefined;
// Route to the thread's own account. In aggregate views `selectedMailbox`
// is virtual, so derive the client + accountId from a list email of this
// thread (handles shared/group accounts); otherwise fall back to the
// selected-mailbox shared-folder logic. (#281)
const threadEmail = get().emails.find(e => e.threadId === threadId);
let actionClient = resolveActionClient(client);
let accountId: string | undefined;
if (get().isUnifiedView && threadEmail?.sourceClientAccountId && threadEmail?.sourceAccountId) {
actionClient = useAuthStore.getState().getClientForAccount(threadEmail.sourceClientAccountId) ?? actionClient;
accountId = threadEmail.sourceAccountId;
} else {
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
}
// Fetch all emails in the thread
const emails = await resolveActionClient(client).getThreadEmails(threadId, accountId);
const emails = await actionClient.getThreadEmails(threadId, accountId);
// Re-stamp the source reference so actions on thread emails resolve to the
// right account (the fetched objects don't carry it).
if (get().isUnifiedView && threadEmail) {
for (const e of emails) {
e.accountId = threadEmail.accountId;
e.accountLabel = threadEmail.accountLabel;
e.sourceClientAccountId = threadEmail.sourceClientAccountId;
e.sourceAccountId = threadEmail.sourceAccountId;
}
}
// Update cache
const newCache = new Map(get().threadEmailsCache);
@@ -2561,7 +2775,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const updatedMailboxes = state.mailboxes.map(mailbox => {
let delta = 0;
for (const email of affectedEmails) {
if (email.mailboxIds?.[mailbox.id]) delta -= 1;
if (emailInMailbox(email, mailbox)) delta -= 1;
}
if (delta === 0) return mailbox;
return {
@@ -2793,6 +3007,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
error: null,
isUnifiedView: true,
unifiedRole: role,
crossView: null,
selectedKeyword: null,
});
try {
@@ -2856,10 +3071,50 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
},
fetchCrossView: async (accounts, view) => {
set({
isLoading: true,
error: null,
isUnifiedView: true,
unifiedRole: null,
crossView: view,
selectedKeyword: null,
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await fetchCrossViewEmails(accounts, view, emailsPerPage, 0);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
unifiedErrors: result.errors,
});
} catch (error) {
console.error('Failed to fetch cross-account view:', error);
set({
error: error instanceof Error ? error.message : "Failed to fetch cross-account view",
isLoading: false,
emails: [],
hasMoreEmails: false,
totalEmails: 0,
});
}
},
refreshCrossCounts: (accounts) => {
try {
set({ crossUnreadCount: getCrossUnreadTotal(accounts) });
} catch (error) {
console.error('Failed to refresh cross-account counts:', error);
}
},
exitUnifiedView: () => {
set({
isUnifiedView: false,
unifiedRole: null,
crossView: null,
unifiedErrors: new Map(),
});
},
+16
View File
@@ -220,6 +220,15 @@ interface SettingsState {
// configured, in which case the view defaults to all non-special (no-role)
// folders of the active account.
enableAllMailView: boolean;
// Cross-account "All accounts" views (gated per-view by the admin policy)
enableCrossUnreadView: boolean;
enableCrossStarredView: boolean;
enableCrossAllView: boolean;
// Folder ids merged into the virtual "All Mail" mailbox. `null` = never
// configured, in which case the view defaults to all non-special (no-role)
// folders of the active account.
allMailFolderIds: string[] | null;
// Email Display
@@ -409,6 +418,10 @@ const DEFAULT_SETTINGS = {
enableAllMailView: false,
allMailFolderIds: null as string[] | null,
enableCrossUnreadView: false,
enableCrossStarredView: false,
enableCrossAllView: false,
// Email Display
disableThreading: false,
@@ -583,6 +596,9 @@ export const useSettingsStore = create<SettingsState>()(
includeGroupInUnified: state.includeGroupInUnified,
enableAllMailView: state.enableAllMailView,
allMailFolderIds: state.allMailFolderIds,
enableCrossUnreadView: state.enableCrossUnreadView,
enableCrossStarredView: state.enableCrossStarredView,
enableCrossAllView: state.enableCrossAllView,
senderFavicons: state.senderFavicons,
showAvatarsInJunk: state.showAvatarsInJunk,
colorfulSidebarIcons: state.colorfulSidebarIcons,