Merge pull request #509 from hildebrandttk/feat/unified-mailbox-account-scope

Feat/unified mailbox account scope

Rework the sidebar "All accounts" into an account-bounded "Unified Mailbox"
by default, with cross-account merging as an opt-in (admin-gated) sub-option.
The standalone per-account "All Mail" virtual folder is folded into the unified
All mail / Unread / Starred entries.

Conflict resolution notes:
- stores/settings-store.ts: both main and this branch independently added a
  per-account default-identity (#507) migration at different versions (main v6,
  branch v7). Merged migration is version 7 using the refactored migrateSettings
  function; the unified-mailbox rework is guarded at `version < 7` so users who
  stopped at main's interim v6 identity bump still receive it, while the #507
  identity-map coercion stays at `version < 6` so their populated map is kept.
- stores/auth-store.ts: kept main's applyPreferredIdentity (superset with the
  pre-#507 legacy migration).
- stores/email-store.ts: removed the ALL_MAIL_MAILBOX_ID paths (folded into the
  unified views) while preserving main's plugin hooks (onSearchResults /
  onEmailsFetched); adopted advancedSearchCrossViewEmails for advanced cross-view
  search.
- components/settings/layout-settings.tsx: kept main's faviconUnreadBadge setting
  alongside the new unifiedCrossAccount toggle.
- integration/: union-merged the two independently-authored suites - branch suite
  is authoritative (matches new behavior) with main's shared-identity (#569) group
  infrastructure preserved.
- components/email/email-composer.tsx: dropped a duplicate data-testid attribute
  introduced by the auto-merge.
This commit is contained in:
Linus Rath
2026-07-16 19:57:51 +02:00
62 changed files with 2460 additions and 461 deletions
+5 -5
View File
@@ -4,9 +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 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
- Unified Mailbox combined Inbox, Sent, Drafts, Junk, Archive, and Trash, scoped by default to the active account and its shared/group folders, with an optional admin-gated cross-account mode that spans every connected account
- Aggregated All mail / Unread / Starred entries in the Unified Mailbox scoped by the same account boundary (or all accounts in cross-account mode) and narrowed by a per-account folder selection; each list labels the source folder of every message
- Search inside the Unified Mailbox text search across every unified view (the per-role mailboxes and the folder-selected All mail / Unread / Starred lists); advanced filters are additionally available in the per-role unified mailboxes
- 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
@@ -117,7 +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
- Shared / group (delegated) accounts: their folders appear alongside your own and can be merged into the Unified Mailbox ("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`)
@@ -125,7 +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
- Admin policy gates for the Unified Mailbox enable or disable the All mail / Unread / Starred entries org-wide, plus a cross-account capability gate (off by default; auto-enabled on upgrade for instances that already used the cross-account views); 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`)
+136 -52
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, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_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";
@@ -110,7 +110,7 @@ export default function Home() {
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string; accountId?: string; clientAccountId?: string } | null>(null);
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -356,10 +356,7 @@ export default function Home() {
useProMultiAccountMailboxes();
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
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
@@ -377,18 +374,36 @@ export default function Home() {
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
const unifiedCrossAccount = useSettingsStore((s) => s.unifiedCrossAccount);
const unifiedCrossAccountGate = usePolicyStore((s) => s.isFeatureEnabled('unifiedCrossAccountEnabled'));
const accounts = useAccountStore((s) => s.accounts);
const connectedAccountsSignature = useMemo(
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
[accounts],
);
// Cross-account is "active" when the user opted in, the admin allows it, and
// more than one account is connected. Drives the sidebar header label: the
// old "All accounts" when spanning accounts, else "Unified Mailbox".
const crossAccountActive =
unifiedCrossAccount &&
unifiedCrossAccountGate &&
accounts.filter((a) => a.isConnected).length > 1;
// Builds the populated UnifiedAccountClient[] used by the unified-view
// effects and one-shot actions in this page. Reads the includeGroup
// setting at call time so the latest toggle value is always honored.
// effects and one-shot actions in this page. Reads the settings at call time
// so the latest toggle values are always honored. When the cross-account
// sub-option is off, the unified mailbox stays within the active account
// boundary (its own + shared folders); when on, it spans every login account.
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
// Cross-account scope requires both the per-user opt-in and the admin
// capability gate; otherwise stay within the active account boundary.
const crossAccount = useSettingsStore.getState().unifiedCrossAccount
&& usePolicyStore.getState().isFeatureEnabled('unifiedCrossAccountEnabled');
return buildUnifiedAccountClients({
includeGroup: useSettingsStore.getState().includeGroupInUnified,
scopeToClientAccountId: crossAccount
? undefined
: (useAccountStore.getState().activeAccountId ?? undefined),
});
}, []);
@@ -986,29 +1001,52 @@ export default function Home() {
};
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
// Push notifications: set up once per client and tear down when the client
// goes away (logout or account switch). Kept separate from the fetch effect
// above so it still runs when data was prefetched at login time.
// Push notifications: set up once per CONNECTED client and tear down when the
// clients go away (logout or account switch). Kept separate from the fetch
// effect above so it still runs when data was prefetched at login time.
//
// We bind every connected login, not just the active one: background accounts
// must drive the unified-section counters too. The active client keeps the
// full handler (current list / scheduled / calendar / filters); background
// logins only re-project the unified counts by rebuilding the unified scope
// (which refreshes every account's cached mailbox list), since their changes
// never touch the active `mailboxes`. (#281 background push)
useEffect(() => {
if (!isAuthenticated || !client) return;
try {
client.onStateChange((change) => handleStateChange(change, client));
const pushEnabled = client.setupPushNotifications();
if (pushEnabled) {
setPushConnected(true);
debug.log('push', '[Push] Push notifications successfully enabled');
} else {
debug.log('push', '[Push] Push notifications not available on this server');
const clients = useAuthStore.getState().getAllConnectedClients();
const cleanups: Array<() => void> = [];
for (const [accId, c] of clients) {
try {
if (accId === activeAccountId) {
c.onStateChange((change) => handleStateChange(change, c));
} else {
c.onStateChange(() => {
buildPopulatedUnifiedAccounts()
.then((built) => {
refreshCrossCounts(built);
refreshUnifiedCounts(built);
})
.catch(() => { /* per-account fetch failures surface elsewhere */ });
});
}
c.setupPushNotifications();
cleanups.push(() => c.closePushNotifications());
} catch (error) {
debug.log('push', '[Push] Failed to setup push notifications for account:', accId, error);
}
} catch (error) {
debug.log('push', '[Push] Failed to setup push notifications:', error);
}
if (cleanups.length > 0) {
setPushConnected(true);
debug.log('push', `[Push] Push notifications enabled for ${cleanups.length} account(s)`);
}
return () => {
client.closePushNotifications();
cleanups.forEach((fn) => fn());
};
}, [isAuthenticated, client, handleStateChange, setPushConnected]);
}, [isAuthenticated, client, activeAccountId, connectedAccountsSignature, handleStateChange, setPushConnected, buildPopulatedUnifiedAccounts, refreshCrossCounts, refreshUnifiedCounts]);
// Keep unified mailbox counts in sync when the feature is enabled and more
// than one account is connected. Runs whenever the set of connected accounts
@@ -1025,7 +1063,7 @@ export default function Home() {
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
refreshUnifiedCounts(built);
});
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
}, [enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, activeAccountId, 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
@@ -1823,7 +1861,18 @@ export default function Home() {
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
// Keep an active search across the switch and re-run it in this view
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
useEmailStore.setState({ isUnifiedView: true, unifiedRole: role, crossView: null });
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
await searchEmails(client, searchQuery);
}
} else {
await fetchUnifiedEmailsAction(populated, role);
}
refreshUnifiedCounts(populated);
return;
}
@@ -1845,7 +1894,18 @@ export default function Home() {
}
const populated = await buildPopulatedUnifiedAccounts();
await fetchCrossViewAction(populated, view);
// Keep an active search across the switch and re-run it in this view
// (mirrors normal mailboxes), preserving advanced filters; otherwise browse.
if (client && (!isFilterEmpty(searchFilters) || searchQuery)) {
useEmailStore.setState({ isUnifiedView: true, crossView: view, unifiedRole: null });
if (!isFilterEmpty(searchFilters)) {
await advancedSearch(client);
} else {
await searchEmails(client, searchQuery);
}
} else {
await fetchCrossViewAction(populated, view);
}
refreshCrossCounts(populated);
return;
}
@@ -2191,13 +2251,16 @@ export default function Home() {
setSearchQuery("");
clearSearchFilters();
if (!client) return;
// In unified view the active "mailbox" is a virtual role, so refresh via
// the unified fan-out instead of fetchEmails.
// In unified view the active "mailbox" is a virtual role or cross view, so
// refresh via the unified fan-out instead of fetchEmails.
if (isUnifiedView) {
const populated = await buildPopulatedUnifiedAccounts();
const role = useEmailStore.getState().unifiedRole;
const cross = useEmailStore.getState().crossView;
if (role) {
const populated = await buildPopulatedUnifiedAccounts();
await fetchUnifiedEmailsAction(populated, role);
} else if (cross) {
await fetchCrossViewAction(populated, cross);
}
return;
}
@@ -2229,41 +2292,64 @@ export default function Home() {
};
}, []);
// Blobs are scoped per JMAP account. In the unified/All-Mail view the open
// message may belong to another login (route to its client) or to a delegated
// shared account (same client, but the owner's accountId in the download URL).
// Resolve both from the email's source so attachments on cross-account
// messages can be viewed/downloaded instead of 404ing against the active
// account.
const resolveBlobSource = useCallback((email: typeof selectedEmail) => {
const clientAccountId = isUnifiedView ? email?.sourceClientAccountId : undefined;
const blobClient = clientAccountId
? (useAuthStore.getState().getClientForAccount(clientAccountId) ?? client)
: client;
const accountId = isUnifiedView ? email?.sourceAccountId : undefined;
return { blobClient, accountId, clientAccountId };
}, [isUnifiedView, client]);
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
if (!client) return;
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
if (!blobClient) return;
try {
const { mailAttachmentAction } = useSettingsStore.getState();
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
setPreviewAttachment({ blobId, name, type });
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
return;
}
await client.downloadBlob(blobId, name, type);
await blobClient.downloadBlob(blobId, name, type, accountId);
} catch (error) {
console.error("Failed to download attachment:", error);
}
};
const handlePreviewAttachmentDownload = useCallback(async () => {
if (!client || !previewAttachment) return;
const previewBlobClient = useCallback(() => {
const id = previewAttachment?.clientAccountId;
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
}, [previewAttachment, client]);
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
}, [client, previewAttachment]);
const handlePreviewAttachmentDownload = useCallback(async () => {
const c = previewBlobClient();
if (!c || !previewAttachment) return;
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
}, [previewBlobClient, previewAttachment]);
const getPreviewAttachmentContent = useCallback(async () => {
if (!client || !previewAttachment) {
const c = previewBlobClient();
if (!c || !previewAttachment) {
throw new Error('No attachment selected');
}
const blob = await client.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
const blob = await c.fetchBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
return {
blob,
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
};
}, [client, previewAttachment]);
}, [previewBlobClient, previewAttachment]);
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
@@ -2413,14 +2499,12 @@ export default function Home() {
// Get current mailbox name for mobile header
const currentMailboxName = isScheduledView
? t('sidebar.scheduled')
: selectedMailbox === ALL_MAIL_MAILBOX_ID
? t('sidebar.mailboxes.all_mail')
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
: (() => {
const mb = mailboxes.find(m => m.id === selectedMailbox);
return mb
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
@@ -2708,7 +2792,7 @@ export default function Home() {
selectedKeyword={selectedKeyword}
scheduledTotal={scheduledTotal}
showScheduledMailbox={delayedSendSupported}
showAllMailMailbox={showAllMailMailbox}
crossAccountActive={crossAccountActive}
showCrossUnread={showCrossUnread}
showCrossStarred={showCrossStarred}
showCrossAll={showCrossAll}
@@ -2835,8 +2919,8 @@ export default function Home() {
className={cn("ps-9 h-9", searchQuery && "pe-8")}
data-search-input
data-tour="search-input"
disabled={isUnifiedView || isScheduledView}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
disabled={isScheduledView}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
/>
{searchQuery && (
<button
@@ -2852,15 +2936,15 @@ export default function Home() {
<button
type="button"
onClick={toggleAdvancedSearch}
disabled={isUnifiedView || isScheduledView}
disabled={isScheduledView}
className={cn(
"relative flex-shrink-0 p-2 rounded-md transition-colors",
(isUnifiedView || isScheduledView) && "opacity-50 cursor-not-allowed",
isScheduledView && "opacity-50 cursor-not-allowed",
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : t("advanced_search.toggle_filters")}
>
<Filter className="w-4 h-4" />
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
+7 -5
View File
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
import { apiFetch } from '@/lib/browser-navigation';
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled'];
// `allMailViewEnabled` is deprecated (folded into `crossAllViewEnabled`, normalized
// forward on policy load), so it is hidden from the admin UI.
const EXCLUDED_FEATURE_GATES: (keyof FeatureGates)[] = ['pluginsEnabled', 'pluginsUploadEnabled', 'themesEnabled', 'userThemesEnabled', 'allMailViewEnabled'];
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
@@ -22,10 +24,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. 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.' },
crossUnreadViewEnabled: { label: 'Unified Mailbox: Unread', description: 'Allow an "Unread" entry in the Unified Mailbox section that lists unread mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossStarredViewEnabled: { label: 'Unified Mailbox: Starred', description: 'Allow a "Starred" entry in the Unified Mailbox section that lists flagged/starred mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
crossAllViewEnabled: { label: 'Unified Mailbox: All Mail', description: 'Allow an "All mail" entry in the Unified Mailbox section that lists all mail across the account and its shared folders (or every account when the cross-account sub-option is on). Honors the user\'s folder selection. Requires the matching per-user toggle in Settings → Appearance.' },
unifiedCrossAccountEnabled: { label: 'Unified Mailbox: Cross-account', description: 'Allow users to expand the Unified Mailbox beyond the active account boundary so its lists merge across every logged-in account. When off, the Unified Mailbox stays within the active account and its shared folders.' },
};
const RESTRICTABLE_SETTINGS = [
+1 -1
View File
@@ -2080,7 +2080,7 @@ export function EmailComposer({
<Button variant="ghost" size="icon" onClick={handleClose} className="h-9 w-9 md:h-8 md:w-8">
<X className="w-5 h-5 md:w-4 md:h-4" />
</Button>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2" data-testid="composer-save-status" data-status={saveStatus}>
<h3 className="font-semibold text-base">{t('new_message')}</h3>
{saveStatus === 'saving' && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
+5 -1
View File
@@ -295,6 +295,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Trash2}
label={t("delete")}
testId="ctx-delete"
onClick={() =>
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
}
@@ -306,7 +307,7 @@ export function EmailContextMenu({
{/* Move to submenu */}
{moveTree.length > 0 && (
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")}>
<ContextMenuSubMenu icon={FolderInput} label={t("move_to")} testId="ctx-move-to">
{(() => {
const renderNodes = (nodes: MailboxNode[]) => {
return nodes.map((node) => {
@@ -319,6 +320,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={Icon}
label={nodeLabel}
testId={`move-to:${node.id}`}
onClick={() =>
handleAction(() =>
showBatchActions
@@ -410,6 +412,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
onClick={() =>
handleAction(
showBatchActions
@@ -429,6 +432,7 @@ export function EmailContextMenu({
<ContextMenuItem
icon={isUnread ? MailOpen : Mail}
label={isUnread ? t("mark_read") : t("mark_unread")}
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
onClick={() =>
handleAction(() =>
showBatchActions
+42 -21
View File
@@ -560,6 +560,8 @@ export function ContactSidebarPanel({
interface DraggableAttachmentChipProps {
attachment: EffectiveAttachment;
client: IJMAPClient | null;
/** Owner accountId for the blob when it lives in a delegated/shared account. */
accountId?: string;
enabled: boolean;
downloadName?: string;
children: (dragProps: {
@@ -570,14 +572,14 @@ interface DraggableAttachmentChipProps {
}) => React.ReactNode;
}
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
const source = useMemo<AttachmentDragSource>(() => ({
name: downloadName || attachment.name || 'download',
type: attachment.type || 'application/octet-stream',
getBlobUrl: async () => {
if (attachment.blobId && client) {
try {
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type);
return await client.fetchBlobAsObjectUrl(attachment.blobId, attachment.name || undefined, attachment.type, accountId);
} catch {
return null;
}
@@ -595,7 +597,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
}
return null;
},
}), [attachment, client, downloadName]);
}), [attachment, client, accountId, downloadName]);
const drag = useAttachmentDrag(source, enabled);
return <>{children(drag)}</>;
}
@@ -714,6 +716,18 @@ export function EmailViewer({
const { tabletListVisible } = useUIStore();
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
// Blobs (inline images, drag-out, TNEF, embedded messages, thumbnails, bundle
// downloads) are account-scoped. In the unified / All-Mail view the open
// message may belong to another login (route to its client) or a delegated
// shared account (same client, owner accountId in the URL). Resolve both from
// the message's source so cross-account blob fetches don't 404 against the
// active account.
const isUnifiedView = useEmailStore((s) => s.isUnifiedView);
const blobClient = useMemo(() => {
const scid = isUnifiedView ? email?.sourceClientAccountId : undefined;
return (scid ? useAuthStore.getState().getClientForAccount(scid) : null) ?? client;
}, [isUnifiedView, email?.sourceClientAccountId, client]);
const blobAccountId = isUnifiedView ? email?.sourceAccountId : undefined;
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
// client, handing a mailto: URL to the OS mail handler goes nowhere for
@@ -1251,7 +1265,7 @@ export function EmailViewer({
async function processTnef() {
try {
debug.time('TNEF fetch blob', 'email');
const blobBytes = await client!.fetchBlobArrayBuffer(tnefAtt!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(tnefAtt!.blobId!, undefined, undefined, blobAccountId);
debug.timeEnd('TNEF fetch blob', 'email');
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
@@ -1304,7 +1318,7 @@ export function EmailViewer({
processTnef();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Embedded message/rfc822 unwrapping
// When Outlook forwards an email as an attachment, the outer email body is
@@ -1341,7 +1355,7 @@ export function EmailViewer({
async function unwrapEmbedded() {
try {
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
if (cancelled) { debug.groupEnd(); return; }
if (blobBytes.byteLength === 0) {
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
@@ -1381,7 +1395,7 @@ export function EmailViewer({
unwrapEmbedded();
return () => { cancelled = true; };
}, [email, client]);
}, [email, client, blobClient, blobAccountId]);
// Fetch inline CID images with authentication to prevent browser auth dialogs
useEffect(() => {
@@ -1427,7 +1441,7 @@ export function EmailViewer({
await Promise.all(cidAttachments.map(async (att) => {
const cidValue = att.cid!.replace(/^<|>$/g, '');
try {
const objectUrl = await client!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type);
const objectUrl = await blobClient!.fetchBlobAsObjectUrl(att.blobId, att.name || 'inline', att.type, blobAccountId);
if (!cancelled) {
urls[cidValue] = objectUrl;
objectUrls.push(objectUrl);
@@ -1449,7 +1463,7 @@ export function EmailViewer({
cancelled = true;
objectUrls.forEach(url => URL.revokeObjectURL(url));
};
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
if (pluginRenderedAttachments.length > 0) {
@@ -1927,8 +1941,8 @@ export function EmailViewer({
for (const attachment of effectiveAttachments) {
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
try {
if (attachment.blobId && client) {
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
if (attachment.blobId && blobClient) {
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
zip.file(entryName, blob);
added++;
} else if (attachment.tnefData) {
@@ -1960,7 +1974,7 @@ export function EmailViewer({
} finally {
setIsDownloadingAll(false);
}
}, [isDownloadingAll, effectiveAttachments, client, email]);
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
const downloadAllButton = effectiveAttachments.length > 1 ? (
@@ -2002,8 +2016,8 @@ export function EmailViewer({
await Promise.all(imageAttachments.map(async (att) => {
let url: string | undefined;
try {
if (att.blobId && client) {
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
if (att.blobId && blobClient) {
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
} else if (att.decryptedAttachment) {
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
if (!bytes || bytes.byteLength === 0) return;
@@ -2034,7 +2048,7 @@ export function EmailViewer({
cancelled = true;
createdUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
// Iframe for rendering HTML emails true-to-life
const iframeRef = useRef<HTMLIFrameElement>(null);
@@ -2820,6 +2834,7 @@ export function EmailViewer({
variant="default"
size="sm"
onClick={() => onEditDraft()}
data-testid="edit-draft"
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('tooltips.edit_draft')}
>
@@ -3731,7 +3746,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -3742,6 +3757,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -3812,7 +3829,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4505,7 +4522,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4516,6 +4533,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4591,7 +4610,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
@@ -4649,7 +4668,7 @@ export function EmailViewer({
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
const thumbUrl = imageThumbUrls[attachment.id];
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className={cn(
@@ -4660,6 +4679,8 @@ export function EmailViewer({
)}
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
onClick={() => handleEffectiveAttachmentOpen(attachment)}
data-testid="attachment"
data-attachment-name={attachment.name}
draggable={dragProps.draggable}
onPointerEnter={dragProps.onPointerEnter}
onDragStart={dragProps.onDragStart}
@@ -4729,7 +4750,7 @@ export function EmailViewer({
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
return (
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={client} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
<DraggableAttachmentChip key={attachment.id} attachment={attachment} client={blobClient} accountId={blobAccountId} enabled={dragOutActive} downloadName={resolveAttachmentName(attachment)}>
{(dragProps) => (
<div
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
+3 -3
View File
@@ -2,7 +2,7 @@
import React, { useCallback } from "react";
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
import { Email, ThreadGroup, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
import { Email, ThreadGroup } from "@/lib/jmap/types";
import { cn } from "@/lib/utils";
import { SelectableAvatar } from "@/components/email/selectable-avatar";
import { Paperclip, Star, Pin, Circle, ChevronRight, ChevronDown, Loader2, MessageSquare, CheckSquare, Square, Reply, Forward, CalendarClock, Folder } from "lucide-react";
@@ -97,7 +97,7 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
// Show the originating folder in the aggregate "All …" views.
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!email.sourceFolder;
const showSourceFolder = isUnifiedView && !!email.sourceFolder;
const getAccountById = useAccountStore((state) => state.getAccountById);
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
const isChecked = selectedEmailIds.has(email.id);
@@ -463,7 +463,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
: null;
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
const showSourceFolder = (isUnifiedView || selectedMailbox === ALL_MAIL_MAILBOX_ID) && !!latestEmail.sourceFolder;
const showSourceFolder = isUnifiedView && !!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
+17 -15
View File
@@ -79,9 +79,10 @@ interface SidebarProps {
onRefreshMailboxes?: () => void;
scheduledTotal?: number;
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. */
/** True when the unified view spans multiple login accounts (cross-account).
* Drives the section header: "All accounts" when true, else "Unified Mailbox". */
crossAccountActive?: boolean;
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
showCrossUnread?: boolean;
showCrossStarred?: boolean;
showCrossAll?: boolean;
@@ -258,6 +259,7 @@ interface SidebarRowProps {
testRole?: string | null;
testName?: string;
testMailboxId?: string;
testShared?: boolean;
}
function SidebarRow({
@@ -281,6 +283,7 @@ function SidebarRow({
testRole,
testName,
testMailboxId,
testShared,
}: SidebarRowProps) {
const t = useTranslations('sidebar');
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
@@ -293,6 +296,7 @@ function SidebarRow({
data-folder-role={testRole ?? undefined}
data-folder-name={testName ?? undefined}
data-mailbox-id={testMailboxId ?? undefined}
data-shared={testShared ? 'true' : undefined}
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
className={cn(
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
@@ -372,6 +376,7 @@ function SidebarSectionHeader({
first,
icon,
sub,
testId,
}: {
label: string;
expanded: boolean;
@@ -382,6 +387,7 @@ function SidebarSectionHeader({
first?: boolean;
icon?: ReactNode;
sub?: boolean;
testId?: string;
}) {
if (isCollapsed) {
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
@@ -396,6 +402,9 @@ function SidebarSectionHeader({
return (
<button
onClick={onToggle}
data-testid={testId}
data-section-name={label}
data-expanded={expanded ? 'true' : 'false'}
className={cn(
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
paddingX,
@@ -496,6 +505,7 @@ function MailboxTreeItem({
testRole={node.role}
testName={node.name}
testMailboxId={node.id}
testShared={node.isShared}
depth={node.depth}
isSelected={isSelected}
isVirtual={isVirtualNode}
@@ -711,7 +721,7 @@ export function Sidebar({
onRefreshMailboxes,
scheduledTotal = 0,
showScheduledMailbox = false,
showAllMailMailbox = false,
crossAccountActive = false,
showCrossUnread = false,
showCrossStarred = false,
showCrossAll = false,
@@ -1036,20 +1046,10 @@ export function Sidebar({
{/* Mailbox List */}
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
{showAllMailMailbox && (
<SidebarRow
icon={<Mails className={cn("w-4 h-4 flex-shrink-0", selectedMailbox === '__all_mail__' ? "text-foreground" : "text-muted-foreground")} />}
label={t('mailboxes.all_mail')}
depth={0}
isSelected={!selectedKeyword && selectedMailbox === '__all_mail__'}
onClick={() => onMailboxSelect?.('__all_mail__')}
isCollapsed={isCollapsed}
/>
)}
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
<div>
<SidebarSectionHeader
label={t("all_accounts")}
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
expanded={unifiedExpanded}
onToggle={toggleUnified}
isCollapsed={isCollapsed}
@@ -1221,6 +1221,7 @@ export function Sidebar({
expanded={sharedExpanded}
onToggle={toggleShared}
isCollapsed={isCollapsed}
testId="section-shared"
/>
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
<>
@@ -1235,6 +1236,7 @@ export function Sidebar({
isCollapsed={isCollapsed}
sub
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
testId="section-shared-account"
/>
{accountExpanded && !isCollapsed && account.children.map((child) => (
<MailboxTreeItem
+29 -18
View File
@@ -118,19 +118,26 @@ 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, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, unifiedCrossAccount, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
const accounts = useAccountStore(s => s.accounts);
const activeAccountId = useAccountStore(s => s.activeAccountId);
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 connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
const unifiedCrossAccountAllowed = isFeatureEnabled('unifiedCrossAccountEnabled');
// Unified Mailbox entries (All mail / Unread / Starred), each gated independently
// by the admin. Scope (single account vs. cross-account) is governed by
// `unifiedCrossAccount`; the folder picker below narrows which own folders feed them.
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;
// The folder picker narrows the own folders included in the entries above; show
// it once the user has enabled at least one of them.
const anyCrossEnabled = enableCrossUnreadView || enableCrossStarredView || enableCrossAllView;
const anyCrossAllowed = crossViews.some(c => c.allowed);
// Own (non-shared) folders and the active account's All Mail selection. The
// selection is per account: a missing entry = never configured, which
@@ -251,6 +258,21 @@ export function LayoutSettings() {
</SettingItem>
)}
{enableUnifiedMailbox && connectedAccountCount > 1 && unifiedCrossAccountAllowed && !isSettingHidden('unifiedCrossAccount') && (
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
<SettingItem
label={t('unified_mailbox.cross_account.label')}
description={t('unified_mailbox.cross_account.description')}
locked={isSettingLocked('unifiedCrossAccount')}
>
<ToggleSwitch
checked={unifiedCrossAccount}
onChange={(v) => updateSetting('unifiedCrossAccount', v)}
/>
</SettingItem>
</div>
)}
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
<SettingItem
@@ -266,8 +288,9 @@ export function LayoutSettings() {
</div>
)}
{enableUnifiedMailbox && crossViews.some(c => c.allowed) && (
{enableUnifiedMailbox && anyCrossAllowed && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
allowed && !isSettingHidden(setting) && (
<SettingItem
@@ -286,21 +309,9 @@ export function LayoutSettings() {
</div>
)}
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
<SettingItem
label={t('all_mail.label')}
description={t('all_mail.description')}
locked={isSettingLocked('enableAllMailView')}
>
<ToggleSwitch
checked={enableAllMailView}
onChange={(v) => updateSetting('enableAllMailView', v)}
/>
</SettingItem>
)}
{allMailViewAllowed && enableAllMailView && (
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
<div>
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</div>
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
+8
View File
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
disabled?: boolean;
destructive?: boolean;
shortcut?: string;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
}
export function ContextMenuItem({
@@ -117,10 +119,12 @@ export function ContextMenuItem({
disabled = false,
destructive = false,
shortcut,
testId,
}: ContextMenuItemProps) {
return (
<button
role="menuitem"
data-testid={testId}
disabled={disabled}
className={cn(
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
@@ -153,12 +157,15 @@ interface ContextMenuSubMenuProps {
icon?: React.ComponentType<{ className?: string }>;
label: string;
children: React.ReactNode;
/** Stable hook for integration tests (not user-visible). */
testId?: string;
}
export function ContextMenuSubMenu({
icon: Icon,
label,
children,
testId,
}: ContextMenuSubMenuProps) {
const [isOpen, setIsOpen] = useState(false);
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
@@ -232,6 +239,7 @@ export function ContextMenuSubMenu({
role="menuitem"
aria-haspopup="true"
aria-expanded={isOpen}
data-testid={testId}
>
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
<span className="flex-1">{label}</span>
+2 -1
View File
@@ -1,7 +1,7 @@
import { readFileSync } from "fs";
import { configManager } from "./lib/admin/config-manager";
import { initAdminPassword } from "./lib/admin/password";
import { migrateLegacyAdminLayout } from "./lib/admin/migrate";
import { migrateLegacyAdminLayout, migratePolicyUnifiedMailbox } from "./lib/admin/migrate";
import { detectSetupState } from "./lib/setup/state";
import { ensureSetupToken } from "./lib/setup/token";
@@ -14,6 +14,7 @@ console.info(`Bulwark Webmail v${current}`);
// Initialize admin config and password bootstrap. Migration runs first so
// existing v1 layouts are split before anything reads admin.json.
migrateLegacyAdminLayout()
.then(() => migratePolicyUnifiedMailbox())
.then(() => configManager.load())
.then(() => initAdminPassword())
.then(async () => {
+38 -1
View File
@@ -80,9 +80,46 @@ integration/
│ └── app.ts # login, add/switch account, folder-counter reads
├── 01-login.spec.ts
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
── 03-multi-account.spec.ts # isolation + cross-account Unified Inbox aggregation
├── 04-all-mail.spec.ts # All Mail view: single-account merge + cross-account
├── 04-shared-identity.spec.ts# composer From offers shared/group send-as identities (issue #569)
├── 05-actions.spec.ts # context-menu read/unread, delete, spam (inbox)
├── 06-shared-folders.spec.ts # delegated folder: appears + read/unread/delete/spam
├── 07-drafts.spec.ts # multiple recipients, changed sender, continue-draft button
├── 08-shared-moves.spec.ts # moving mail across own/shared and shared/shared
├── 09-live-counters.spec.ts # live unified/All-Mail counters (login + shared)
└── 10-attachments.spec.ts # cross-account attachment download from All Mail
```
## Findings surfaced by the suite
Some tests assert server-side truth (or use `test.fail` to pin a known gap)
because the UI behaviour is currently incomplete. Worth a look:
- **Shared-account counters now reconcile on focus/interval** (`09-live-counters`).
Stalwart's SSE only pushes StateChange for the *primary* account, so a
background change in a shared/delegated account is never pushed. The client
now also polls the session's secondary accounts, so their folder badges and
the unified/All-Mail counter refresh on the visibility reconcile and on a slow
background poll. (A *login* account already updates live via its own SSE.)
Note: these shared counters still don't update the instant a local action
runs — they follow the reconcile, not the optimistic path.
- **`mark-as-spam` doesn't optimistically decrement the source counter** the
way `delete` does; it settles after a reconcile.
- **Reopening a draft resets the From selector** to the default identity even
though the draft was saved with (and the server retains) the chosen sender.
Pinned with `test.fail` in `07-drafts`.
- **Cross-account moves (own ⇆ shared folder) don't relocate the message.** The
"Move to" submenu offers the shared folder, but clicking it is a no-op.
Shared ⇆ shared (same owner) moves work. Pinned with `test.fail` in
`08-shared-moves`.
- **Cross-account attachments & inline images (fixed).** Blobs are account-
scoped, so viewing/downloading/previewing an attachment, rendering an inline
`cid:` image, dragging out, and the bundle/S-MIME/TNEF/embedded-message
fetches on an All-Mail message from another account 404'd against the active
account. Every viewer blob fetch now routes to the message's owning client +
accountId (`10-attachments` covers download + inline image).
## How the tests work
- **Mutations** are made out-of-band — mail is injected over SMTP
+11 -11
View File
@@ -8,9 +8,9 @@ import {
folderCounts,
expectFolderUnread,
expectFolderTotal,
expectFolderCountsSynced,
emailItem,
expectEmailVisible,
forceSync,
} from './helpers/app';
/**
@@ -82,11 +82,12 @@ test.describe('Single-account sync', () => {
await jmap.request([
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
]);
await forceSync(page);
// Source Inbox drains, destination gains the message.
await expectFolderUnread(page, { role: 'inbox' }, 0);
await expectFolderTotal(page, { name: 'Archive2' }, 1);
// Source Inbox drains, destination gains the message. These follow a
// reconcile (not live push), so nudge one before every poll to stay robust
// against a single missed reconcile under load.
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
expect(inbox).toBeTruthy();
});
@@ -99,13 +100,12 @@ test.describe('Single-account sync', () => {
await expectFolderTotal(page, { role: 'inbox' }, 1);
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
await forceSync(page);
// The folder counter is the sync-critical signal and drains to zero. (The
// already-rendered list view is not re-queried on a background delete, so
// we don't assert on the row disappearing here.)
await expectFolderTotal(page, { role: 'inbox' }, 0);
await expectFolderUnread(page, { role: 'inbox' }, 0);
// The folder counter is the sync-critical signal and drains to zero (via a
// reconcile, nudged before every poll). (The already-rendered list view is
// not re-queried on a background delete, so we don't assert on the row
// disappearing here.)
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
});
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
+9 -20
View File
@@ -10,8 +10,7 @@ import {
seedUnifiedSettings,
folderRow,
expectFolderUnread,
expectFolderTotal,
forceSync,
expectFolderCountsSynced,
} from './helpers/app';
/**
@@ -49,18 +48,17 @@ test.describe('Multi-account sync', () => {
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
await addAccount(page, bob);
await forceSync(page);
// Both accounts are now registered in the switcher.
await accountSwitcher(page).click();
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
await page.keyboard.press('Escape');
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
// Switch back to alice: her count is intact.
await switchAccount(page, alice.email);
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 2 });
});
test('the cross-account Unified Inbox aggregates unread across accounts', async ({ page }) => {
@@ -70,35 +68,26 @@ test.describe('Multi-account sync', () => {
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob);
await forceSync(page);
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
// (bob) still reports just its own 1.
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
await expectFolderUnread(page, { name: 'unified-inbox' }, 2);
await expectFolderTotal(page, { name: 'unified-inbox' }, 2);
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
});
// fixme: the unified counter for a *background* (non-active) account is not
// updated live on this branch — the background-push counter fix lives in the
// unified-mailbox feature commits (single-source unified counters / keep
// unified counters current for shared accounts), which sit on
// feat/unified-mailbox-account-scope, not on this harness-only base branch.
test.fixme('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob); // bob is now the active account
await forceSync(page);
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
// Mail lands in alice's inbox while bob is the active account.
await send(alice, subj('bg'));
await forceSync(page);
// The unified counter reflects the background account's new mail.
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 1 });
// bob (active) own Inbox is unaffected.
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0);
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
});
});
+96
View File
@@ -0,0 +1,96 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
seedAllMailSettings,
folderRow,
openFolder,
expectFolderCountsSynced,
expectEmailVisible,
emailItem,
forceSync,
} from './helpers/app';
/**
* The "All Mail" view a virtual folder that merges messages across an
* account's folders (Inbox + custom, excluding junk/sent/trash/drafts/archive),
* and across every logged-in account when the cross-account sub-option is on.
*/
const { alice, bob } = ACCOUNTS;
const ALL_MAIL = '__cross_all__';
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
const send = (to: typeof alice, subject: string) =>
sendMail({ from: to.email, authPass: to.password, to: to.email, subject, body: 'x' });
test.describe('All Mail — single account', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('merges Inbox + custom folders and excludes Junk', async ({ page }) => {
const inboxSubj = subj('am-inbox');
const folderSubj = subj('am-folder');
const junkSubj = subj('am-junk');
await send(alice, inboxSubj);
await send(alice, folderSubj);
await send(alice, junkSubj);
// File one into a custom folder and one into Junk (excluded from All Mail).
const folderMail = await jmap.waitForEmail(folderSubj);
await jmap.moveEmailToFolder(folderMail.id, 'Projects');
const junkMail = await jmap.waitForEmail(junkSubj);
const junk = await jmap.mailboxByRole('junk');
await jmap.moveEmail(junkMail.id, junk!.id);
await seedAllMailSettings(page, { crossAccount: false });
await login(page, alice);
// The All Mail entry is present and shows the two included-folder unreads.
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
// Its list merges the Inbox and custom-folder messages, but not Junk.
await openFolder(page, { name: ALL_MAIL });
await forceSync(page);
await expectEmailVisible(page, inboxSubj);
await expectEmailVisible(page, folderSubj);
await expect(emailItem(page, junkSubj)).toHaveCount(0);
});
});
test.describe('All Mail — cross account', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('merges mail from every logged-in account', async ({ page }) => {
const aSubj = subj('am-a');
const bSubj = subj('am-b');
await send(alice, aSubj);
await send(bob, bSubj);
await seedAllMailSettings(page, { crossAccount: true });
await login(page, alice);
await addAccount(page, bob);
// All Mail aggregates unread across both accounts (alice 1 + bob 1).
await expect(folderRow(page, { name: ALL_MAIL }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: ALL_MAIL }, { unread: 2 });
await openFolder(page, { name: ALL_MAIL });
await forceSync(page);
await expectEmailVisible(page, aSubj);
await expectEmailVisible(page, bSubj);
});
});
+121
View File
@@ -0,0 +1,121 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expectFolderUnread,
expectFolderTotal,
expectFolderCountsSynced,
expectEmailVisible,
expectEmailUnread,
emailContextAction,
emailItem,
openFolder,
} from './helpers/app';
/**
* Message actions from the list context menu mark read/unread, delete, spam
* performed in the Inbox, with the outcome checked on both the UI (counters,
* row state) and the server (which mailbox the message ended up in).
*/
const alice = ACCOUNTS.alice;
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
const send = (subject: string) =>
sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
test.describe('Inbox message actions', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('mark read then unread toggles the row state and Inbox unread counter', async ({ page }) => {
const s = subj('act-read');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderUnread(page, { role: 'inbox' }, 1);
await expectEmailUnread(page, s, true);
await emailContextAction(page, s, 'ctx-mark-read');
await expectEmailUnread(page, s, false);
await expectFolderUnread(page, { role: 'inbox' }, 0);
await emailContextAction(page, s, 'ctx-mark-unread');
await expectEmailUnread(page, s, true);
await expectFolderUnread(page, { role: 'inbox' }, 1);
});
test('delete moves the message to Trash and updates both counters', async ({ page }) => {
const s = subj('act-del');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await emailContextAction(page, s, 'ctx-delete');
// Leaves the Inbox, lands in Trash — on the UI...
await expectFolderTotal(page, { role: 'inbox' }, 0);
await expectFolderTotal(page, { role: 'trash' }, 1);
await expect(emailItem(page, s)).toHaveCount(0);
// ...and on the server.
const trash = await jmap.mailboxByRole('trash');
const found = await jmap.findEmailBySubject(s, trash!.id);
expect(found, 'deleted message is in Trash on the server').toBeTruthy();
});
test('mark as spam moves the message to Junk', async ({ page }) => {
const s = subj('act-spam');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await expectFolderTotal(page, { role: 'inbox' }, 1);
await emailContextAction(page, s, 'ctx-spam');
// The destination (Junk) counter updates optimistically, but the source
// (Inbox) counter isn't always decremented until the next reconcile when
// the action fires moments after login — unlike delete, which decrements
// the source immediately. The synced assertion nudges a reconcile per poll.
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
await expectFolderCountsSynced(page, { role: 'inbox' }, { total: 0 });
const junk = await jmap.mailboxByRole('junk');
const found = await jmap.findEmailBySubject(s, junk!.id);
expect(found, 'spammed message is in Junk on the server').toBeTruthy();
});
test('spam then not-spam round-trips the message back out of Junk', async ({ page }) => {
const s = subj('act-notspam');
await send(s);
await jmap.waitForEmail(s);
await login(page, alice);
await emailContextAction(page, s, 'ctx-spam');
await expectFolderCountsSynced(page, { role: 'junk' }, { total: 1 });
// Open Junk, then mark not-spam.
await openFolder(page, { role: 'junk' });
await expectEmailVisible(page, s);
await emailContextAction(page, s, 'ctx-not-spam');
// The message leaves the open Junk list (optimistic) and round-trips on the
// server: out of Junk, back in Inbox. (Asserted on the optimistic list +
// authoritative server state rather than the Junk badge, whose reconcile
// can stall under heavy concurrent load.)
await expect(emailItem(page, s)).toHaveCount(0);
const junk = await jmap.mailboxByRole('junk');
const inbox = await jmap.mailboxByRole('inbox');
expect(await jmap.findEmailBySubject(s, junk!.id), 'message no longer in Junk').toBeFalsy();
expect(await jmap.findEmailBySubject(s, inbox!.id), 'message back in Inbox').toBeTruthy();
});
});
+128
View File
@@ -0,0 +1,128 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expandSharedFolders,
folderRow,
openFolder,
expectFolderCountsSynced,
expectEmailVisible,
expectEmailUnread,
emailContextAction,
emailItem,
forceSync,
} from './helpers/app';
/**
* Shared (delegated) folders. Alice shares a custom folder plus her Trash and
* Junk so delete/spam can route to the owner's system folders with carol, who
* then acts on the mail from her own session and checks the shared counters.
*
* carol is the grantee (not asserted on by other specs), so the shared-account
* visibility this leaves in Stalwart's session cache doesn't leak elsewhere.
*/
const { alice, carol } = ACCOUNTS;
const SHARED = 'TeamShared';
let seq = 0;
const subj = (l: string) => `IT ${l} ${Date.now()}-${seq++}`;
test.describe('Shared folder actions', () => {
let ja: JmapClient; // owner (alice)
let sharedId: string;
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
const jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
// Delegate a custom folder + Trash + Junk to carol.
sharedId = await ja.createSharedFolder(SHARED, carol.email);
await ja.shareMailboxByRole('trash', carol.email);
await ja.shareMailboxByRole('junk', carol.email);
});
async function seedIntoShared(subject: string): Promise<void> {
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject, body: 'x' });
const m = await ja.waitForEmail(subject);
await ja.moveEmail(m.id, sharedId);
}
test('shared folder appears with its counter and message', async ({ page }) => {
const s = subj('sh-show');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await expect(folderRow(page, { name: SHARED, shared: true }).first()).toBeVisible();
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await openFolder(page, { name: SHARED, shared: true });
await forceSync(page);
await expectEmailVisible(page, s);
});
test('mark read/unread in a shared folder updates its counter', async ({ page }) => {
const s = subj('sh-read');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
await emailContextAction(page, s, 'ctx-mark-read');
await expectEmailUnread(page, s, false);
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 0 });
await emailContextAction(page, s, 'ctx-mark-unread');
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { unread: 1 });
// Owner sees the same state on the server.
const found = await ja.findEmailBySubject(s, sharedId);
expect(found.keywords?.$seen).toBeFalsy();
});
test('delete in a shared folder moves the message to the shared Trash', async ({ page }) => {
const s = subj('sh-del');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-delete');
// Source shared folder drains, and the message really is in the owner's
// Trash on the server. (We assert the destination server-side rather than
// the shared Trash badge to keep the check independent of sidebar layout.)
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 0 });
const trash = await ja.mailboxByRole('trash');
expect(await ja.findEmailBySubject(s, trash!.id), 'message in owner Trash').toBeTruthy();
});
test('mark as spam in a shared folder moves the message to the shared Junk', async ({ page }) => {
const s = subj('sh-spam');
await seedIntoShared(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
await openFolder(page, { name: SHARED, shared: true });
await expectFolderCountsSynced(page, { name: SHARED, shared: true }, { total: 1 });
await emailContextAction(page, s, 'ctx-spam');
// The message leaves the shared folder's list, and on the server it has
// moved to the owner's Junk and out of the shared folder. (Unlike delete,
// spam doesn't optimistically drain the source *counter*, and forceSync
// can't reconcile a shared account — so we assert list + server state.)
await expect(emailItem(page, s)).toHaveCount(0);
const junk = await ja.mailboxByRole('junk');
expect(await ja.findEmailBySubject(s, junk!.id), 'message in owner Junk').toBeTruthy();
expect(await ja.findEmailBySubject(s, sharedId), 'message no longer in shared folder').toBeFalsy();
});
});
+146
View File
@@ -0,0 +1,146 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { JmapClient } from './helpers/jmap';
import {
login,
openComposer,
addRecipient,
setFrom,
setSubject,
waitDraftSaved,
closeComposer,
composerRecipients,
openFolder,
emailItem,
} from './helpers/app';
/**
* Draft handling. Focus areas reported as flaky by the user:
* - the "continue draft" (edit-draft) button in the message view,
* - multiple recipients being persisted to the draft,
* - a changed sender identity being persisted to the draft.
*
* Each test drives the composer, lets it auto-save, then verifies the draft on
* the server (JMAP) and by reopening it in the UI.
*/
const { alice, bob, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
async function draftBody(page: import('@playwright/test').Page, text: string) {
await page.locator('.ProseMirror').first().fill(text);
}
test.describe('Drafts', () => {
let jmap: JmapClient;
test.beforeEach(async () => {
jmap = await JmapClient.connect(alice.email, alice.password);
await jmap.reset();
});
test('multiple recipients save and reopen via the continue-draft button', async ({ page }) => {
const subject = subj('draft-multi');
await login(page, alice);
await openComposer(page);
await addRecipient(page, bob.email);
await addRecipient(page, carol.email);
await setSubject(page, subject);
await draftBody(page, 'draft body');
await waitDraftSaved(page);
await closeComposer(page);
// Server: the draft carries BOTH recipients.
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
expect(to).toEqual([bob.email, carol.email].sort());
// UI: opening the draft shows the continue-draft button, which reopens the
// composer with both recipients intact.
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
await page.locator('[data-testid="edit-draft"]').click();
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible' });
const recips = await composerRecipients(page);
expect(recips).toContain(bob.email);
expect(recips).toContain(carol.email);
});
test('a recipient typed but not committed to a chip is still saved', async ({ page }) => {
const subject = subj('draft-uncommitted');
await login(page, alice);
await openComposer(page);
await addRecipient(page, bob.email); // committed chip
// Type a second address but do NOT press Enter — leave it as raw input.
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(carol.email);
await setSubject(page, subject); // blur the To field
await draftBody(page, 'uncommitted body');
await waitDraftSaved(page);
await closeComposer(page);
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
const to = (draft.to ?? []).map((r: { email: string }) => r.email).sort();
// Both the committed and the still-in-the-input recipient must survive.
expect(to).toEqual([bob.email, carol.email].sort());
});
test('a server-created draft shows the continue-draft button when viewed', async ({ page }) => {
const subject = subj('draft-server');
await jmap.createDraft(subject, bob.email);
await login(page, alice);
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
// The edit-draft ("continue draft") button must be present for any message
// carrying the $draft keyword, regardless of how the draft was created.
await expect(page.locator('[data-testid="edit-draft"]')).toBeVisible();
});
test('a changed sender identity is saved to the draft (server)', async ({ page }) => {
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
const subject = subj('draft-from');
await login(page, alice);
await openComposer(page);
await setFrom(page, altId);
await addRecipient(page, bob.email);
await setSubject(page, subject);
await draftBody(page, 'from-change body');
await waitDraftSaved(page);
await closeComposer(page);
const drafts = await jmap.mailboxByRole('drafts');
const draft = await jmap.waitForEmail(subject, { mailboxId: drafts!.id });
expect((draft.from ?? [])[0]?.name, 'draft From carries the selected identity').toBe('Alice Team');
});
// KNOWN BUG (documented via test.fail): a draft composed with a non-default
// identity is saved with the right From on the server (see the test above),
// but reopening the draft resets the composer's From selector to the default
// identity instead of restoring the one the draft was written with. If this
// starts passing, the reopen path was fixed — flip this back to a plain test.
test.fail('reopening a draft restores the changed sender in the From selector', async ({ page }) => {
const altId = await jmap.ensureIdentity('Alice Team', alice.email);
const subject = subj('draft-from-reopen');
await login(page, alice);
await openComposer(page);
await setFrom(page, altId);
await addRecipient(page, bob.email);
await setSubject(page, subject);
await draftBody(page, 'reopen body');
await waitDraftSaved(page);
await closeComposer(page);
await openFolder(page, { role: 'drafts' });
await emailItem(page, subject).first().click();
await page.locator('[data-testid="edit-draft"]').click();
await expect(page.locator('[data-testid="composer-from"]')).toHaveValue(altId);
});
});
+119
View File
@@ -0,0 +1,119 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
expandSharedFolders,
openFolder,
folderMailboxId,
moveEmailTo,
forceSync,
} from './helpers/app';
/**
* Moving mail across the own-account / shared-folder boundary, in both
* directions, and between two shared folders. The move is driven from the list
* context menu's "Move to" submenu; the authoritative check is the server-side
* mailbox the message ends up in, with the reliably-updating (own-account)
* counters checked in the UI too.
*/
const { alice, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
test.describe('Shared-folder moves', () => {
let ja: JmapClient; // owner
let jc: JmapClient; // grantee
let teamA: string;
let teamB: string;
test.beforeEach(async () => {
ja = await JmapClient.connect(alice.email, alice.password);
jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
teamA = await ja.createSharedFolder('TeamA', carol.email);
teamB = await ja.createSharedFolder('TeamB', carol.email);
});
async function seedInto(mailboxId: string, subject: string, owner = ja): Promise<void> {
const acct = owner === ja ? alice : carol;
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
const m = await owner.waitForEmail(subject);
await owner.moveEmail(m.id, mailboxId);
}
test('shared folder A -> shared folder B', async ({ page }) => {
const s = subj('mv-a2b');
await seedInto(teamA, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamB', shared: true });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamB), 'message in TeamB').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamA), 'message left TeamA').toBeFalsy();
});
test('shared folder B -> shared folder A', async ({ page }) => {
const s = subj('mv-b2a');
await seedInto(teamB, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { name: 'TeamB', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(1500);
expect(await ja.findEmailBySubject(s, teamA), 'message in TeamA').toBeTruthy();
expect(await ja.findEmailBySubject(s, teamB), 'message left TeamB').toBeFalsy();
});
// KNOWN LIMITATION (documented via test.fail): the "Move to" submenu offers a
// shared folder as a destination for an own-account message, but clicking it
// does NOT relocate the message across the account boundary — it stays put.
// Same in reverse (shared -> own). If cross-account moves get implemented,
// these will start passing; flip them back to plain tests then.
test.fail('own account -> shared folder', async ({ page }) => {
const s = subj('mv-own2sh');
await sendMail({ from: carol.email, authPass: carol.password, to: carol.email, subject: s, body: 'x' });
await jc.waitForEmail(s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { name: 'TeamA', shared: true });
await openFolder(page, { role: 'inbox', shared: false });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Expected (once supported): the message moves to the owner's shared TeamA.
expect(await ja.findEmailBySubject(s, teamA), 'message in shared TeamA').toBeTruthy();
});
test.fail('shared folder -> own account', async ({ page }) => {
const s = subj('mv-sh2own');
await seedInto(teamA, s);
await login(page, carol);
await expandSharedFolders(page, alice.email);
const dest = await folderMailboxId(page, { role: 'inbox', shared: false });
await openFolder(page, { name: 'TeamA', shared: true });
await forceSync(page);
await moveEmailTo(page, s, dest);
await page.waitForTimeout(2000);
// Expected (once supported): the message arrives in carol's own Inbox.
expect(await jc.findEmailBySubject(s), 'message in own account').toBeTruthy();
});
});
@@ -0,0 +1,74 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
seedUnifiedSettings,
seedAllMailSettings,
expandSharedFolders,
folderCounts,
expectFolderUnread,
forceSync,
} from './helpers/app';
/**
* Live currency of the unified / All-Mail counters across every source folder.
*
* Stalwart's SSE only pushes StateChange for the *primary* account, so:
* - a background *login* account updates the badge live (each login has its
* own SSE) asserted with no reconcile;
* - a *shared/delegated* account gets no push at all, so the client polls the
* session's secondary accounts too; the badge reconciles on focus/interval.
* (Regression test for the shared-account state-poll.)
*/
const { alice, bob, carol } = ACCOUNTS;
const subj = (l: string) => `IT ${l} ${Date.now()}`;
async function deliverIntoSharedFolder(owner: JmapClient, folderId: string, subject: string) {
const acct = ACCOUNTS.alice;
await sendMail({ from: acct.email, authPass: acct.password, to: acct.email, subject, body: 'x' });
const m = await owner.waitForEmail(subject);
await owner.moveEmail(m.id, folderId);
}
test.describe('Live unified/All-Mail counters', () => {
test('a background login account updates the unified counter live (no reconcile)', async ({ page }) => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
await seedUnifiedSettings(page);
await login(page, alice);
await addAccount(page, bob); // bob active, alice in the background
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
// Mail lands in alice's inbox while bob is active — no focus/forceSync here.
await sendMail({ from: alice.email, authPass: alice.password, to: alice.email, subject: subj('bg-live'), body: 'x' });
await expectFolderUnread(page, { name: 'unified-inbox' }, 1);
});
test('a shared-folder change reconciles the All-Mail counter on focus', async ({ page }) => {
const ja = await JmapClient.connect(alice.email, alice.password);
const jc = await JmapClient.connect(carol.email, carol.password);
await ja.reset();
await jc.reset();
const shared = await ja.createSharedFolder('TeamShared', carol.email);
await seedAllMailSettings(page, { crossAccount: false });
await login(page, carol);
await expandSharedFolders(page, alice.email);
expect((await folderCounts(page, { name: '__cross_all__' })).unread).toBe(0);
// A background change in the shared (delegated) account gets no SSE push.
await deliverIntoSharedFolder(ja, shared, subj('sh-live'));
// Focus reconcile now polls the shared account too, so the All-Mail badge
// picks up the shared folder's new unread.
await forceSync(page);
await expect
.poll(async () => (await folderCounts(page, { name: '__cross_all__' })).unread, { timeout: 15000 })
.toBe(1);
});
});
+111
View File
@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import { ACCOUNTS } from './helpers/config';
import { sendMail } from './helpers/smtp';
import { JmapClient } from './helpers/jmap';
import {
login,
addAccount,
switchAccount,
seedSettings,
folderRow,
openFolder,
emailItem,
expectEmailVisible,
forceSync,
} from './helpers/app';
/**
* Attachments on a message that belongs to a *different* account, opened from
* the cross-account All-Mail view. Blobs are account-scoped, so downloading one
* must route to the owning account's client + accountId otherwise it 404s
* against the active account (the reported bug).
*/
const { alice, bob } = ACCOUNTS;
const ATT = { filename: 'report.bin', contentType: 'application/octet-stream', content: 'hello-attachment-content-12345' };
test.describe('Cross-account attachments', () => {
test.beforeEach(async () => {
for (const a of [alice, bob]) {
const j = await JmapClient.connect(a.email, a.password);
await j.reset();
}
});
test('an attachment on another account\'s All-Mail message downloads correctly', async ({ page }) => {
const subject = `IT attach ${Date.now()}`;
// Deliver a message with an attachment to bob.
await sendMail({ from: bob.email, authPass: bob.password, to: bob.email, subject, body: 'see attachment', attachment: ATT });
// Cross-account All Mail + always download attachments (don't preview).
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
mailAttachmentAction: 'download',
});
// Make alice the active account, with bob added, so bob's message is
// genuinely cross-account when opened.
await login(page, alice);
await addAccount(page, bob);
await switchAccount(page, alice.email);
await forceSync(page);
// Open the All-Mail view and bob's message.
await expect(folderRow(page, { name: '__cross_all__' }).first()).toBeVisible();
await openFolder(page, { name: '__cross_all__' });
await forceSync(page);
await expectEmailVisible(page, subject);
await emailItem(page, subject).first().click();
// The attachment chip is present; clicking it downloads the blob from bob's
// account (pre-fix this 404s against alice and no download fires).
const chip = page.locator(`[data-testid="attachment"][data-attachment-name="${ATT.filename}"]`).first();
await chip.waitFor({ state: 'visible', timeout: 15000 });
const [download] = await Promise.all([
page.waitForEvent('download', { timeout: 15000 }),
chip.click(),
]);
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream) chunks.push(c as Buffer);
expect(Buffer.concat(chunks).toString()).toContain(ATT.content);
});
test('an inline image on another account\'s All-Mail message renders', async ({ page }) => {
const subject = `IT inline ${Date.now()}`;
// 1x1 PNG referenced from the HTML body via cid.
const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
await sendMail({
from: bob.email, authPass: bob.password, to: bob.email, subject, body: '',
inlineImage: { cid: 'inlinepic', contentType: 'image/png', base64: png, html: '<p>see below</p><img src="cid:inlinepic" alt="pic" width="1" height="1" />' },
});
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
});
await login(page, alice);
await addAccount(page, bob);
await switchAccount(page, alice.email);
await forceSync(page);
await openFolder(page, { name: '__cross_all__' });
await forceSync(page);
await expectEmailVisible(page, subject);
await emailItem(page, subject).first().click();
// The inline cid: image resolves to a blob URL fetched from bob's account.
// Pre-fix the fetch 404s and it falls back to the data:image/gif placeholder.
const img = page.frameLocator('iframe[title="Email content"]').locator('img').first();
await expect
.poll(async () => (await img.getAttribute('src').catch(() => '')) ?? '', { timeout: 15000 })
.toMatch(/^blob:/);
});
});
+190 -38
View File
@@ -36,21 +36,41 @@ export async function neutralizeDevOverlay(page: Page): Promise<void> {
}
/**
* Enable the cross-account Unified Mailbox before the app boots by seeding the
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
* feature gate (provided by integration/webmail-config/policy.json). Must be
* called before {@link login} so the init script is registered before the
* first navigation.
* Seed the persisted settings store before the app boots. Merges over the
* store defaults on rehydrate. Must be called before {@link login} so the init
* script is registered before the first navigation.
*/
export async function seedSettings(page: Page, settings: Record<string, unknown>): Promise<void> {
await page.addInitScript((s) => {
localStorage.setItem('settings-storage', JSON.stringify({ state: s, version: 7 }));
}, settings);
}
/**
* Enable the cross-account Unified Mailbox. Requires the
* `unifiedCrossAccountEnabled` admin feature gate (provided by
* integration/webmail-config/policy.json).
*/
export async function seedUnifiedSettings(page: Page): Promise<void> {
await page.addInitScript(() => {
localStorage.setItem(
'settings-storage',
JSON.stringify({
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
version: 7,
}),
);
await seedSettings(page, {
enableUnifiedMailbox: true,
unifiedCrossAccount: true,
includeGroupInUnified: true,
});
}
/**
* Enable the "All Mail" view. `crossAccount` spans every logged-in account
* (requires the `unifiedCrossAccountEnabled` gate); otherwise it is account-
* bounded (spans the active account's own + shared folders). The "All mail"
* entry itself is gated by `crossAllViewEnabled` (also in policy.json).
*/
export async function seedAllMailSettings(page: Page, opts: { crossAccount?: boolean } = {}): Promise<void> {
await seedSettings(page, {
enableUnifiedMailbox: true,
enableCrossAllView: true,
includeGroupInUnified: true,
unifiedCrossAccount: !!opts.crossAccount,
});
}
@@ -117,10 +137,75 @@ export async function forceSync(page: Page): Promise<void> {
await page.evaluate(() => document.dispatchEvent(new Event('visibilitychange')));
}
// ─── Composer / drafts ────────────────────────────────────────────────────
/** Open the composer via the keyboard shortcut and wait for it to render. */
export async function openComposer(page: Page): Promise<void> {
await page.keyboard.press('c');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'visible', timeout: 15000 });
}
/** Add a recipient to the To field (commits it as a chip with Enter). */
export async function addRecipient(page: Page, email: string): Promise<void> {
const input = page.locator('[data-testid="composer-to"] input').first();
await input.click();
await input.fill(email);
await input.press('Enter');
}
/** Select a sending identity in the From dropdown by its identity id. */
export async function setFrom(page: Page, identityId: string): Promise<void> {
await page.locator('[data-testid="composer-from"]').selectOption({ value: identityId });
}
/** Fill the subject field. */
export async function setSubject(page: Page, subject: string): Promise<void> {
await page.locator('[data-testid="composer-subject"]').fill(subject);
}
/** Wait until the composer reports the draft as saved. */
export async function waitDraftSaved(page: Page): Promise<void> {
await expect(page.locator('[data-testid="composer-save-status"]')).toHaveAttribute('data-status', 'saved', {
timeout: 20000,
});
}
/** Close the composer (draft is auto-saved). */
export async function closeComposer(page: Page): Promise<void> {
await page.keyboard.press('Escape');
await page.locator('[data-testid="email-composer"]').waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
}
/** Recipient chips currently shown in the composer's To field. */
export async function composerRecipients(page: Page): Promise<string[]> {
const to = page.locator('[data-testid="composer-to"]');
const text = (await to.innerText()).toLowerCase();
return text.split(/\s+/).filter((t) => t.includes('@'));
}
/**
* The sender addresses the composer's From control offers.
*
* With more than one identity the control is a <select> and each choice is an
* <option>; with a single identity it collapses to a static <span> that shows
* only that address. Returning the raw text of whichever is rendered lets a
* test assert on the *set of senders* without caring which shape it took.
*/
export async function composerFromOptions(page: Page): Promise<string[]> {
const from = page.locator('[data-testid="composer-from"]').first();
await from.waitFor({ state: 'visible', timeout: 10000 });
if ((await from.locator('option').count()) > 0) {
return from.locator('option').allTextContents();
}
return [await from.innerText()];
}
export interface FolderSelector {
role?: string;
name?: string;
mailboxId?: string;
/** true = only shared-account folders, false = only own folders. */
shared?: boolean;
}
/** Locator for a sidebar folder row. */
@@ -129,9 +214,24 @@ export function folderRow(page: Page, sel: FolderSelector): Locator {
if (sel.role) s += `[data-folder-role="${sel.role}"]`;
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
if (sel.shared === true) s += '[data-shared="true"]';
if (sel.shared === false) s += ':not([data-shared="true"])';
return page.locator(s);
}
/**
* Expand the sidebar "Shared" section and the given sharer's shared-account
* group so its folders (data-shared="true") render. Idempotent.
*/
export async function expandSharedFolders(page: Page, sharerEmail: string): Promise<void> {
const section = page.locator('[data-testid="section-shared"]');
await section.waitFor({ state: 'visible', timeout: 30000 });
if ((await section.getAttribute('data-expanded')) !== 'true') await section.click();
const account = page.locator(`[data-testid="section-shared-account"][data-section-name="${sharerEmail}"]`);
await account.waitFor({ state: 'visible', timeout: 30000 });
if ((await account.getAttribute('data-expanded')) !== 'true') await account.click();
}
export interface FolderCounts {
unread: number;
total: number;
@@ -157,6 +257,31 @@ export async function expectFolderUnread(page: Page, sel: FolderSelector, expect
.toBe(expected);
}
/** The JMAP (UI) mailbox id backing a folder row — namespaced for shared folders. */
export async function folderMailboxId(page: Page, sel: FolderSelector): Promise<string> {
const id = await folderRow(page, sel).first().getAttribute('data-mailbox-id');
if (!id) throw new Error(`folder ${JSON.stringify(sel)} has no data-mailbox-id`);
return id;
}
/**
* Move an email to `destMailboxId` (a UI mailbox id, e.g. from
* {@link folderMailboxId}) via the list context menu's "Move to" submenu.
*/
export async function moveEmailTo(page: Page, subject: string, destMailboxId: string): Promise<void> {
const row = emailItem(page, subject).first();
await row.waitFor({ state: 'visible' });
const submenu = page.locator('[data-testid="ctx-move-to"]');
await expect(async () => {
await row.click({ button: 'right' });
await submenu.waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await submenu.hover();
const target = page.locator(`[data-testid="move-to:${destMailboxId}"]`);
await target.waitFor({ state: 'visible', timeout: 5000 });
await target.click();
}
/** Poll until a folder's total count reaches `expected`. */
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
await expect
@@ -164,36 +289,39 @@ export async function expectFolderTotal(page: Page, sel: FolderSelector, expecte
.toBe(expected);
}
/**
* Assert a folder's counts, nudging a reconcile (visibilitychange ->
* checkForStateChanges) before *every* poll. Use for counters that update via
* reconcile rather than live SSE push after a server-side move/delete, a
* mark-as-spam, or a shared-account change where a single missed reconcile
* would otherwise flake. Only the provided fields are compared.
*/
export async function expectFolderCountsSynced(
page: Page,
sel: FolderSelector,
expected: { unread?: number; total?: number },
timeout = 45000,
): Promise<void> {
await expect
.poll(
async () => {
await forceSync(page);
const c = await folderCounts(page, sel);
return {
...(expected.unread !== undefined ? { unread: c.unread } : {}),
...(expected.total !== undefined ? { total: c.total } : {}),
};
},
{ timeout, intervals: [500, 1000, 1500, 2000, 2000, 3000] },
)
.toEqual(expected);
}
/** Click a folder row to select it. */
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
await folderRow(page, sel).first().click();
}
/** Open the "New message" composer and wait for it to render. */
export async function openComposer(page: Page): Promise<Locator> {
await page.locator('[data-tour="compose-button"]').first().click();
const composer = page.locator('[data-testid="email-composer"]');
await composer.waitFor({ state: 'visible', timeout: 15000 });
return composer;
}
/**
* The sender addresses the composer's From control offers.
*
* With more than one identity the control is a <select> and each choice is an
* <option>; with a single identity it collapses to a static <span> that shows
* only that address. Returning the raw text of whichever is rendered lets a
* test assert on the *set of senders* without caring which shape it took.
*/
export async function composerFromOptions(page: Page): Promise<string[]> {
const from = page.locator('[data-testid="composer-from"]').first();
await from.waitFor({ state: 'visible', timeout: 10000 });
if ((await from.locator('option').count()) > 0) {
return from.locator('option').allTextContents();
}
return [await from.innerText()];
}
/** Locator for an email row by (exact) subject. */
export function emailItem(page: Page, subject: string): Locator {
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
@@ -203,3 +331,27 @@ export function emailItem(page: Page, subject: string): Locator {
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
}
/** Assert an email row's unread state (from its `data-unread` attribute). */
export async function expectEmailUnread(page: Page, subject: string, unread: boolean, timeout = 20000): Promise<void> {
await expect(emailItem(page, subject).first()).toHaveAttribute('data-unread', String(unread), { timeout });
}
/**
* Open an email's right-click context menu and click one of its actions.
* `testId` is one of: `ctx-delete`, `ctx-spam`, `ctx-not-spam`,
* `ctx-mark-read`, `ctx-mark-unread`.
*/
export async function emailContextAction(page: Page, subject: string, testId: string): Promise<void> {
const row = emailItem(page, subject).first();
await row.waitFor({ state: 'visible' });
await row.scrollIntoViewIfNeeded();
const item = page.locator(`[data-testid="${testId}"]`);
// Right-click can occasionally land before the list row is interactive;
// retry opening the menu until the action item is actually present.
await expect(async () => {
await row.click({ button: 'right' });
await item.waitFor({ state: 'visible', timeout: 2000 });
}).toPass({ timeout: 15000 });
await item.click();
}
+138 -4
View File
@@ -12,9 +12,22 @@ import { JMAP_URL } from './config';
const CORE = 'urn:ietf:params:jmap:core';
const MAIL = 'urn:ietf:params:jmap:mail';
// Identity/* lives under the submission capability, not mail.
const PRINCIPALS = 'urn:ietf:params:jmap:principals';
const SUBMISSION = 'urn:ietf:params:jmap:submission';
/** Rights granted on a shared mailbox (JMAP ACL). */
export const FULL_MAILBOX_RIGHTS = {
mayReadItems: true,
mayAddItems: true,
mayRemoveItems: true,
maySetSeen: true,
maySetKeywords: true,
mayCreateChild: true,
mayRename: false,
mayDelete: false,
maySubmit: false,
};
interface JmapMailbox {
id: string;
name: string;
@@ -65,16 +78,91 @@ export class JmapClient {
.map(([, name]) => name);
}
async request(methodCalls: MethodCall[]): Promise<any> {
async request(methodCalls: MethodCall[], using: string[] = [CORE, MAIL, SUBMISSION]): Promise<any> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
body: JSON.stringify({ using: [CORE, MAIL, SUBMISSION], methodCalls }),
body: JSON.stringify({ using, methodCalls }),
});
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
return res.json();
}
/** All sending identities of this account. */
async identities(): Promise<Array<{ id: string; name: string; email: string }>> {
const r = await this.request([['Identity/get', { accountId: this.accountId }, '0']], [CORE, SUBMISSION]);
return r.methodResponses[0][1].list;
}
/**
* Ensure a second sending identity `name <email>` exists (idempotent by
* name). Returns its id. Used to make the composer's From selector appear so
* a changed sender can be exercised.
*/
async ensureIdentity(name: string, email: string): Promise<string> {
const existing = (await this.identities()).find((i) => i.name === name);
if (existing) return existing.id;
const r = await this.request(
[['Identity/set', { accountId: this.accountId, create: { alt: { name, email, replyTo: null } } }, '0']],
[CORE, SUBMISSION],
);
const created = r.methodResponses[0][1].created?.alt;
if (!created) throw new Error(`Identity/set failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Resolve another user's principal id (needed as the key in `shareWith`). */
async principalIdByEmail(email: string): Promise<string> {
const r = await this.request(
[
['Principal/query', { accountId: this.accountId, filter: { email } }, '0'],
['Principal/get', { accountId: this.accountId, '#ids': { resultOf: '0', name: 'Principal/query', path: '/ids' } }, '1'],
],
[CORE, PRINCIPALS],
);
const list = r.methodResponses[1][1].list as Array<{ id: string; email?: string }>;
const match = list.find((p) => p.email === email) ?? list[0];
if (!match) throw new Error(`No principal found for ${email}`);
return match.id;
}
/**
* Create a folder in this account and share it with `granteeEmail`. Returns
* the new mailbox id. The grantee then sees this account as a shared account
* in their JMAP session.
*/
async createSharedFolder(name: string, granteeEmail: string): Promise<string> {
const principalId = await this.principalIdByEmail(granteeEmail);
const r = await this.request([
['Mailbox/set', {
accountId: this.accountId,
create: { shared: { name, shareWith: { [principalId]: FULL_MAILBOX_RIGHTS } } },
}, '0'],
]);
const created = r.methodResponses[0][1].created?.shared;
if (!created) throw new Error(`createSharedFolder failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Grant `granteeEmail` access to an existing mailbox of this account. */
async shareMailbox(mailboxId: string, granteeEmail: string): Promise<void> {
const principalId = await this.principalIdByEmail(granteeEmail);
await this.request([
['Mailbox/set', {
accountId: this.accountId,
update: { [mailboxId]: { [`shareWith/${principalId}`]: FULL_MAILBOX_RIGHTS } },
}, '0'],
]);
}
/** Grant `granteeEmail` access to a system folder (by role) of this account. */
async shareMailboxByRole(role: string, granteeEmail: string): Promise<string> {
const mb = await this.mailboxByRole(role);
if (!mb) throw new Error(`No ${role} mailbox to share`);
await this.shareMailbox(mb.id, granteeEmail);
return mb.id;
}
async mailboxes(): Promise<JmapMailbox[]> {
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
return r.methodResponses[0][1].list as JmapMailbox[];
@@ -130,6 +218,52 @@ export class JmapClient {
}
}
/** Move an email so it lives solely in `toMailboxId`. */
async moveEmail(emailId: string, toMailboxId: string): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { mailboxIds: { [toMailboxId]: true } } } }, '0'],
]);
}
/** Deliver-and-file: create/find a custom folder and drop a message id into it. */
async moveEmailToFolder(emailId: string, folderName: string): Promise<string> {
const id = await this.createMailbox(folderName);
await this.moveEmail(emailId, id);
return id;
}
/** Create a draft message (with the $draft keyword) in the Drafts folder. */
async createDraft(subject: string, toEmail: string): Promise<string> {
const drafts = await this.mailboxByRole('drafts');
if (!drafts) throw new Error('No Drafts mailbox');
const r = await this.request([
['Email/set', {
accountId: this.accountId,
create: {
d: {
mailboxIds: { [drafts.id]: true },
keywords: { $draft: true },
from: [{ email: this.email }],
to: [{ email: toEmail }],
subject,
bodyValues: { b: { value: 'server-created draft body' } },
textBody: [{ partId: 'b', type: 'text/plain' }],
},
},
}, '0'],
]);
const created = r.methodResponses[0][1].created?.d;
if (!created) throw new Error(`createDraft failed: ${JSON.stringify(r.methodResponses[0][1])}`);
return created.id;
}
/** Set or clear the $seen keyword on an email. */
async setSeen(emailId: string, seen: boolean): Promise<void> {
await this.request([
['Email/set', { accountId: this.accountId, update: { [emailId]: { [`keywords/$seen`]: seen ? true : null } } }, '0'],
]);
}
/** Look up an email id by subject within an optional mailbox. */
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
const filter: Record<string, unknown> = { subject };
@@ -139,7 +273,7 @@ export class JmapClient {
['Email/get', {
accountId: this.accountId,
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
}, '1'],
]);
return r.methodResponses[1][1].list[0];
+52 -2
View File
@@ -24,6 +24,13 @@ interface SendOptions {
body: string;
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
headers?: Record<string, string>;
/** Optional single attachment (sent as multipart/mixed, base64). */
attachment?: { filename: string; contentType: string; content: string };
/**
* Optional inline image referenced by the HTML body via `cid:<cid>`. Sent as
* multipart/related; `base64` is the pre-encoded image payload.
*/
inlineImage?: { cid: string; contentType: string; base64: string; html: string };
}
class SmtpError extends Error {}
@@ -110,14 +117,57 @@ export async function sendMail(opts: SendOptions): Promise<void> {
From: opts.from,
To: recipients.join(', '),
Subject: opts.subject,
'Content-Type': 'text/plain; charset=utf-8',
...opts.headers,
};
let mime: string;
if (opts.inlineImage) {
const boundary = 'itrelated_boundary_0001';
headers['MIME-Version'] = '1.0';
headers['Content-Type'] = `multipart/related; boundary="${boundary}"`;
const b64 = opts.inlineImage.base64.replace(/(.{76})/g, '$1\r\n');
mime = [
`--${boundary}`,
'Content-Type: text/html; charset=utf-8',
'',
crlf(opts.inlineImage.html),
`--${boundary}`,
`Content-Type: ${opts.inlineImage.contentType}`,
`Content-ID: <${opts.inlineImage.cid}>`,
'Content-Disposition: inline',
'Content-Transfer-Encoding: base64',
'',
b64,
`--${boundary}--`,
].join('\r\n');
} else if (opts.attachment) {
const boundary = 'itmixed_boundary_0001';
headers['MIME-Version'] = '1.0';
headers['Content-Type'] = `multipart/mixed; boundary="${boundary}"`;
const b64 = Buffer.from(opts.attachment.content).toString('base64').replace(/(.{76})/g, '$1\r\n');
mime = [
`--${boundary}`,
'Content-Type: text/plain; charset=utf-8',
'',
crlf(opts.body),
`--${boundary}`,
`Content-Type: ${opts.attachment.contentType}; name="${opts.attachment.filename}"`,
`Content-Disposition: attachment; filename="${opts.attachment.filename}"`,
'Content-Transfer-Encoding: base64',
'',
b64,
`--${boundary}--`,
].join('\r\n');
} else {
headers['Content-Type'] = 'text/plain; charset=utf-8';
mime = crlf(opts.body);
}
const headerBlock = Object.entries(headers)
.map(([k, v]) => `${k}: ${v}`)
.join('\r\n');
// Dot-stuff any line that begins with '.'
const safeBody = crlf(opts.body).replace(/\r\n\./g, '\r\n..');
const safeBody = mime.replace(/\r\n\./g, '\r\n..');
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
await waitReply('250');
send('QUIT');
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
).rejects.toThrow('Failed to fetch blob: 404');
});
});
// #281 V3: every email fetch path must namespace mailboxIds for shared/
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
describe('shared-account mailboxId namespacing', () => {
function queryAndGet(email: Record<string, unknown>) {
return {
methodResponses: [
['Email/query', { total: 1, ids: ['e1'] }, '0'],
['Email/get', { list: [email] }, '1'],
],
};
}
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
});
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
});
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
expect(emails[0].mailboxIds).toEqual({ inbox: true });
});
});
});
@@ -6,6 +6,7 @@ import {
buildCrossFilter,
getCrossUnreadTotal,
fetchCrossViewEmails,
advancedSearchCrossViewEmails,
resolveSourceFolderName,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
@@ -42,6 +43,26 @@ describe('getCrossIncludedMailboxes', () => {
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'projects']);
});
it('honors an explicit crossIncludedMailboxIds selection (folder picker)', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined), mb('archive', 'archive')],
// user picked inbox + archive, excluded projects - overrides role exclusion
crossIncludedMailboxIds: ['inbox', 'archive'],
});
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'archive']);
});
it('an empty selection yields no folders', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined)],
crossIncludedMailboxIds: [],
});
expect(getCrossIncludedMailboxes(account)).toEqual([]);
});
});
describe('buildCrossFilter', () => {
@@ -86,6 +107,22 @@ describe('getCrossUnreadTotal', () => {
});
expect(getCrossUnreadTotal([a, b])).toBe(10);
});
it('counts only the selected folders when crossIncludedMailboxIds is set; shared accounts stay unrestricted', () => {
// personal account narrowed to inbox only (projects excluded by the picker)
const personal = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox', 3), mb('projects', undefined, 4)],
crossIncludedMailboxIds: ['inbox'],
});
// shared account unrestricted -> role-exclusion default (inbox + custom)
const shared = makeAccount({
accountId: 'owner',
isShared: true,
mailboxes: [mb('ns:inbox', 'inbox', 5, 'orig-inbox'), mb('ns:team', undefined, 2, 'orig-team'), mb('ns:junk', 'junk', 9, 'orig-junk')],
});
expect(getCrossUnreadTotal([personal, shared])).toBe(3 + 5 + 2);
});
});
describe('resolveSourceFolderName', () => {
@@ -167,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
expect(result.errors.get('bad')).toBe('boom');
});
});
describe('advancedSearchCrossViewEmails', () => {
it('ANDs the advanced filter onto the cross-view membership', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
const [filter] = advancedSearchEmails.mock.calls[0];
expect(filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
});
});
it('uses only the membership filter when the extra filter is empty', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
const [filter] = advancedSearchEmails.mock.calls[0];
expect(filter).toEqual({ inMailbox: 'inbox' });
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { migratePolicyUnifiedMailbox } from '../migrate';
// migratePolicyUnifiedMailbox reads ADMIN_CONFIG_DIR at call time (see paths.ts),
// so each test points it at a fresh temp dir.
let dir: string;
const policyPath = () => path.join(dir, 'policy.json');
const markerPath = () => path.join(dir, '.migrated-unified-mailbox');
const writePolicy = (features: Record<string, unknown>) =>
writeFile(policyPath(), JSON.stringify({ features, restrictions: {} }, null, 2), 'utf-8');
const readFeatures = async () =>
JSON.parse(await readFile(policyPath(), 'utf-8')).features as Record<string, unknown>;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-'));
process.env.ADMIN_CONFIG_DIR = dir;
});
afterEach(async () => {
delete process.env.ADMIN_CONFIG_DIR;
await rm(dir, { recursive: true, force: true });
});
describe('migratePolicyUnifiedMailbox', () => {
it('enables unifiedCrossAccountEnabled when a cross view was active', async () => {
await writePolicy({ crossUnreadViewEnabled: true });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
expect(existsSync(markerPath())).toBe(true);
});
it('does not enable it for a standalone All-Mail-only policy', async () => {
await writePolicy({ allMailViewEnabled: true, crossUnreadViewEnabled: false, crossStarredViewEnabled: false, crossAllViewEnabled: false });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBeUndefined();
});
it('is a one-shot: a later admin disable survives a re-run', async () => {
await writePolicy({ crossAllViewEnabled: true });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
// Admin turns it back off; the marker is present, so re-running is a no-op.
await writePolicy({ crossAllViewEnabled: true, unifiedCrossAccountEnabled: false });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(false);
});
it('no policy.json: writes the marker and does not throw', async () => {
await migratePolicyUnifiedMailbox();
expect(existsSync(markerPath())).toBe(true);
expect(existsSync(policyPath())).toBe(false);
});
});
+17 -4
View File
@@ -33,12 +33,12 @@ class ConfigManager {
this.adminConfig = await this.readJsonFile('config.json') || {};
const policy = await this.readJsonFile('policy.json');
if (policy) {
this.policyCache = {
this.policyCache = ConfigManager.normalizePolicy({
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
});
} else {
this.policyCache = { ...DEFAULT_POLICY };
}
@@ -166,15 +166,28 @@ class ConfigManager {
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = {
this.policyCache = ConfigManager.normalizePolicy({
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
});
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
/**
* Migrates deprecated feature gates forward. The standalone "All Mail" view
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
* admin who enabled it keeps that entry available via `crossAllViewEnabled`.
* Idempotent - safe to run on every load.
*/
private static normalizePolicy(policy: SettingsPolicy): SettingsPolicy {
if (policy.features.allMailViewEnabled) {
policy.features.crossAllViewEnabled = true;
}
return policy;
}
/**
* Reload config from disk (for manual file edits or multi-instance).
*/
+60
View File
@@ -11,6 +11,7 @@ import {
import type { AdminConfigData, AdminStateData } from './types';
const MIGRATION_MARKER = '.migrated-v2';
const POLICY_UNIFIED_MARKER = '.migrated-unified-mailbox';
interface LegacyAdminData {
passwordHash: string;
@@ -59,6 +60,65 @@ export async function migrateLegacyAdminLayout(): Promise<void> {
}
}
/**
* One-shot policy migration for the Unified Mailbox rework. Before it, the
* cross views (crossUnread/crossStarred/crossAll) merged across every logged-in
* account, so an admin who had any of them enabled was already permitting
* cross-account aggregation. The new `unifiedCrossAccountEnabled` gate (default
* false) controls that capability, so enable it whenever a cross view was active
* - otherwise existing cross-account installs would silently lose the behaviour
* on upgrade (the per-user `unifiedCrossAccount` is AND-ed with this gate).
*
* Persisted + marker-guarded (not a per-load normalization) so a later admin
* decision to disable the gate survives restarts. Skipped on read-only config
* dirs - operators who locked their config must migrate manually (mirrors
* migrateLegacyAdminLayout). The deprecated `allMailViewEnabled` (a single-account
* view, never cross-account) deliberately does NOT trigger this.
*/
export async function migratePolicyUnifiedMailbox(): Promise<void> {
if (isConfigReadOnly()) return;
const markerPath = getConfigPath(POLICY_UNIFIED_MARKER);
if (existsSync(markerPath)) return;
try {
const policyPath = getConfigPath('policy.json');
if (existsSync(policyPath)) {
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(await readFile(policyPath, 'utf-8')) as Record<string, unknown>;
} catch {
logger.warn('policy.json is not valid JSON; skipping Unified Mailbox policy migration');
}
const features =
parsed && typeof parsed.features === 'object' && parsed.features
? (parsed.features as Record<string, unknown>)
: null;
if (features) {
const hadCrossAccount = !!(
features.crossUnreadViewEnabled ||
features.crossStarredViewEnabled ||
features.crossAllViewEnabled
);
if (hadCrossAccount && features.unifiedCrossAccountEnabled !== true) {
features.unifiedCrossAccountEnabled = true;
const tmp = policyPath + '.tmp';
await writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8');
await rename(tmp, policyPath);
logger.info('Migrated policy: enabled unifiedCrossAccountEnabled (cross-account views were active)');
}
}
}
await ensureConfigDir();
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
} catch (error) {
logger.warn('Unified Mailbox policy migration failed; will retry on next boot', {
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
/**
* If the existing admin.json carries timestamp fields (legacy mixed layout),
* split them into admin-state.json and rewrite admin.json without them.
+3
View File
@@ -60,10 +60,12 @@ export interface FeatureGates {
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
allMailViewEnabled: boolean;
crossUnreadViewEnabled: boolean;
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
unifiedCrossAccountEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -89,6 +91,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossUnreadViewEnabled: false,
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
};
export interface ThemePolicy {
+3 -3
View File
@@ -224,9 +224,9 @@ export interface IJMAPClient {
): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
getIdentities(): Promise<Identity[]>;
+100 -29
View File
@@ -1968,6 +1968,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Mirror getEmails: emails fetched from a delegated/shared account carry
// bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
// up with the namespaced ids the store holds for shared mailboxes. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
@@ -2008,6 +2015,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Namespace shared/delegated-account mailbox ids (see searchEmails). The
// cross-account views (All mail / Unread / Starred) browse via this method,
// so without it shared emails would carry bare owner ids there. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
@@ -3448,8 +3462,8 @@ export class JMAPClient implements IJMAPClient {
return response.blob();
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type);
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
return URL.createObjectURL(blob);
}
@@ -5728,8 +5742,8 @@ export class JMAPClient implements IJMAPClient {
return created as FileNode;
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type);
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -5742,6 +5756,7 @@ export class JMAPClient implements IJMAPClient {
}
private pollingInterval: NodeJS.Timeout | null = null;
private secondaryPollInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null;
@@ -5759,6 +5774,10 @@ export class JMAPClient implements IJMAPClient {
};
private static readonly POLLING_INTERVAL = 3_000;
// Shared/secondary accounts get no SSE push (Stalwart pushes the primary
// account only), so poll them on a slow cadence alongside SSE to keep their
// folder + unified/All-Mail counters from going stale between focus events.
private static readonly SECONDARY_POLL_INTERVAL = 20_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
@@ -5766,13 +5785,34 @@ export class JMAPClient implements IJMAPClient {
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
// SSE covers the primary account only; keep shared accounts fresh too.
this.startSecondaryAccountPoll();
} else {
// The fallback poll already covers every session account.
this.startPollingFallback();
}
this.setupBrowserEventListeners();
return true;
}
/**
* Slow poll of the session's shared/secondary accounts, run in parallel with
* SSE (which never reports them). Skipped when there are no shared accounts,
* and paused while the tab is hidden (visibilitychange forces a check on
* return). Reuses checkForStateChanges, which already reports per-account.
*/
private startSecondaryAccountPoll(): void {
if (this.secondaryPollInterval) return;
const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId);
if (!hasSecondary) return;
// Prime the per-account baseline so the first tick doesn't false-fire.
void this.fetchCurrentStates();
this.secondaryPollInterval = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
void this.checkForStateChanges();
}, JMAPClient.SECONDARY_POLL_INTERVAL);
}
private connectSSE(templateUrl: string): void {
if (this.isRateLimited()) {
this.scheduleSSEReconnect();
@@ -5899,12 +5939,30 @@ export class JMAPClient implements IJMAPClient {
}, JMAPClient.POLLING_INTERVAL);
}
/**
* Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE
* only pushes StateChange for the primary account, never for delegated/shared
* (secondary) accounts, so their folder counters and the unified/All-Mail
* badges that aggregate them would otherwise never refresh from a background
* change. Polling every session account (primary + shared) closes that gap on
* the visibility/interval reconcile path. Mailbox/Email get callIds are tagged
* with the accountId (`mbx:<id>` / `eml:<id>`) so each account is compared
* independently. (#shared-counter-push)
*/
private pollAccountIds(): string[] {
const ids = Object.keys(this.accounts || {});
return ids.length > 0 ? ids : [this.accountId];
}
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
const methodCalls: JMAPMethodCall[] = [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
];
const methodCalls: JMAPMethodCall[] = [];
for (const acctId of this.pollAccountIds()) {
methodCalls.push(
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
);
}
if (this.supportsCalendars()) {
using.push('urn:ietf:params:jmap:calendars');
@@ -5925,6 +5983,16 @@ export class JMAPClient implements IJMAPClient {
return { using, methodCalls };
}
/** Map a polled method response back to its (accountId, stateKey). */
private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null {
if (typeof callId === 'string') {
if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' };
if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' };
}
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
return stateKey ? { accountId: this.accountId, stateKey } : null;
}
private async fetchCurrentStates(): Promise<void> {
if (this.isRateLimited()) {
return;
@@ -5939,10 +6007,10 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (resolved && result?.state) {
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
}
}
}
@@ -5965,25 +6033,24 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
const changes: { [key: string]: string } = {};
let hasChanges = false;
// Build a per-account changed map so a background change in a shared
// (secondary) account is reported under its own accountId — which
// handleStateChange treats as "some mailbox changed" and refetches the
// full (own + delegated) mailbox list from.
const changedByAccount: Record<string, Record<string, string>> = {};
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
changes[stateKey] = result.state;
hasChanges = true;
}
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (!resolved || !result?.state) continue;
const key = `${resolved.accountId}:${resolved.stateKey}`;
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
}
this.pollingStates[key] = result.state;
}
if (hasChanges && this.stateChangeCallback) {
this.stateChangeCallback({
'@type': 'StateChange',
changed: { [this.accountId]: changes },
});
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
}
}
} catch {
@@ -5996,6 +6063,10 @@ export class JMAPClient implements IJMAPClient {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
if (this.secondaryPollInterval) {
clearInterval(this.secondaryPollInterval);
this.secondaryPollInterval = null;
}
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
@@ -6162,8 +6233,8 @@ export class JMAPClient implements IJMAPClient {
// ── S/MIME raw-email helpers ─────────────────────────────────────
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
+6 -13
View File
@@ -892,19 +892,12 @@ export function isUnifiedMailboxId(id: string): boolean {
}
/**
* Virtual mailbox id for the gated "All Mail" view: every folder of a single
* account merged into one date-sorted list. Distinct from the unified mailbox
* ids above, which merge one role across multiple accounts. Which folders are
* 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).
* Cross views shown in the unified ("Unified Mailbox") section: All mail /
* Unread / Starred. Each merges messages across the account boundary (the active
* account + its shared folders by default, or every logged-in account when the
* cross-account sub-option is on), narrowed by the user's folder selection (see
* `allMailFolderIds`). Distinct from the per-role unified ids (one role across
* accounts).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
+51 -3
View File
@@ -22,6 +22,18 @@ export interface UnifiedAccountClient {
// must use the mailbox's `originalId` and explicitly target this accountId
// so the server routes to the owner's data.
isShared?: boolean;
// Store-side mailbox ids that make up THIS account's contribution to the
// cross views (All mail / Unread / Starred). It is intentionally per-account,
// not a global list: mailbox ids are account-scoped, so an id from one account
// is meaningless in another. The effective folder set of a cross view is the
// UNION across every account's entry (one UnifiedAccountClient per account),
// i.e. the sum of the respective per-account selections.
//
// For personal accounts this is the user's folder selection
// (`allMailFolderIds[accountId]`); shared/group accounts are not individually
// configurable and leave this undefined. When undefined, getCrossIncludedMailboxes
// falls back to the role-exclusion default (inbox + custom folders).
crossIncludedMailboxIds?: string[];
}
export interface UnifiedFetchResult {
@@ -49,7 +61,12 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
*/
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
for (const m of mailboxes) {
if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name;
// All fetch paths now namespace shared emails' mailboxIds to the store id
// (`${ownerId}:${origId}`), so matching `m.id` works for own and shared
// alike. The `originalId` check stays as a defensive fallback for any email
// that still carries a bare owner id. (#281 V3)
if (email.mailboxIds?.[m.id]) return m.name;
if (m.originalId && email.mailboxIds?.[m.originalId]) return m.name;
}
return undefined;
}
@@ -313,10 +330,18 @@ export function fetchUnifiedMailboxCounts(
// 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).
* Mailboxes of an account included in the cross views (All mail / Unread /
* Starred). When the account carries an explicit `crossIncludedMailboxIds`
* selection (personal accounts honor the user's folder picker, shared accounts
* include everything), only those mailboxes are used. Otherwise it falls back
* to the role-exclusion default: everything whose role is not excluded (inbox +
* custom/no-role folders).
*/
export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] {
if (account.crossIncludedMailboxIds) {
const selected = new Set(account.crossIncludedMailboxIds);
return account.mailboxes.filter((m) => selected.has(m.id));
}
return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
}
@@ -445,6 +470,29 @@ export async function searchCrossViewEmails(
));
}
/**
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
* only the membership filter is used (equivalent to a plain browse).
*/
export async function advancedSearchCrossViewEmails(
accounts: UnifiedAccountClient[],
view: CrossView,
extraFilter: Record<string, unknown>,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const hasExtra = Object.keys(extraFilter).length > 0;
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
const membership = buildCrossFilter(view, ids);
const filter = hasExtra
? { operator: 'AND', conditions: [membership, extraFilter] }
: membership;
return account.client.advancedSearchEmails(filter, jmapAccountId, limit, position);
});
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Všechny archivy",
"unified_junk": "Všechen spam",
"all_accounts": "Všechny účty",
"unified_mailbox": "Sjednocená schránka",
"expand": "Rozbalit",
"collapse": "Sbalit",
"expand_tooltip": "Rozbalit",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Sjednocená schránka",
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Zahrnout skupinové schránky",
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Alle arkiver",
"unified_junk": "Alt spam",
"all_accounts": "Alle konti",
"unified_mailbox": "Samlet postkasse",
"expand": "Udvid",
"collapse": "Skjul",
"expand_tooltip": "Udvid",
@@ -927,14 +928,16 @@
"unified_mailbox": {
"label": "Samlet postkasse",
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Inkluder gruppepostkasser",
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Alle Archive",
"unified_junk": "Alle Spam",
"all_accounts": "Alle Konten",
"unified_mailbox": "Gemeinsames Postfach",
"expand": "Erweitern",
"collapse": "Einklappen",
"expand_tooltip": "Erweitern",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Gemeinsames Postfach",
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
"cross_account": {
"label": "Über alle Konten",
"description": "Den vereinheitlichten Posteingang über alle verbundenen Konten zusammenführen, statt beim aktiven Konto zu bleiben."
},
"include_group": {
"label": "Gruppenpostfächer einbeziehen",
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Gilt für {account}.",
+14 -11
View File
@@ -114,6 +114,7 @@
"unified_archive": "All Archive",
"unified_junk": "All Junk",
"all_accounts": "All Accounts",
"unified_mailbox": "Unified Mailbox",
"expand": "Expand",
"collapse": "Collapse",
"expand_tooltip": "Expand",
@@ -926,17 +927,19 @@
},
"unified_mailbox": {
"label": "Unified Mailbox",
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts",
"description": "Show combined folders (Inbox, Sent, etc.) for the active account and its shared folders.",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Include group inboxes",
"description": "Also merge shared/group inboxes into the unified view."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"folders_label": "Folders in the unified lists",
"folders_description": "Choose which of this account's folders are merged into the All mail / Unread / Starred lists.",
"account_hint": "Applies to {account}.",
"no_folders": "No folders available."
},
@@ -963,16 +966,16 @@
"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."
"label": "Unread",
"description": "Show an Unread entry in the Unified Mailbox listing unread mail across the selected folders."
},
"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."
"label": "Starred",
"description": "Show a Starred entry in the Unified Mailbox listing flagged/starred mail across the selected folders."
},
"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."
"label": "All mail",
"description": "Show an All mail entry in the Unified Mailbox listing all mail across the selected folders."
}
},
"keywords": {
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Todos los archivos",
"unified_junk": "Todo el spam",
"all_accounts": "Todas las cuentas",
"unified_mailbox": "Buzón unificado",
"expand": "Expandir",
"collapse": "Contraer",
"expand_tooltip": "Expandir",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Buzón unificado",
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Incluir buzones de grupo",
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "همه بایگانی‌ها",
"unified_junk": "همه هرزنامه‌ها",
"all_accounts": "همه حساب‌ها",
"unified_mailbox": "صندوق پستی یکپارچه",
"expand": "باز کردن",
"collapse": "جمع کردن",
"expand_tooltip": "باز کردن",
@@ -927,14 +928,16 @@
"unified_mailbox": {
"label": "صندوق پستی یکپارچه",
"description": "نمایش پوشه‌های ترکیبی در همه حساب‌های متصل",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "شامل صندوق ورودی گروه‌ها",
"description": "صندوق ورودی گروه‌های اشتراکی را هم ادغام کن"
}
},
"all_mail": {
"label": "همه ایمیل‌ها",
"description": "نمایش گزینه \"همه ایمیل‌ها\" بالای پوشه‌ها",
"folders_label": "پوشه‌ها در همه ایمیل‌ها",
"folders_description": "انتخاب کنید کدام پوشه‌ها ادغام شوند",
"no_folders": "پوشه‌ای موجود نیست",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Toutes les archives",
"unified_junk": "Tous les indésirables",
"all_accounts": "Tous les comptes",
"unified_mailbox": "Boîte aux lettres unifiée",
"expand": "Développer",
"collapse": "Réduire",
"expand_tooltip": "Développer",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Boîte aux lettres unifiée",
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Inclure les boîtes de groupe",
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -63,6 +63,7 @@
"jmap_server_auto_picked": "שרת שנבחר מדומיין הדוא״ל שלך."
},
"sidebar": {
"unified_mailbox": "תיבת דואר מאוחדת",
"close": "סגור",
"compose": "כתיבה",
"compose_hint": "כתיבה (c)",
@@ -889,14 +890,16 @@
"unified_mailbox": {
"label": "תיבת דואר מאוחדת",
"description": "הצג תיקיות משולבות (דואר נכנס, נשלח וכו׳) בכל החשבונות המחוברים",
"cross_account": {
"label": "בכל החשבונות",
"description": "מזג את תיבת הדואר המאוחדת בכל החשבונות המחוברים במקום להישאר בחשבון הפעיל."
},
"include_group": {
"label": "כלול תיבות דואר קבוצתיות",
"description": "גם מזג תיבות דואר משותפות/קבוצתיות לתצוגה המאוחדת."
}
},
"all_mail": {
"label": "כל הדוא״ל",
"description": "הצג ערך ״כל הדוא״ל״ מעל התיקיות שלך המחברות הודעות מכל תיקיות החשבון לרשימה אחת.",
"folders_label": "תיקיות בכל הדוא״ל",
"folders_description": "בחר אילו תיקיות מוזגות לתצוגת כל הדוא״ל.",
"account_hint": "חל על {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Összes archívum",
"unified_junk": "Összes spam",
"all_accounts": "Minden fiók",
"unified_mailbox": "Egységes postafiók",
"expand": "Kibontás",
"collapse": "Összecsukás",
"expand_tooltip": "Kibontás",
@@ -927,14 +928,16 @@
"unified_mailbox": {
"label": "Egységes postafiók",
"description": "Összevont mappák (Beérkező, Elküldött, stb.) megjelenítése az összes csatlakoztatott fiók között",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Csoportos postafiókok belefoglalása",
"description": "Megosztott/csoportos postafiókok egyesítése az egységes nézetbe."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Tutti gli archivi",
"unified_junk": "Tutto lo spam",
"all_accounts": "Tutti gli account",
"unified_mailbox": "Casella di posta unificata",
"expand": "Espandi",
"collapse": "Comprimi",
"expand_tooltip": "Espandi",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Casella di posta unificata",
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Includi le caselle di gruppo",
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "すべてのアーカイブ",
"unified_junk": "すべての迷惑メール",
"all_accounts": "すべてのアカウント",
"unified_mailbox": "統合メールボックス",
"expand": "展開",
"collapse": "折りたたむ",
"expand_tooltip": "展開",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "統合メールボックス",
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "グループ受信トレイを含める",
"description": "共有/グループ受信トレイも統合ビューに含めます。"
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "모든 보관함",
"unified_junk": "모든 스팸함",
"all_accounts": "모든 계정",
"unified_mailbox": "통합 메일함",
"expand": "펼치기",
"collapse": "접기",
"expand_tooltip": "펼치기",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "통합 메일함",
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "그룹 받은편지함 포함",
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Visi arhīvi",
"unified_junk": "Viss mēstules",
"all_accounts": "Visi konti",
"unified_mailbox": "Apvienotā pastkaste",
"expand": "Izvērst",
"collapse": "Sairt",
"expand_tooltip": "Izvērst",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Apvienotā pastkaste",
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Iekļaut grupas pastkastes",
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Alle archieven",
"unified_junk": "Alle spam",
"all_accounts": "Alle accounts",
"unified_mailbox": "Gecombineerd postvak",
"expand": "Uitklappen",
"collapse": "Inklappen",
"expand_tooltip": "Uitklappen",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Gecombineerd postvak",
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Groepspostvakken meenemen",
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Wszystkie archiwa",
"unified_junk": "Wszystkie spam",
"all_accounts": "Wszystkie konta",
"unified_mailbox": "Wspólna skrzynka",
"expand": "Rozwiń",
"collapse": "Zwiń",
"expand_tooltip": "Rozwiń",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Wspólna skrzynka",
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Uwzględnij skrzynki grupowe",
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Todos os arquivos",
"unified_junk": "Todo o spam",
"all_accounts": "Todas as contas",
"unified_mailbox": "Caixa de correio unificada",
"expand": "Expandir",
"collapse": "Recolher",
"expand_tooltip": "Expandir",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Caixa de correio unificada",
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Incluir caixas de grupo",
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Arhivă completă",
"unified_junk": "Toate mesajele nedorite",
"all_accounts": "Toate conturile",
"unified_mailbox": "Căsuță poștală unificată",
"expand": "Extindeți",
"collapse": "Reduceți",
"expand_tooltip": "Extindeți",
@@ -927,14 +928,16 @@
"unified_mailbox": {
"label": "Căsuță poștală unificată",
"description": "Afișați folderele combinate (Mesaje primite, Mesaje trimise etc.) pentru toate conturile conectate",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Includeți căsuțele de e-mail de grup",
"description": "De asemenea, integrați căsuțele de e-mail partajate/de grup în vizualizarea unificată."
}
},
"all_mail": {
"label": "Toate mesajele",
"description": "Afișați o intrare „Toate mesajele” deasupra folderelor, care reunește mesajele din toate folderele acestui cont într-o singură listă.",
"folders_label": "Dosare în „Toate mesajele”",
"folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.",
"account_hint": "Se aplică pentru {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Все архивы",
"unified_junk": "Весь спам",
"all_accounts": "Все аккаунты",
"unified_mailbox": "Общий почтовый ящик",
"expand": "Развернуть",
"collapse": "Свернуть",
"expand_tooltip": "Развернуть",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Общий почтовый ящик",
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Включать групповые ящики",
"description": "Также объединять общие/групповые ящики в едином представлении."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -64,6 +64,7 @@
}
},
"sidebar": {
"unified_mailbox": "Zjednotená schránka",
"close": "Zavrieť",
"compose": "Napísať",
"compose_hint": "Napísať (c)",
@@ -927,14 +928,16 @@
"unified_mailbox": {
"label": "Zjednotená schránka",
"description": "Zobrazovať zlúčené priečinky (Doručené, Odoslané atd.) zo všetkých pripojených účtov",
"cross_account": {
"label": "Naprieč všetkými účtami",
"description": "Zlúčiť zjednotenú schránku naprieč všetkými pripojenými účtami namiesto zotrvania v aktívnom účte."
},
"include_group": {
"label": "Zahrnúť skupinové schránky",
"description": "Zahrnúť do zjednoteného zobrazenia aj zdieľané/skupinové schránky."
}
},
"all_mail": {
"label": "Všetka pošta",
"description": "Zobraziť položku \"Všetka pošta\" nad priečinkami.",
"folders_label": "Priečinky vo Všetkej pošte",
"folders_description": "Vyberte, ktoré priečinky sa spoja do zobrazenia Všetka pošta.",
"account_hint": "Platí pre {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Tüm Arşiv",
"unified_junk": "Tüm Önemsiz",
"all_accounts": "Tüm Hesaplar",
"unified_mailbox": "Birleşik Posta Kutusu",
"expand": "Genişlet",
"collapse": "Daralt",
"expand_tooltip": "Genişlet",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Birleşik Posta Kutusu",
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Grup gelen kutularını dahil et",
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "Усі архіви",
"unified_junk": "Весь спам",
"all_accounts": "Усі облікові записи",
"unified_mailbox": "Спільна поштова скринька",
"expand": "Розгорнути",
"collapse": "Згорнути",
"expand_tooltip": "Розгорнути",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "Спільна поштова скринька",
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "Включати групові скриньки",
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
+5 -2
View File
@@ -114,6 +114,7 @@
"unified_archive": "所有归档",
"unified_junk": "所有垃圾邮件",
"all_accounts": "所有账户",
"unified_mailbox": "统一邮箱",
"expand": "展开",
"collapse": "收起",
"expand_tooltip": "展开",
@@ -924,14 +925,16 @@
"unified_mailbox": {
"label": "统一邮箱",
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
"cross_account": {
"label": "Across all accounts",
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
},
"include_group": {
"label": "包含群组收件箱",
"description": "在统一视图中也合并共享/群组收件箱。"
}
},
"all_mail": {
"label": "All Mail",
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
"folders_label": "Folders in All Mail",
"folders_description": "Choose which folders are merged into the All Mail view.",
"account_hint": "Applies to {account}.",
@@ -3,6 +3,7 @@ import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import type { Email, Mailbox } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { UnifiedAccountClient } from '@/lib/unified-mailbox';
// Regression coverage for issue #281: single-email actions performed in the
// unified inbox must be routed to the *email's own account* client, not the
@@ -54,6 +55,8 @@ function makeClient() {
toggleStar: vi.fn().mockResolvedValue(undefined),
moveEmail: vi.fn().mockResolvedValue(undefined),
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
} as unknown as IJMAPClient;
}
@@ -96,6 +99,9 @@ describe('unified-view single-email action routing (#281)', () => {
processingReadStatus: new Set(),
selectedEmail: null,
selectedEmailIds: new Set(),
unifiedScope: [],
unifiedCounts: [],
crossUnreadCount: 0,
emails: [
// 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 } }),
@@ -180,6 +186,96 @@ describe('unified-view single-email action routing (#281)', () => {
expect(activeClient.toggleStar).toHaveBeenCalledWith('email-shared', true, 'owner-x');
});
it('decrements a shared/group folder counter when deleting from the unified view', async () => {
// Real app: the active account's `mailboxes` includes its delegated shared
// folders (namespaced id + originalId + owner accountId). Unified-fetched
// shared emails carry the owner's BARE mailboxIds and sourceAccountId=owner.
// Regression: emailInMailbox missed these, so the shared folder's badge
// stayed at its old value after deleting in All mail / All unread.
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
role: 'inbox', isShared: true, accountId: 'owner-x',
unreadEmails: 4, totalEmails: 10,
}),
],
emails: [
makeEmail({
id: 'email-shared', accountId: 'owner-x',
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
keywords: {}, // unread
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
});
await useEmailStore.getState().batchDelete(activeClient, true);
expect(activeClient.batchDeleteEmails).toHaveBeenCalledWith(['email-shared'], 'owner-x');
const shared = useEmailStore.getState().mailboxes.find(m => m.id === 'owner-x:x-inbox')!;
expect(shared.unreadEmails).toBe(3); // was 4
expect(shared.totalEmails).toBe(9); // was 10
});
it('decrements the unified-section badges when deleting from the unified view (live projection)', async () => {
// The unified-section badges (unifiedCounts / crossUnreadCount) must be a
// live projection of the per-account mailbox lists, NOT a stale server
// snapshot. Deleting a message in the unified view patches the folder's
// counter; the badge must follow in lockstep without a re-fetch.
const scope: UnifiedAccountClient[] = [
{
accountId: 'account-a', accountLabel: 'A', client: activeClient,
clientAccountId: 'account-a', jmapAccountId: 'account-a', isShared: false,
mailboxes: [makeMailbox({ id: 'a-inbox', role: 'inbox' })],
},
{
accountId: 'owner-x', accountLabel: 'Shared', client: activeClient,
clientAccountId: 'account-a', jmapAccountId: 'owner-x', isShared: true,
mailboxes: [makeMailbox({ id: 'owner-x:x-inbox', originalId: 'x-inbox', role: 'inbox', isShared: true, accountId: 'owner-x' })],
},
];
useEmailStore.setState({
mailboxes: [
makeMailbox({ id: 'a-inbox', role: 'inbox', unreadEmails: 2, totalEmails: 5 }),
makeMailbox({
id: 'owner-x:x-inbox', originalId: 'x-inbox', name: 'Shared Inbox',
role: 'inbox', isShared: true, accountId: 'owner-x',
unreadEmails: 4, totalEmails: 10,
}),
],
emails: [
makeEmail({
id: 'email-shared', accountId: 'owner-x',
sourceClientAccountId: 'account-a', sourceAccountId: 'owner-x',
keywords: {}, // unread
mailboxIds: { 'x-inbox': true }, // BARE owner id (not namespaced)
}),
],
selectedEmailIds: new Set(['email-shared']),
});
// Seed the badges from the scope (also stores unifiedScope).
useEmailStore.getState().refreshUnifiedCounts(scope);
useEmailStore.getState().refreshCrossCounts(scope);
const before = useEmailStore.getState();
expect(before.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 6, totalEmails: 15 });
expect(before.crossUnreadCount).toBe(6);
await useEmailStore.getState().batchDelete(activeClient, true);
const after = useEmailStore.getState();
// Underlying folder counter dropped...
expect(after.mailboxes.find(m => m.id === 'owner-x:x-inbox')!.unreadEmails).toBe(3);
// ...and the unified-section badges followed via the live projection.
expect(after.unifiedCounts.find(c => c.role === 'inbox')).toMatchObject({ unreadEmails: 5, totalEmails: 14 });
expect(after.crossUnreadCount).toBe(5);
});
it('still uses the active/passed client outside unified view', async () => {
useEmailStore.setState({
isUnifiedView: false,
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore } from '../settings-store';
import { useSettingsStore, migrateSettings } from '../settings-store';
describe('settings-store per-account allMailFolderIds', () => {
beforeEach(() => {
@@ -53,3 +53,43 @@ describe('settings-store per-account allMailFolderIds', () => {
});
});
});
describe('migrateSettings v5 -> v6 (Unified Mailbox rework)', () => {
it('keeps cross-account users cross-account when any cross view was on, and enables shared', () => {
const out = migrateSettings(
{ allMailFolderIds: {}, enableCrossUnreadView: true, enableAllMailView: false, includeGroupInUnified: false },
5,
) as unknown as Record<string, unknown>;
expect(out.unifiedCrossAccount).toBe(true);
// shared inclusion is enabled for every migrated config, even if it was off
expect(out.includeGroupInUnified).toBe(true);
expect(out.enableAllMailView).toBeUndefined();
});
it('folds a standalone All-Mail user into the account-bounded unified "All mail" entry', () => {
const out = migrateSettings(
{
allMailFolderIds: { 'acct-1': ['inbox', 'projects'] },
enableAllMailView: true,
enableUnifiedMailbox: false,
enableCrossUnreadView: false,
enableCrossStarredView: false,
enableCrossAllView: false,
},
5,
) as unknown as Record<string, unknown>;
expect(out.enableUnifiedMailbox).toBe(true);
expect(out.enableCrossAllView).toBe(true);
expect(out.unifiedCrossAccount).toBe(false); // new account-bounded default
expect(out.includeGroupInUnified).toBe(true);
// folder selection carries over unchanged -> narrows the unified lists
expect(out.allMailFolderIds).toEqual({ 'acct-1': ['inbox', 'projects'] });
expect(out.enableAllMailView).toBeUndefined();
});
it('a fresh user gets account-bounded defaults', () => {
const out = migrateSettings({ allMailFolderIds: {} }, 5) as unknown as Record<string, unknown>;
expect(out.unifiedCrossAccount).toBe(false);
expect(out.includeGroupInUnified).toBe(true);
});
});
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useSettingsStore } from '../settings-store';
import { useSettingsStore, migrateSettings } from '../settings-store';
describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
beforeEach(() => {
@@ -55,4 +55,27 @@ describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' });
});
});
describe('migrateSettings identity map', () => {
it('adds an empty preferredIdentityIds map for pre-v6 users', () => {
const out = migrateSettings({ allMailFolderIds: {} }, 6) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({});
});
it('coerces a non-record preferredIdentityIds to an empty map', () => {
const out = migrateSettings(
{ allMailFolderIds: {}, preferredIdentityIds: ['b'] },
7,
) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({});
});
it('preserves a valid per-account map across migration', () => {
const out = migrateSettings(
{ allMailFolderIds: {}, preferredIdentityIds: { 'acct-1': 'b' } },
7,
) as unknown as Record<string, unknown>;
expect(out.preferredIdentityIds).toEqual({ 'acct-1': 'b' });
});
});
});
+201 -114
View File
@@ -1,5 +1,5 @@
import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, ALL_MAIL_MAILBOX_ID, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, 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";
@@ -7,7 +7,7 @@ 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, fetchCrossViewEmails, searchCrossViewEmails, getCrossUnreadTotal, resolveSourceFolderName, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { fetchUnifiedEmails, fetchUnifiedMailboxCounts, searchUnifiedEmails, advancedSearchUnifiedEmails, fetchCrossViewEmails, searchCrossViewEmails, advancedSearchCrossViewEmails, getCrossUnreadTotal, type UnifiedAccountClient, type UnifiedMailboxCounts } from "@/lib/unified-mailbox";
import { useAuthStore } from "@/stores/auth-store";
import { useAccountStore } from "@/stores/account-store";
@@ -82,9 +82,23 @@ interface EmailStore {
// isUnifiedView.
crossView: CrossView | null;
unifiedErrors: Map<string, string>; // accountId -> error message
// Unified-section sidebar badges. These are NOT an independent source of
// truth: they are a pure projection of the live per-account mailbox lists
// (`mailboxes` + `accountMailboxes`) over the last-known unified scope
// (`unifiedScope`). Recomputed automatically whenever those lists change (see
// the store subscription below), so optimistic delete/move/markRead patches and
// push-driven mailbox refreshes flow into the badges without a server round
// trip. (#281 follow-up: single source of truth for unified counters.)
unifiedCounts: UnifiedMailboxCounts[];
// Unread total across the cross-view included folders (badge for unread/all).
crossUnreadCount: number;
// The account/folder structure (which accounts, role mailboxes, cross-include
// selection) that the unified badges are projected over. Set by
// refreshUnifiedCounts/refreshCrossCounts from the freshly-built
// UnifiedAccountClient[]. The COUNTER values it carries are ignored at
// projection time - live counters are read from `mailboxes`/`accountMailboxes`
// instead - so a stale snapshot here only affects structure, never numbers.
unifiedScope: UnifiedAccountClient[];
// Scheduled send state
scheduledEmails: ScheduledEmail[];
@@ -330,34 +344,19 @@ function resolveActionMailboxes(): Mailbox[] {
}
/**
* Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the
* active/viewing account. Honors that account's `allMailFolderIds` entry; when
* not configured it defaults to every non-special (no-role) folder. Shared
* folders are excluded - All Mail is scoped to a single account. Returns
* JMAP-side ids (originalId for namespaced mailboxes).
* Resolves the store-side mailbox ids that make up a personal account's
* contribution to the unified cross views (All mail / Unread / Starred), honoring
* that account's `allMailFolderIds` folder selection. Returns `undefined` when the
* account has no explicit selection, so the cross views fall back to their
* role-exclusion default (inbox + custom folders). An explicit `[]` selection
* yields an empty list (no own folders). `ownMailboxes` must already exclude
* shared folders - the picker only ever scopes the user's own folders.
*/
function resolveAllMailJmapIds(): string[] {
const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared);
// Per-account selection: read the entry for the account the view is scoped to
// (the Pro viewing override, else the global active account). A missing entry
// = "not configured" -> all no-role folders; an explicit [] = no folders.
const accountId = useEmailStore.getState().viewingAccountId ?? useAuthStore.getState().activeAccountId;
const configured = accountId ? useSettingsStore.getState().allMailFolderIds[accountId] : undefined;
const selected = configured === undefined
? mailboxes.filter((mb) => !mb.role)
: mailboxes.filter((mb) => configured.includes(mb.id));
return selected.map((mb) => mb.originalId || mb.id);
}
/**
* Builds the JMAP Email/query filter for the All Mail view from a set of
* mailbox ids - an OR of `inMailbox` conditions (or a single condition).
*/
function buildAllMailFilter(jmapMailboxIds: string[]): Record<string, unknown> {
if (jmapMailboxIds.length === 1) {
return { inMailbox: jmapMailboxIds[0] };
}
return { operator: 'OR', conditions: jmapMailboxIds.map((id) => ({ inMailbox: id })) };
function resolveCrossIncludedMailboxIds(accountId: string, ownMailboxes: Mailbox[]): string[] | undefined {
const configured = useSettingsStore.getState().allMailFolderIds[accountId];
if (configured === undefined) return undefined;
const selected = new Set(configured);
return ownMailboxes.filter((mb) => selected.has(mb.id)).map((mb) => mb.id);
}
/**
@@ -418,12 +417,24 @@ function resolveEmailActionContext(
* owner account reachable through each logged-in client. The shared entries
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
* requests via `originalId` + owner accountId.
*
* When `scopeToClientAccountId` is set, only the matching logged-in account
* (and the shared owners reachable through its client) is built - this keeps
* the unified mailbox within a single account boundary. Omitting it spans every
* logged-in account (the cross-account sub-option).
*
* Personal entries carry `crossIncludedMailboxIds` derived from the account's
* `allMailFolderIds` folder selection, restricting the All mail / Unread /
* Starred cross views to the chosen own folders (shared entries are left
* unrestricted so all their folders are included).
*/
export async function buildUnifiedAccountClients(
opts: { includeGroup?: boolean } = {},
opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {},
): Promise<UnifiedAccountClient[]> {
const { includeGroup = false } = opts;
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
const { includeGroup = false, scopeToClientAccountId } = opts;
const authAccounts = useAccountStore.getState().accounts.filter(
(a) => a.isConnected && (!scopeToClientAccountId || a.id === scopeToClientAccountId),
);
const allClients = useAuthStore.getState().getAllConnectedClients();
const built: UnifiedAccountClient[] = [];
// Per-account mailbox lists gathered here are cached into `accountMailboxes`
@@ -443,7 +454,7 @@ export async function buildUnifiedAccountClients(
// 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 });
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, clientAccountId: a.id, jmapAccountId: primaryJmapId, isShared: false, crossIncludedMailboxIds: resolveCrossIncludedMailboxIds(a.id, ownMailboxes) });
fetchedMailboxes[a.id] = ownMailboxes;
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
// resolves uniformly for personal and shared sources alike.
@@ -510,19 +521,31 @@ 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)
// Own-account emails carry bare ids (equal to both `id` and `originalId`).
// As of #281 V3, EVERY client fetch path (getEmails/getEmail/getThreadEmails/
// searchEmails/advancedSearchEmails) namespaces shared/delegated emails'
// `mailboxIds` to the store id (`${ownerId}:${origId}`), so the `ids[mailbox.id]`
// fast path below matches own and shared mailboxes alike - one id space.
// The `originalId` branches remain as a defensive fallback for any email that
// still carries a bare owner id (scoped to the owning account via
// `sourceAccountId === mailbox.accountId` so a bare owner id can't collide with
// another account's folder). (#281)
function emailInMailbox(
email: { mailboxIds?: Record<string, boolean> },
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
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];
if (
mailbox.isShared &&
mailbox.originalId &&
email.sourceAccountId &&
email.sourceAccountId === mailbox.accountId
) {
return !!ids[mailbox.originalId];
}
return false;
}
@@ -639,6 +662,62 @@ function applyDeleteCounters(
};
}
// ─── Unified-badge live projection ────────────────────────────────────────────
//
// The unified-section badges (unifiedCounts / crossUnreadCount) are a pure
// projection of the live per-account mailbox lists - the SAME lists the
// optimistic delete/move/markRead paths patch and that push refreshes. Rather
// than trusting the counter snapshot baked into the UnifiedAccountClient[] (which
// came from a server fetch and goes stale the moment a local mutation runs), we
// look each scope mailbox up by id in the live store list and use its current
// counters. This keeps the badges in lockstep with the per-folder counters - one
// source of truth, no server round trip, no eventual-consistency snap-back.
// The live store list that holds a unified-scope account's folders. Mirrors
// applyMailboxCounterUpdate's routing exactly: the active client's folders (incl.
// its delegated shared folders) live in `mailboxes`; every other logged-in
// account's folders live in `accountMailboxes[clientAccountId]`. Falls back to
// the account's own (snapshot) list so an unknown account still contributes its
// last-known counters instead of vanishing.
function liveListForAccount(
account: UnifiedAccountClient,
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): Mailbox[] {
const activeId = useAuthStore.getState().activeAccountId;
if (account.clientAccountId === activeId) return state.mailboxes;
return state.accountMailboxes[account.clientAccountId] ?? account.mailboxes;
}
// Returns a shallow copy of the scope account whose mailboxes carry LIVE counter
// values (matched by id against the live store list). Structure - which
// mailboxes, their roles, originalId, crossIncludedMailboxIds - is preserved from
// the scope snapshot; only the counter numbers are refreshed. This lets the
// existing lib aggregators (fetchUnifiedMailboxCounts / getCrossUnreadTotal) run
// unchanged over live data.
function accountWithLiveCounters(
account: UnifiedAccountClient,
state: { mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): UnifiedAccountClient {
const live = liveListForAccount(account, state);
const byId = new Map(live.map((m) => [m.id, m]));
return { ...account, mailboxes: account.mailboxes.map((m) => byId.get(m.id) ?? m) };
}
// Project the live mailbox state over the current unified scope into the two
// badge values. Pure function of (unifiedScope, mailboxes, accountMailboxes).
function projectUnifiedCounts(
state: { unifiedScope: UnifiedAccountClient[]; mailboxes: Mailbox[]; accountMailboxes: Record<string, Mailbox[]> },
): { unifiedCounts: UnifiedMailboxCounts[]; crossUnreadCount: number } {
if (state.unifiedScope.length === 0) {
return { unifiedCounts: [], crossUnreadCount: 0 };
}
const live = state.unifiedScope.map((a) => accountWithLiveCounters(a, state));
return {
unifiedCounts: fetchUnifiedMailboxCounts(live),
crossUnreadCount: getCrossUnreadTotal(live),
};
}
// 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.
@@ -706,6 +785,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors: new Map(),
unifiedCounts: [],
crossUnreadCount: 0,
unifiedScope: [],
// Scheduled send state
scheduledEmails: [],
@@ -871,7 +951,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// doesn't exist in the fetched list (e.g. after an account switch)
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
@@ -935,29 +1014,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
await get().fetchScheduledEmails(client);
return;
}
if (targetMailboxId === ALL_MAIL_MAILBOX_ID) {
const jmapIds = resolveAllMailJmapIds();
if (jmapIds.length === 0) {
set({ emails: [], hasMoreEmails: false, totalEmails: 0, isLoading: false });
return;
}
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
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);
}
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
isLoading: false,
});
return;
}
const effectiveClient = resolveActionClient(client);
// Find the mailbox to get its accountId (for shared folder support)
@@ -1018,9 +1074,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
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 hasFilters = !isFilterEmpty(get().searchFilters);
const result = hasFilters
? await advancedSearchCrossViewEmails(built, crossView, buildJMAPFilter(searchQuery, get().searchFilters, undefined), emailsPerPage, position)
: 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));
@@ -1106,21 +1165,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const { searchFilters } = get();
const hasFilters = !isFilterEmpty(searchFilters);
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
if (searchQuery || hasFilters) {
// Search within All Mail spans the whole account (no inMailbox).
result = hasFilters
? await effectiveClient.advancedSearchEmails(buildJMAPFilter(searchQuery, searchFilters, undefined), undefined, emailsPerPage, position)
: await effectiveClient.searchEmails(searchQuery, undefined, undefined, emailsPerPage, position);
} else {
const jmapIds = resolveAllMailJmapIds();
if (jmapIds.length === 0) {
set({ hasMoreEmails: false, isLoadingMore: false });
return;
}
result = await effectiveClient.advancedSearchEmails(buildAllMailFilter(jmapIds), undefined, emailsPerPage, position);
}
} else if (searchQuery || hasFilters) {
if (searchQuery || hasFilters) {
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
@@ -1146,13 +1191,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
result = await effectiveClient.getEmails(selectedKeyword ? undefined : jmapMailboxId, accountId, emailsPerPage, position, selectedKeyword ? `$label:${selectedKeyword}` : undefined, true);
}
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;
@@ -1815,24 +1853,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors = result.errors;
} else {
// Get the current mailbox to scope the search. In the All Mail view the
// search spans every folder of the account (no inMailbox constraint).
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
// Get the current mailbox to scope the search.
const mailboxes = resolveActionMailboxes();
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
// Use originalId for shared mailboxes
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
// Only pass accountId for shared mailboxes, not for primary account
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
}
const hookEdit = await emailHooks.onSearchResults.transform({
newEmailIds: [] as string[],
result: result,
query: query,
filters: searchFilters
const hookEdit = await emailHooks.onSearchResults.transform({
newEmailIds: [] as string[],
result: result,
query: query,
filters: searchFilters
});
result = hookEdit.result;
@@ -1843,7 +1879,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
result.total += newEmails.length;
}
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
query,
filters: searchFilters
});
@@ -1895,7 +1931,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (isUnifiedView && crossView) {
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
const built = await buildUnifiedAccountClients({ includeGroup });
result = await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, 0);
// Cross views apply the advanced filter (text + fields) on top of the
// view membership; an empty filter degrades to a plain membership query.
result = await advancedSearchCrossViewEmails(
built, crossView, buildJMAPFilter(searchQuery, searchFilters, undefined), emailsPerPage, 0,
);
unifiedErrors = result.errors;
} else if (isUnifiedView && unifiedRole) {
@@ -1911,10 +1951,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
unifiedErrors = result.errors;
} else {
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
@@ -2624,30 +2663,37 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Get the current account ID from the client (assuming primary account)
const accountId = client.getAccountId();
// Check if there are changes for this account
// Changes may arrive for the client's primary account OR a delegated
// shared/group owner it has access to. Active-account *view* concerns
// (current email list, scheduled, calendar, filters) key off the primary
// account only, but the mailbox-COUNT refresh must react to any changed
// account: the active client's getAllMailboxes returns own + delegated
// folders, and the unified-section counts project from that list. (#281)
const accountChanges = change.changed[accountId];
if (!accountChanges) return;
const anyMailboxChanged = Object.values(change.changed).some((c) => c?.Mailbox);
// Handle Email state changes - refresh current mailbox
if (accountChanges.Email) {
if (accountChanges?.Email) {
await get().refreshCurrentMailbox(client);
get().fetchTagCounts(client);
}
if (accountChanges.EmailSubmission) {
if (accountChanges?.EmailSubmission) {
await get().refreshScheduledMetadata(client);
if (get().isScheduledView) {
await get().fetchScheduledEmails(client);
}
}
// Handle Mailbox state changes - refresh mailbox list
if (accountChanges.Mailbox) {
// Handle Mailbox state changes - refresh mailbox list (own + delegated
// shared folders), so both the active account's and its shared folders'
// counters follow background activity.
if (anyMailboxChanged) {
await get().fetchMailboxes(client);
}
// Handle Calendar/CalendarEvent state changes - refresh calendar data
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
if (accountChanges?.Calendar || accountChanges?.CalendarEvent) {
const calendarStore = useCalendarStore.getState();
if (calendarStore.supportsCalendar) {
calendarStore.fetchCalendars(client);
@@ -2665,7 +2711,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
}
// Handle SieveScript state changes - refresh filter rules
if (accountChanges.SieveScript) {
if (accountChanges?.SieveScript) {
const { useFilterStore } = await import('./filter-store');
const filterStore = useFilterStore.getState();
if (filterStore.isSupported) {
@@ -3293,8 +3339,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
refreshUnifiedCounts: async (accounts) => {
try {
const counts = fetchUnifiedMailboxCounts(accounts);
set({ unifiedCounts: counts });
// Store the scope and project live counters over it. The badges then track
// the live mailbox lists via the store subscription (below), so subsequent
// optimistic mutations update them without another build/fetch.
set((state) => {
const next = { ...state, unifiedScope: accounts };
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
});
} catch (error) {
console.error('Failed to refresh unified counts:', error);
}
@@ -3333,7 +3384,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
refreshCrossCounts: (accounts) => {
try {
set({ crossUnreadCount: getCrossUnreadTotal(accounts) });
set((state) => {
const next = { ...state, unifiedScope: accounts };
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
});
} catch (error) {
console.error('Failed to refresh cross-account counts:', error);
}
@@ -3355,6 +3409,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
selectedEmail: leavingScheduled ? null : state.selectedEmail,
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
// Search is unavailable in the scheduled view (the input is disabled there).
// Reset any active search when entering it so a stale query can't linger or
// re-run when the user leaves again.
searchQuery: isScheduledView ? "" : state.searchQuery,
searchFilters: isScheduledView ? { ...DEFAULT_SEARCH_FILTERS } : state.searchFilters,
};
}),
clearPendingUndoSend: () => set({ pendingUndoSend: null }),
@@ -3730,3 +3789,31 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
},
}));
// Keep the unified-section badges in lockstep with the live per-account mailbox
// lists. Whenever `mailboxes`, `accountMailboxes`, or the unified scope change -
// i.e. after any optimistic delete/move/markRead patch or a push-driven mailbox
// refresh - re-project the badges from that single source of truth. The guard
// short-circuits on every unrelated state change (emails, loading flags, …) by
// reference equality, and our own counter writes don't re-enter the projection
// because they leave the three watched lists untouched (no loop). (#281)
useEmailStore.subscribe((state, prev) => {
if (
state.mailboxes === prev.mailboxes &&
state.accountMailboxes === prev.accountMailboxes &&
state.unifiedScope === prev.unifiedScope
) {
return;
}
if (state.unifiedScope.length === 0) return;
const projected = projectUnifiedCounts(state);
const sameCross = projected.crossUnreadCount === state.crossUnreadCount;
const sameUnified =
projected.unifiedCounts.length === state.unifiedCounts.length &&
projected.unifiedCounts.every((c, i) => {
const cur = state.unifiedCounts[i];
return cur && cur.role === c.role && cur.unreadEmails === c.unreadEmails && cur.totalEmails === c.totalEmails;
});
if (sameCross && sameUnified) return;
useEmailStore.setState(projected);
});
+77 -41
View File
@@ -233,22 +233,26 @@ interface SettingsState {
// Unified Mailbox
enableUnifiedMailbox: boolean;
// Include shared/delegated folders in the unified mailbox. Default true: the
// account-bounded unified view is defined by spanning the account's own folders
// plus every shared folder it can access.
includeGroupInUnified: boolean;
// When true, the unified mailbox merges across every logged-in account
// (cross-account). When false (default for new installs) it stays within the
// active account boundary (own + shared folders). Gated by the admin
// `unifiedCrossAccountEnabled` feature.
unifiedCrossAccount: boolean;
// All Mail view (gated): user toggle (like the unified mailbox) plus the set
// of 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.
enableAllMailView: boolean;
// Cross-account "All accounts" views (gated per-view by the admin policy)
// Unified Mailbox entries, each gated per-view by the admin policy. These show
// the All mail / Unread / Starred lists, scoped by `unifiedCrossAccount` and
// narrowed by the `allMailFolderIds` folder selection.
enableCrossUnreadView: boolean;
enableCrossStarredView: boolean;
enableCrossAllView: boolean;
// Per-account "All Mail" folder selection, keyed by AccountEntry.id. A
// missing entry = "not configured" -> defaults to every no-role folder; an
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
// Per-account folder selection narrowing the unified All mail / Unread /
// Starred lists, keyed by AccountEntry.id. A missing entry = "not configured"
// -> defaults to inbox + custom folders; an explicit [] = "no own folders".
allMailFolderIds: Record<string, string[]>;
// Per-account default sender identity, keyed by AccountEntry.id -> JMAP
@@ -444,10 +448,9 @@ const DEFAULT_SETTINGS = {
// Unified Mailbox
enableUnifiedMailbox: false,
includeGroupInUnified: false,
includeGroupInUnified: true,
unifiedCrossAccount: false,
// All Mail view (gated)
enableAllMailView: false,
allMailFolderIds: {} as Record<string, string[]>,
preferredIdentityIds: {} as Record<string, string>,
@@ -633,7 +636,7 @@ export const useSettingsStore = create<SettingsState>()(
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
enableUnifiedMailbox: state.enableUnifiedMailbox,
includeGroupInUnified: state.includeGroupInUnified,
enableAllMailView: state.enableAllMailView,
unifiedCrossAccount: state.unifiedCrossAccount,
allMailFolderIds: state.allMailFolderIds,
preferredIdentityIds: state.preferredIdentityIds,
enableCrossUnreadView: state.enableCrossUnreadView,
@@ -893,9 +896,36 @@ export const useSettingsStore = create<SettingsState>()(
}),
{
name: 'settings-storage',
version: 6,
migrate: (persisted, version) => {
const state = persisted as Record<string, unknown>;
version: 7,
migrate: migrateSettings,
onRehydrateStorage: () => {
return (state) => {
if (state) {
// Defensive: a legacy global array or any non-record value (e.g.
// synced from an older client) is coerced to an empty map so
// per-account consumers never see a non-record.
if (!isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
if (!isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
applyFontSize(state.fontSize);
applyDensity(state.density);
applyAnimations(state.animationsEnabled);
}
};
},
}
)
);
/**
* Versioned migration for persisted settings. Exported for tests. Mutates and
* returns the persisted record so each bump only needs to handle its own delta.
*/
export function migrateSettings(persisted: unknown, version: number): SettingsState {
const state = persisted as Record<string, unknown>;
if (version < 2 && state.listDensity) {
state.density = state.listDensity;
delete state.listDensity;
@@ -921,34 +951,40 @@ export const useSettingsStore = create<SettingsState>()(
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
// v6: introduced the per-account default-identity map (issue #507).
// Coerce any missing/legacy value to an empty record.
// "All accounts" was reworked into the account-bounded "Unified
// Mailbox". The standalone __all_mail__ view (`enableAllMailView`) was
// folded into the unified "All mail" entry (`enableCrossAllView`), and a
// `unifiedCrossAccount` toggle now governs whether the views span every
// logged-in account. Existing users keep their current behaviour:
// - if any cross view was on, they were already cross-account -> keep it on
// - else if only standalone All Mail was on, enable the account-bounded
// unified "All mail" entry (folder selection carries over via allMailFolderIds)
// Guarded at <7 (not <6) so users who stopped at main's interim v6
// identity-map bump - which shipped without this rework - still receive it.
if (version < 7) {
const hadCross = !!(state.enableCrossUnreadView || state.enableCrossStarredView || state.enableCrossAllView);
if (hadCross) {
state.unifiedCrossAccount = true;
} else if (state.enableAllMailView) {
state.enableUnifiedMailbox = true;
state.enableCrossAllView = true;
state.unifiedCrossAccount = false;
}
delete state.enableAllMailView;
if (typeof state.unifiedCrossAccount !== 'boolean') state.unifiedCrossAccount = false;
// The reworked unified mailbox spans the account's own folders plus its
// shared/group folders, so enable shared inclusion for every migrated
// configuration (matches the new-install default).
state.includeGroupInUnified = true;
}
// Per-account default-identity map (issue #507). Coerce any
// missing/legacy value to an empty record. Guarded at <6 so users who
// already received it via main's v6 bump keep their populated map.
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
return state as unknown as SettingsState;
},
onRehydrateStorage: () => {
return (state) => {
if (state) {
// Defensive: a legacy global array or any non-record value (e.g.
// synced from an older client) is coerced to an empty map so
// per-account consumers never see a non-record.
if (!isPlainRecord(state.allMailFolderIds)) {
state.allMailFolderIds = {};
}
if (!isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
applyFontSize(state.fontSize);
applyDensity(state.density);
applyAnimations(state.animationsEnabled);
}
};
},
}
)
);
}
// Helper functions to apply settings to DOM
function applyFontSize(size: FontSize) {
+4 -1
View File
@@ -8,7 +8,10 @@ export default defineConfig({
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
exclude: ['e2e/**', 'node_modules/**', '.next/**'],
// integration/** is the dockerized Playwright suite (run via
// `npm run test:integration`); examples/** is untracked sample code. Both
// use their own runners and must not be collected by vitest.
exclude: ['e2e/**', 'integration/**', 'examples/**', 'node_modules/**', '.next/**'],
},
resolve: {
alias: {