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:
+5
-5
@@ -4,9 +4,9 @@
|
|||||||
|
|
||||||
- Read, compose, reply, reply-all, and forward with a Tiptap rich text editor (inline images, drag-and-drop embedding, tables)
|
- 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
|
- 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
|
- 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
|
||||||
- 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
|
- 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
|
||||||
- "All Mail" view that merges an account's folders (with a configurable folder selection) into a single list
|
- 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
|
- 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
|
- 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
|
- 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)
|
- Configurable signature position (above or below quoted text)
|
||||||
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
- Sub-addressing (`user+tag@domain.com`) with configurable delimiter and contextual tag suggestions
|
||||||
- Shared folders across accounts
|
- 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
|
- Multiple JMAP servers per deployment with optional auto-pick by email domain
|
||||||
- Optional custom JMAP endpoints on the login form (`ALLOW_CUSTOM_JMAP_ENDPOINT`)
|
- 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
|
- 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
|
- 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)
|
- 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
|
- 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`)
|
- Admin toggle for search-engine indexing (`robots.txt` / `noindex`)
|
||||||
|
|||||||
+136
-52
@@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
|
|||||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
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 { useAccountStore } from "@/stores/account-store";
|
||||||
import { usePolicyStore } from "@/stores/policy-store";
|
import { usePolicyStore } from "@/stores/policy-store";
|
||||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||||
@@ -110,7 +110,7 @@ export default function Home() {
|
|||||||
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
const [conversationEmails, setConversationEmails] = useState<Email[]>([]);
|
||||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||||
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState<number | null>(null);
|
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 [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -356,10 +356,7 @@ export default function Home() {
|
|||||||
useProMultiAccountMailboxes();
|
useProMultiAccountMailboxes();
|
||||||
|
|
||||||
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
const enableUnifiedMailbox = useSettingsStore((s) => s.enableUnifiedMailbox);
|
||||||
const enableAllMailView = useSettingsStore((s) => s.enableAllMailView);
|
|
||||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
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
|
// 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
|
// 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 activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
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 accounts = useAccountStore((s) => s.accounts);
|
||||||
const connectedAccountsSignature = useMemo(
|
const connectedAccountsSignature = useMemo(
|
||||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||||
[accounts],
|
[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
|
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||||
// effects and one-shot actions in this page. Reads the includeGroup
|
// effects and one-shot actions in this page. Reads the settings at call time
|
||||||
// setting at call time so the latest toggle value is always honored.
|
// 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[]> => {
|
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({
|
return buildUnifiedAccountClients({
|
||||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
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]);
|
}, [isAuthenticated, client, fetchMailboxes, fetchEmails, fetchQuota, fetchTagCounts, refreshScheduledMetadata]);
|
||||||
|
|
||||||
// Push notifications: set up once per client and tear down when the client
|
// Push notifications: set up once per CONNECTED client and tear down when the
|
||||||
// goes away (logout or account switch). Kept separate from the fetch effect
|
// clients go away (logout or account switch). Kept separate from the fetch
|
||||||
// above so it still runs when data was prefetched at login time.
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated || !client) return;
|
if (!isAuthenticated || !client) return;
|
||||||
|
|
||||||
try {
|
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||||
client.onStateChange((change) => handleStateChange(change, client));
|
const cleanups: Array<() => void> = [];
|
||||||
const pushEnabled = client.setupPushNotifications();
|
|
||||||
if (pushEnabled) {
|
for (const [accId, c] of clients) {
|
||||||
setPushConnected(true);
|
try {
|
||||||
debug.log('push', '[Push] Push notifications successfully enabled');
|
if (accId === activeAccountId) {
|
||||||
} else {
|
c.onStateChange((change) => handleStateChange(change, c));
|
||||||
debug.log('push', '[Push] Push notifications not available on this server');
|
} 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 () => {
|
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
|
// 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
|
// 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;
|
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||||
refreshUnifiedCounts(built);
|
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
|
// System-notification click handler. The push SW navigates the user back
|
||||||
// here with `?email=<id>` (specific email it built the toast from) or
|
// here with `?email=<id>` (specific email it built the toast from) or
|
||||||
@@ -1823,7 +1861,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
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);
|
refreshUnifiedCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1845,7 +1894,18 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
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);
|
refreshCrossCounts(populated);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2191,13 +2251,16 @@ export default function Home() {
|
|||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
clearSearchFilters();
|
clearSearchFilters();
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
// In unified view the active "mailbox" is a virtual role, so refresh via
|
// In unified view the active "mailbox" is a virtual role or cross view, so
|
||||||
// the unified fan-out instead of fetchEmails.
|
// refresh via the unified fan-out instead of fetchEmails.
|
||||||
if (isUnifiedView) {
|
if (isUnifiedView) {
|
||||||
|
const populated = await buildPopulatedUnifiedAccounts();
|
||||||
const role = useEmailStore.getState().unifiedRole;
|
const role = useEmailStore.getState().unifiedRole;
|
||||||
|
const cross = useEmailStore.getState().crossView;
|
||||||
if (role) {
|
if (role) {
|
||||||
const populated = await buildPopulatedUnifiedAccounts();
|
|
||||||
await fetchUnifiedEmailsAction(populated, role);
|
await fetchUnifiedEmailsAction(populated, role);
|
||||||
|
} else if (cross) {
|
||||||
|
await fetchCrossViewAction(populated, cross);
|
||||||
}
|
}
|
||||||
return;
|
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) => {
|
const handleDownloadAttachment = async (blobId: string, name: string, type?: string, forceDownload?: boolean) => {
|
||||||
if (!client) return;
|
const { blobClient, accountId, clientAccountId } = resolveBlobSource(selectedEmail);
|
||||||
|
if (!blobClient) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { mailAttachmentAction } = useSettingsStore.getState();
|
const { mailAttachmentAction } = useSettingsStore.getState();
|
||||||
|
|
||||||
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
if (!forceDownload && mailAttachmentAction === 'preview' && isFilePreviewable(name, type)) {
|
||||||
setPreviewAttachment({ blobId, name, type });
|
setPreviewAttachment({ blobId, name, type, accountId, clientAccountId });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.downloadBlob(blobId, name, type);
|
await blobClient.downloadBlob(blobId, name, type, accountId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to download attachment:", error);
|
console.error("Failed to download attachment:", error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePreviewAttachmentDownload = useCallback(async () => {
|
const previewBlobClient = useCallback(() => {
|
||||||
if (!client || !previewAttachment) return;
|
const id = previewAttachment?.clientAccountId;
|
||||||
|
return id ? (useAuthStore.getState().getClientForAccount(id) ?? client) : client;
|
||||||
|
}, [previewAttachment, client]);
|
||||||
|
|
||||||
await client.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type);
|
const handlePreviewAttachmentDownload = useCallback(async () => {
|
||||||
}, [client, previewAttachment]);
|
const c = previewBlobClient();
|
||||||
|
if (!c || !previewAttachment) return;
|
||||||
|
|
||||||
|
await c.downloadBlob(previewAttachment.blobId, previewAttachment.name, previewAttachment.type, previewAttachment.accountId);
|
||||||
|
}, [previewBlobClient, previewAttachment]);
|
||||||
|
|
||||||
const getPreviewAttachmentContent = useCallback(async () => {
|
const getPreviewAttachmentContent = useCallback(async () => {
|
||||||
if (!client || !previewAttachment) {
|
const c = previewBlobClient();
|
||||||
|
if (!c || !previewAttachment) {
|
||||||
throw new Error('No attachment selected');
|
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 {
|
return {
|
||||||
blob,
|
blob,
|
||||||
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
contentType: previewAttachment.type || blob.type || 'application/octet-stream',
|
||||||
};
|
};
|
||||||
}, [client, previewAttachment]);
|
}, [previewBlobClient, previewAttachment]);
|
||||||
|
|
||||||
const handleQuickReply = async (body: string) => {
|
const handleQuickReply = async (body: string) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !selectedEmail) return;
|
||||||
@@ -2413,14 +2499,12 @@ export default function Home() {
|
|||||||
// Get current mailbox name for mobile header
|
// Get current mailbox name for mobile header
|
||||||
const currentMailboxName = isScheduledView
|
const currentMailboxName = isScheduledView
|
||||||
? t('sidebar.scheduled')
|
? t('sidebar.scheduled')
|
||||||
: selectedMailbox === ALL_MAIL_MAILBOX_ID
|
: (() => {
|
||||||
? t('sidebar.mailboxes.all_mail')
|
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
||||||
: (() => {
|
return mb
|
||||||
const mb = mailboxes.find(m => m.id === selectedMailbox);
|
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
||||||
return mb
|
: "Inbox";
|
||||||
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
|
})();
|
||||||
: "Inbox";
|
|
||||||
})();
|
|
||||||
const isFocusedMailLayout = mailLayout === 'focus';
|
const isFocusedMailLayout = mailLayout === 'focus';
|
||||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||||
@@ -2708,7 +2792,7 @@ export default function Home() {
|
|||||||
selectedKeyword={selectedKeyword}
|
selectedKeyword={selectedKeyword}
|
||||||
scheduledTotal={scheduledTotal}
|
scheduledTotal={scheduledTotal}
|
||||||
showScheduledMailbox={delayedSendSupported}
|
showScheduledMailbox={delayedSendSupported}
|
||||||
showAllMailMailbox={showAllMailMailbox}
|
crossAccountActive={crossAccountActive}
|
||||||
showCrossUnread={showCrossUnread}
|
showCrossUnread={showCrossUnread}
|
||||||
showCrossStarred={showCrossStarred}
|
showCrossStarred={showCrossStarred}
|
||||||
showCrossAll={showCrossAll}
|
showCrossAll={showCrossAll}
|
||||||
@@ -2835,8 +2919,8 @@ export default function Home() {
|
|||||||
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
className={cn("ps-9 h-9", searchQuery && "pe-8")}
|
||||||
data-search-input
|
data-search-input
|
||||||
data-tour="search-input"
|
data-tour="search-input"
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isScheduledView}
|
||||||
title={isUnifiedView ? t("unified_mailbox.search_unavailable") : isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
|
||||||
/>
|
/>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<button
|
||||||
@@ -2852,15 +2936,15 @@ export default function Home() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={toggleAdvancedSearch}
|
onClick={toggleAdvancedSearch}
|
||||||
disabled={isUnifiedView || isScheduledView}
|
disabled={isScheduledView}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex-shrink-0 p-2 rounded-md transition-colors",
|
"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
|
isAdvancedSearchOpen || activeFilterCount(searchFilters) > 0
|
||||||
? "bg-primary/10 text-primary"
|
? "bg-primary/10 text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground hover:bg-muted"
|
: "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" />
|
<Filter className="w-4 h-4" />
|
||||||
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
{!isAdvancedSearchOpen && activeFilterCount(searchFilters) > 0 && (
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { SettingsPolicy, FeatureGates } from '@/lib/admin/types';
|
|||||||
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
import { DEFAULT_FEATURE_GATES, DEFAULT_POLICY } from '@/lib/admin/types';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
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 }>> = {
|
const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; description: string }>> = {
|
||||||
sidebarAppsEnabled: { label: 'Sidebar Apps', description: 'Allow custom web apps in navigation rail' },
|
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' },
|
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
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.' },
|
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 account’s folders into one list. Users choose which folders are included. Requires the 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.' },
|
||||||
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: '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.' },
|
||||||
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: '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.' },
|
||||||
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.' },
|
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 = [
|
const RESTRICTABLE_SETTINGS = [
|
||||||
|
|||||||
@@ -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">
|
<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" />
|
<X className="w-5 h-5 md:w-4 md:h-4" />
|
||||||
</Button>
|
</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>
|
<h3 className="font-semibold text-base">{t('new_message')}</h3>
|
||||||
{saveStatus === 'saving' && (
|
{saveStatus === 'saving' && (
|
||||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Trash2}
|
icon={Trash2}
|
||||||
label={t("delete")}
|
label={t("delete")}
|
||||||
|
testId="ctx-delete"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
handleAction(showBatchActions ? onBatchDelete! : onDelete!)
|
||||||
}
|
}
|
||||||
@@ -306,7 +307,7 @@ export function EmailContextMenu({
|
|||||||
|
|
||||||
{/* Move to submenu */}
|
{/* Move to submenu */}
|
||||||
{moveTree.length > 0 && (
|
{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[]) => {
|
const renderNodes = (nodes: MailboxNode[]) => {
|
||||||
return nodes.map((node) => {
|
return nodes.map((node) => {
|
||||||
@@ -319,6 +320,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={nodeLabel}
|
label={nodeLabel}
|
||||||
|
testId={`move-to:${node.id}`}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -410,6 +412,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
icon={isInJunkFolder ? ShieldCheck : ShieldAlert}
|
||||||
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
label={isInJunkFolder ? t("not_spam") : t("mark_as_spam")}
|
||||||
|
testId={isInJunkFolder ? "ctx-not-spam" : "ctx-spam"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(
|
handleAction(
|
||||||
showBatchActions
|
showBatchActions
|
||||||
@@ -429,6 +432,7 @@ export function EmailContextMenu({
|
|||||||
<ContextMenuItem
|
<ContextMenuItem
|
||||||
icon={isUnread ? MailOpen : Mail}
|
icon={isUnread ? MailOpen : Mail}
|
||||||
label={isUnread ? t("mark_read") : t("mark_unread")}
|
label={isUnread ? t("mark_read") : t("mark_unread")}
|
||||||
|
testId={isUnread ? "ctx-mark-read" : "ctx-mark-unread"}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleAction(() =>
|
handleAction(() =>
|
||||||
showBatchActions
|
showBatchActions
|
||||||
|
|||||||
@@ -560,6 +560,8 @@ export function ContactSidebarPanel({
|
|||||||
interface DraggableAttachmentChipProps {
|
interface DraggableAttachmentChipProps {
|
||||||
attachment: EffectiveAttachment;
|
attachment: EffectiveAttachment;
|
||||||
client: IJMAPClient | null;
|
client: IJMAPClient | null;
|
||||||
|
/** Owner accountId for the blob when it lives in a delegated/shared account. */
|
||||||
|
accountId?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
downloadName?: string;
|
downloadName?: string;
|
||||||
children: (dragProps: {
|
children: (dragProps: {
|
||||||
@@ -570,14 +572,14 @@ interface DraggableAttachmentChipProps {
|
|||||||
}) => React.ReactNode;
|
}) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraggableAttachmentChip({ attachment, client, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
function DraggableAttachmentChip({ attachment, client, accountId, enabled, downloadName, children }: DraggableAttachmentChipProps) {
|
||||||
const source = useMemo<AttachmentDragSource>(() => ({
|
const source = useMemo<AttachmentDragSource>(() => ({
|
||||||
name: downloadName || attachment.name || 'download',
|
name: downloadName || attachment.name || 'download',
|
||||||
type: attachment.type || 'application/octet-stream',
|
type: attachment.type || 'application/octet-stream',
|
||||||
getBlobUrl: async () => {
|
getBlobUrl: async () => {
|
||||||
if (attachment.blobId && client) {
|
if (attachment.blobId && client) {
|
||||||
try {
|
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -595,7 +597,7 @@ function DraggableAttachmentChip({ attachment, client, enabled, downloadName, ch
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
}), [attachment, client, downloadName]);
|
}), [attachment, client, accountId, downloadName]);
|
||||||
const drag = useAttachmentDrag(source, enabled);
|
const drag = useAttachmentDrag(source, enabled);
|
||||||
return <>{children(drag)}</>;
|
return <>{children(drag)}</>;
|
||||||
}
|
}
|
||||||
@@ -714,6 +716,18 @@ export function EmailViewer({
|
|||||||
const { tabletListVisible } = useUIStore();
|
const { tabletListVisible } = useUIStore();
|
||||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
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
|
// List-Unsubscribe mailto: send the message ourselves - this is a webmail
|
||||||
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
// client, handing a mailto: URL to the OS mail handler goes nowhere for
|
||||||
@@ -1251,7 +1265,7 @@ export function EmailViewer({
|
|||||||
async function processTnef() {
|
async function processTnef() {
|
||||||
try {
|
try {
|
||||||
debug.time('TNEF fetch blob', 'email');
|
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.timeEnd('TNEF fetch blob', 'email');
|
||||||
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
debug.log('email', 'TNEF: Fetched blob, size:', blobBytes.byteLength, 'bytes');
|
||||||
|
|
||||||
@@ -1304,7 +1318,7 @@ export function EmailViewer({
|
|||||||
processTnef();
|
processTnef();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [email, client]);
|
}, [email, client, blobClient, blobAccountId]);
|
||||||
|
|
||||||
// Embedded message/rfc822 unwrapping
|
// Embedded message/rfc822 unwrapping
|
||||||
// When Outlook forwards an email as an attachment, the outer email body is
|
// When Outlook forwards an email as an attachment, the outer email body is
|
||||||
@@ -1341,7 +1355,7 @@ export function EmailViewer({
|
|||||||
|
|
||||||
async function unwrapEmbedded() {
|
async function unwrapEmbedded() {
|
||||||
try {
|
try {
|
||||||
const blobBytes = await client!.fetchBlobArrayBuffer(rfc822Att!.blobId!);
|
const blobBytes = await blobClient!.fetchBlobArrayBuffer(rfc822Att!.blobId!, undefined, undefined, blobAccountId);
|
||||||
if (cancelled) { debug.groupEnd(); return; }
|
if (cancelled) { debug.groupEnd(); return; }
|
||||||
if (blobBytes.byteLength === 0) {
|
if (blobBytes.byteLength === 0) {
|
||||||
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
debug.warn('email', 'Embedded RFC822: Fetched blob is empty');
|
||||||
@@ -1381,7 +1395,7 @@ export function EmailViewer({
|
|||||||
unwrapEmbedded();
|
unwrapEmbedded();
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [email, client]);
|
}, [email, client, blobClient, blobAccountId]);
|
||||||
|
|
||||||
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
// Fetch inline CID images with authentication to prevent browser auth dialogs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1427,7 +1441,7 @@ export function EmailViewer({
|
|||||||
await Promise.all(cidAttachments.map(async (att) => {
|
await Promise.all(cidAttachments.map(async (att) => {
|
||||||
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
const cidValue = att.cid!.replace(/^<|>$/g, '');
|
||||||
try {
|
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) {
|
if (!cancelled) {
|
||||||
urls[cidValue] = objectUrl;
|
urls[cidValue] = objectUrl;
|
||||||
objectUrls.push(objectUrl);
|
objectUrls.push(objectUrl);
|
||||||
@@ -1449,7 +1463,7 @@ export function EmailViewer({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
objectUrls.forEach(url => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [client, email?.id, pluginRenderedAttachments, email?.attachments]);
|
}, [client, blobClient, blobAccountId, email?.id, pluginRenderedAttachments, email?.attachments]);
|
||||||
|
|
||||||
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
const effectiveAttachments = useMemo<EffectiveAttachment[]>(() => {
|
||||||
if (pluginRenderedAttachments.length > 0) {
|
if (pluginRenderedAttachments.length > 0) {
|
||||||
@@ -1927,8 +1941,8 @@ export function EmailViewer({
|
|||||||
for (const attachment of effectiveAttachments) {
|
for (const attachment of effectiveAttachments) {
|
||||||
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
const entryName = uniqueName(getAttachmentDisplayName(attachment.name, attachment.type));
|
||||||
try {
|
try {
|
||||||
if (attachment.blobId && client) {
|
if (attachment.blobId && blobClient) {
|
||||||
const blob = await client.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type);
|
const blob = await blobClient.fetchBlob(attachment.blobId, attachment.name || entryName, attachment.type, blobAccountId);
|
||||||
zip.file(entryName, blob);
|
zip.file(entryName, blob);
|
||||||
added++;
|
added++;
|
||||||
} else if (attachment.tnefData) {
|
} else if (attachment.tnefData) {
|
||||||
@@ -1960,7 +1974,7 @@ export function EmailViewer({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsDownloadingAll(false);
|
setIsDownloadingAll(false);
|
||||||
}
|
}
|
||||||
}, [isDownloadingAll, effectiveAttachments, client, email]);
|
}, [isDownloadingAll, effectiveAttachments, blobClient, blobAccountId, email]);
|
||||||
|
|
||||||
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
// Shared "Download all" chip, shown only when bundling is worthwhile (2+).
|
||||||
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
const downloadAllButton = effectiveAttachments.length > 1 ? (
|
||||||
@@ -2002,8 +2016,8 @@ export function EmailViewer({
|
|||||||
await Promise.all(imageAttachments.map(async (att) => {
|
await Promise.all(imageAttachments.map(async (att) => {
|
||||||
let url: string | undefined;
|
let url: string | undefined;
|
||||||
try {
|
try {
|
||||||
if (att.blobId && client) {
|
if (att.blobId && blobClient) {
|
||||||
url = await client.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type);
|
url = await blobClient.fetchBlobAsObjectUrl(att.blobId, att.name || 'thumb', att.type, blobAccountId);
|
||||||
} else if (att.decryptedAttachment) {
|
} else if (att.decryptedAttachment) {
|
||||||
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
|
const bytes = getAttachmentContentBytes(att.decryptedAttachment);
|
||||||
if (!bytes || bytes.byteLength === 0) return;
|
if (!bytes || bytes.byteLength === 0) return;
|
||||||
@@ -2034,7 +2048,7 @@ export function EmailViewer({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
createdUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||||
};
|
};
|
||||||
}, [effectiveAttachments, client, attachmentImagePreviewsEnabled]);
|
}, [effectiveAttachments, client, blobClient, blobAccountId, attachmentImagePreviewsEnabled]);
|
||||||
|
|
||||||
// Iframe for rendering HTML emails true-to-life
|
// Iframe for rendering HTML emails true-to-life
|
||||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
@@ -2820,6 +2834,7 @@ export function EmailViewer({
|
|||||||
variant="default"
|
variant="default"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => onEditDraft()}
|
onClick={() => onEditDraft()}
|
||||||
|
data-testid="edit-draft"
|
||||||
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
className="sm:flex sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
|
||||||
title={t('tooltips.edit_draft')}
|
title={t('tooltips.edit_draft')}
|
||||||
>
|
>
|
||||||
@@ -3731,7 +3746,7 @@ export function EmailViewer({
|
|||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -3742,6 +3757,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
data-testid="attachment"
|
||||||
|
data-attachment-name={attachment.name}
|
||||||
draggable={dragProps.draggable}
|
draggable={dragProps.draggable}
|
||||||
onPointerEnter={dragProps.onPointerEnter}
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
onDragStart={dragProps.onDragStart}
|
onDragStart={dragProps.onDragStart}
|
||||||
@@ -3812,7 +3829,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
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 opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -4516,6 +4533,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
data-testid="attachment"
|
||||||
|
data-attachment-name={attachment.name}
|
||||||
draggable={dragProps.draggable}
|
draggable={dragProps.draggable}
|
||||||
onPointerEnter={dragProps.onPointerEnter}
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
onDragStart={dragProps.onDragStart}
|
onDragStart={dragProps.onDragStart}
|
||||||
@@ -4591,7 +4610,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
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 opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
const thumbUrl = imageThumbUrls[attachment.id];
|
const thumbUrl = imageThumbUrls[attachment.id];
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -4660,6 +4679,8 @@ export function EmailViewer({
|
|||||||
)}
|
)}
|
||||||
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
title={`${opensPreview ? tFiles('preview') : t('download')} ${getAttachmentDisplayName(attachment.name, attachment.type)}`}
|
||||||
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
onClick={() => handleEffectiveAttachmentOpen(attachment)}
|
||||||
|
data-testid="attachment"
|
||||||
|
data-attachment-name={attachment.name}
|
||||||
draggable={dragProps.draggable}
|
draggable={dragProps.draggable}
|
||||||
onPointerEnter={dragProps.onPointerEnter}
|
onPointerEnter={dragProps.onPointerEnter}
|
||||||
onDragStart={dragProps.onDragStart}
|
onDragStart={dragProps.onDragStart}
|
||||||
@@ -4729,7 +4750,7 @@ export function EmailViewer({
|
|||||||
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
const isPreviewable = isFilePreviewable(attachment.name || undefined, attachment.type);
|
||||||
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
const opensPreview = isPreviewable && mailAttachmentAction === 'preview';
|
||||||
return (
|
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) => (
|
{(dragProps) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md hover:bg-muted/60 group relative cursor-pointer w-full"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import React, { useCallback } from "react";
|
import React, { useCallback } from "react";
|
||||||
import { formatDate, formatDateTime, stripInvisibleLeading } from "@/lib/utils";
|
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 { cn } from "@/lib/utils";
|
||||||
import { SelectableAvatar } from "@/components/email/selectable-avatar";
|
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";
|
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 showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||||
// Show the originating folder in the aggregate "All …" views.
|
// 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 getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||||
const isChecked = selectedEmailIds.has(email.id);
|
const isChecked = selectedEmailIds.has(email.id);
|
||||||
@@ -463,7 +463,7 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const { selectedMailbox, mailboxes, selectedEmailIds, toggleEmailSelection, selectRangeEmails, clearSelection, isUnifiedView, unifiedRole } = useEmailStore();
|
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 getAccountById = useAccountStore((state) => state.getAccountById);
|
||||||
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
const threadAccountColor = latestEmail.accountId ? getAccountById(latestEmail.accountId)?.avatarColor : undefined;
|
||||||
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
// In Sent/Drafts folders, show recipient instead of sender (which is always
|
||||||
|
|||||||
@@ -79,9 +79,10 @@ interface SidebarProps {
|
|||||||
onRefreshMailboxes?: () => void;
|
onRefreshMailboxes?: () => void;
|
||||||
scheduledTotal?: number;
|
scheduledTotal?: number;
|
||||||
showScheduledMailbox?: boolean;
|
showScheduledMailbox?: boolean;
|
||||||
/** Gated "All Mail" virtual folder that merges all of the account's folders. */
|
/** True when the unified view spans multiple login accounts (cross-account).
|
||||||
showAllMailMailbox?: boolean;
|
* Drives the section header: "All accounts" when true, else "Unified Mailbox". */
|
||||||
/** Gated cross-account views in the "All accounts" section. */
|
crossAccountActive?: boolean;
|
||||||
|
/** Gated All mail / Unread / Starred entries in the "Unified Mailbox" section. */
|
||||||
showCrossUnread?: boolean;
|
showCrossUnread?: boolean;
|
||||||
showCrossStarred?: boolean;
|
showCrossStarred?: boolean;
|
||||||
showCrossAll?: boolean;
|
showCrossAll?: boolean;
|
||||||
@@ -258,6 +259,7 @@ interface SidebarRowProps {
|
|||||||
testRole?: string | null;
|
testRole?: string | null;
|
||||||
testName?: string;
|
testName?: string;
|
||||||
testMailboxId?: string;
|
testMailboxId?: string;
|
||||||
|
testShared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarRow({
|
function SidebarRow({
|
||||||
@@ -281,6 +283,7 @@ function SidebarRow({
|
|||||||
testRole,
|
testRole,
|
||||||
testName,
|
testName,
|
||||||
testMailboxId,
|
testMailboxId,
|
||||||
|
testShared,
|
||||||
}: SidebarRowProps) {
|
}: SidebarRowProps) {
|
||||||
const t = useTranslations('sidebar');
|
const t = useTranslations('sidebar');
|
||||||
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
const leftPad = isCollapsed ? 0 : ROW_PX_BASE + depth * INDENT_STEP;
|
||||||
@@ -293,6 +296,7 @@ function SidebarRow({
|
|||||||
data-folder-role={testRole ?? undefined}
|
data-folder-role={testRole ?? undefined}
|
||||||
data-folder-name={testName ?? undefined}
|
data-folder-name={testName ?? undefined}
|
||||||
data-mailbox-id={testMailboxId ?? undefined}
|
data-mailbox-id={testMailboxId ?? undefined}
|
||||||
|
data-shared={testShared ? 'true' : undefined}
|
||||||
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-colors duration-150",
|
||||||
@@ -372,6 +376,7 @@ function SidebarSectionHeader({
|
|||||||
first,
|
first,
|
||||||
icon,
|
icon,
|
||||||
sub,
|
sub,
|
||||||
|
testId,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
expanded: boolean;
|
expanded: boolean;
|
||||||
@@ -382,6 +387,7 @@ function SidebarSectionHeader({
|
|||||||
first?: boolean;
|
first?: boolean;
|
||||||
icon?: ReactNode;
|
icon?: ReactNode;
|
||||||
sub?: boolean;
|
sub?: boolean;
|
||||||
|
testId?: string;
|
||||||
}) {
|
}) {
|
||||||
if (isCollapsed) {
|
if (isCollapsed) {
|
||||||
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
return first ? null : <div className="h-px bg-border/50 mx-2 my-2" aria-hidden />;
|
||||||
@@ -396,6 +402,9 @@ function SidebarSectionHeader({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
|
data-testid={testId}
|
||||||
|
data-section-name={label}
|
||||||
|
data-expanded={expanded ? 'true' : 'false'}
|
||||||
className={cn(
|
className={cn(
|
||||||
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
"group w-full flex items-center pb-1 select-none rounded-sm hover:bg-muted/40 transition-colors",
|
||||||
paddingX,
|
paddingX,
|
||||||
@@ -496,6 +505,7 @@ function MailboxTreeItem({
|
|||||||
testRole={node.role}
|
testRole={node.role}
|
||||||
testName={node.name}
|
testName={node.name}
|
||||||
testMailboxId={node.id}
|
testMailboxId={node.id}
|
||||||
|
testShared={node.isShared}
|
||||||
depth={node.depth}
|
depth={node.depth}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isVirtual={isVirtualNode}
|
isVirtual={isVirtualNode}
|
||||||
@@ -711,7 +721,7 @@ export function Sidebar({
|
|||||||
onRefreshMailboxes,
|
onRefreshMailboxes,
|
||||||
scheduledTotal = 0,
|
scheduledTotal = 0,
|
||||||
showScheduledMailbox = false,
|
showScheduledMailbox = false,
|
||||||
showAllMailMailbox = false,
|
crossAccountActive = false,
|
||||||
showCrossUnread = false,
|
showCrossUnread = false,
|
||||||
showCrossStarred = false,
|
showCrossStarred = false,
|
||||||
showCrossAll = false,
|
showCrossAll = false,
|
||||||
@@ -1036,20 +1046,10 @@ export function Sidebar({
|
|||||||
|
|
||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
<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) && (
|
{(showUnified || showCrossUnread || showCrossStarred || showCrossAll) && (
|
||||||
<div>
|
<div>
|
||||||
<SidebarSectionHeader
|
<SidebarSectionHeader
|
||||||
label={t("all_accounts")}
|
label={t(crossAccountActive ? "all_accounts" : "unified_mailbox")}
|
||||||
expanded={unifiedExpanded}
|
expanded={unifiedExpanded}
|
||||||
onToggle={toggleUnified}
|
onToggle={toggleUnified}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
@@ -1221,6 +1221,7 @@ export function Sidebar({
|
|||||||
expanded={sharedExpanded}
|
expanded={sharedExpanded}
|
||||||
onToggle={toggleShared}
|
onToggle={toggleShared}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
|
testId="section-shared"
|
||||||
/>
|
/>
|
||||||
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
{((sharedExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
@@ -1235,6 +1236,7 @@ export function Sidebar({
|
|||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
sub
|
sub
|
||||||
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
icon={<User className="w-3.5 h-3.5 text-muted-foreground" />}
|
||||||
|
testId="section-shared-account"
|
||||||
/>
|
/>
|
||||||
{accountExpanded && !isCollapsed && account.children.map((child) => (
|
{accountExpanded && !isCollapsed && account.children.map((child) => (
|
||||||
<MailboxTreeItem
|
<MailboxTreeItem
|
||||||
|
|||||||
@@ -118,19 +118,26 @@ function MailLayoutPreview({
|
|||||||
export function LayoutSettings() {
|
export function LayoutSettings() {
|
||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tEmail = useTranslations('settings.email_behavior');
|
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 { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||||
const accounts = useAccountStore(s => s.accounts);
|
const accounts = useAccountStore(s => s.accounts);
|
||||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||||
const allMailViewAllowed = isFeatureEnabled('allMailViewEnabled');
|
const connectedAccountCount = useMemo(() => accounts.filter(a => a.isConnected).length, [accounts]);
|
||||||
// Cross-account "All accounts" views, each gated independently by the admin.
|
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 = [
|
const crossViews = [
|
||||||
{ setting: 'enableCrossUnreadView', value: enableCrossUnreadView, allowed: isFeatureEnabled('crossUnreadViewEnabled'), labelKey: 'cross_unread.label', descKey: 'cross_unread.description' },
|
{ 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: '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' },
|
{ setting: 'enableCrossAllView', value: enableCrossAllView, allowed: isFeatureEnabled('crossAllViewEnabled'), labelKey: 'cross_all.label', descKey: 'cross_all.description' },
|
||||||
] as const;
|
] 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
|
// Own (non-shared) folders and the active account's All Mail selection. The
|
||||||
// selection is per account: a missing entry = never configured, which
|
// selection is per account: a missing entry = never configured, which
|
||||||
@@ -251,6 +258,21 @@ export function LayoutSettings() {
|
|||||||
</SettingItem>
|
</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') && (
|
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
|
<div className="ms-4 border-s-2 border-border ps-4 -mt-2">
|
||||||
<SettingItem
|
<SettingItem
|
||||||
@@ -266,8 +288,9 @@ export function LayoutSettings() {
|
|||||||
</div>
|
</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">
|
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||||
|
|
||||||
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
{crossViews.map(({ setting, value, allowed, labelKey, descKey }) => (
|
||||||
allowed && !isSettingHidden(setting) && (
|
allowed && !isSettingHidden(setting) && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
@@ -286,21 +309,9 @@ export function LayoutSettings() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{allMailViewAllowed && !isSettingHidden('enableAllMailView') && (
|
{enableUnifiedMailbox && anyCrossAllowed && anyCrossEnabled && (
|
||||||
<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 && (
|
|
||||||
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
<div className="ms-4 border-s-2 border-border ps-4 -mt-2 space-y-2">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-foreground">{t('all_mail.folders_label')}</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>
|
<div className="text-xs text-muted-foreground">{t('all_mail.folders_description')}</div>
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ interface ContextMenuItemProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
destructive?: boolean;
|
destructive?: boolean;
|
||||||
shortcut?: string;
|
shortcut?: string;
|
||||||
|
/** Stable hook for integration tests (not user-visible). */
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextMenuItem({
|
export function ContextMenuItem({
|
||||||
@@ -117,10 +119,12 @@ export function ContextMenuItem({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
destructive = false,
|
destructive = false,
|
||||||
shortcut,
|
shortcut,
|
||||||
|
testId,
|
||||||
}: ContextMenuItemProps) {
|
}: ContextMenuItemProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
|
data-testid={testId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full px-3 py-1.5 text-sm text-start flex items-center gap-2",
|
"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 }>;
|
icon?: React.ComponentType<{ className?: string }>;
|
||||||
label: string;
|
label: string;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
/** Stable hook for integration tests (not user-visible). */
|
||||||
|
testId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContextMenuSubMenu({
|
export function ContextMenuSubMenu({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
children,
|
children,
|
||||||
|
testId,
|
||||||
}: ContextMenuSubMenuProps) {
|
}: ContextMenuSubMenuProps) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
const [subMenuPos, setSubMenuPos] = useState<Position | null>(null);
|
||||||
@@ -232,6 +239,7 @@ export function ContextMenuSubMenu({
|
|||||||
role="menuitem"
|
role="menuitem"
|
||||||
aria-haspopup="true"
|
aria-haspopup="true"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
|
data-testid={testId}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
{Icon && <Icon className="w-4 h-4 flex-shrink-0" />}
|
||||||
<span className="flex-1">{label}</span>
|
<span className="flex-1">{label}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { readFileSync } from "fs";
|
import { readFileSync } from "fs";
|
||||||
import { configManager } from "./lib/admin/config-manager";
|
import { configManager } from "./lib/admin/config-manager";
|
||||||
import { initAdminPassword } from "./lib/admin/password";
|
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 { detectSetupState } from "./lib/setup/state";
|
||||||
import { ensureSetupToken } from "./lib/setup/token";
|
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
|
// Initialize admin config and password bootstrap. Migration runs first so
|
||||||
// existing v1 layouts are split before anything reads admin.json.
|
// existing v1 layouts are split before anything reads admin.json.
|
||||||
migrateLegacyAdminLayout()
|
migrateLegacyAdminLayout()
|
||||||
|
.then(() => migratePolicyUnifiedMailbox())
|
||||||
.then(() => configManager.load())
|
.then(() => configManager.load())
|
||||||
.then(() => initAdminPassword())
|
.then(() => initAdminPassword())
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
|
|||||||
+38
-1
@@ -80,9 +80,46 @@ integration/
|
|||||||
│ └── app.ts # login, add/switch account, folder-counter reads
|
│ └── app.ts # login, add/switch account, folder-counter reads
|
||||||
├── 01-login.spec.ts
|
├── 01-login.spec.ts
|
||||||
├── 02-mail-sync.spec.ts # single-account: receive/read/move/delete/folder-create
|
├── 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
|
## How the tests work
|
||||||
|
|
||||||
- **Mutations** are made out-of-band — mail is injected over SMTP
|
- **Mutations** are made out-of-band — mail is injected over SMTP
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import {
|
|||||||
folderCounts,
|
folderCounts,
|
||||||
expectFolderUnread,
|
expectFolderUnread,
|
||||||
expectFolderTotal,
|
expectFolderTotal,
|
||||||
|
expectFolderCountsSynced,
|
||||||
emailItem,
|
emailItem,
|
||||||
expectEmailVisible,
|
expectEmailVisible,
|
||||||
forceSync,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,11 +82,12 @@ test.describe('Single-account sync', () => {
|
|||||||
await jmap.request([
|
await jmap.request([
|
||||||
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
['Email/set', { accountId: jmap.accountId, update: { [email.id]: { mailboxIds: { [destId]: true } } } }, '0'],
|
||||||
]);
|
]);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// Source Inbox drains, destination gains the message.
|
// Source Inbox drains, destination gains the message. These follow a
|
||||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
// reconcile (not live push), so nudge one before every poll to stay robust
|
||||||
await expectFolderTotal(page, { name: 'Archive2' }, 1);
|
// against a single missed reconcile under load.
|
||||||
|
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0 });
|
||||||
|
await expectFolderCountsSynced(page, { name: 'Archive2' }, { total: 1 });
|
||||||
expect(inbox).toBeTruthy();
|
expect(inbox).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,13 +100,12 @@ test.describe('Single-account sync', () => {
|
|||||||
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
await expectFolderTotal(page, { role: 'inbox' }, 1);
|
||||||
|
|
||||||
await jmap.request([['Email/set', { accountId: jmap.accountId, destroy: [email.id] }, '0']]);
|
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
|
// The folder counter is the sync-critical signal and drains to zero (via a
|
||||||
// already-rendered list view is not re-queried on a background delete, so
|
// reconcile, nudged before every poll). (The already-rendered list view is
|
||||||
// we don't assert on the row disappearing here.)
|
// not re-queried on a background delete, so we don't assert on the row
|
||||||
await expectFolderTotal(page, { role: 'inbox' }, 0);
|
// disappearing here.)
|
||||||
await expectFolderUnread(page, { role: 'inbox' }, 0);
|
await expectFolderCountsSynced(page, { role: 'inbox' }, { unread: 0, total: 0 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
test('counts are consistent between server and UI after a burst of deliveries', async ({ page }) => {
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ import {
|
|||||||
seedUnifiedSettings,
|
seedUnifiedSettings,
|
||||||
folderRow,
|
folderRow,
|
||||||
expectFolderUnread,
|
expectFolderUnread,
|
||||||
expectFolderTotal,
|
expectFolderCountsSynced,
|
||||||
forceSync,
|
|
||||||
} from './helpers/app';
|
} from './helpers/app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,18 +48,17 @@ test.describe('Multi-account sync', () => {
|
|||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 2);
|
||||||
|
|
||||||
await addAccount(page, bob);
|
await addAccount(page, bob);
|
||||||
await forceSync(page);
|
|
||||||
// Both accounts are now registered in the switcher.
|
// Both accounts are now registered in the switcher.
|
||||||
await accountSwitcher(page).click();
|
await accountSwitcher(page).click();
|
||||||
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
await expect(page.locator('[data-testid="account-option"]')).toHaveCount(2);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
||||||
// Active = bob: his own Inbox shows 1 unread — alice's 2 don't leak in.
|
// 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.
|
// Switch back to alice: her count is intact.
|
||||||
await switchAccount(page, alice.email);
|
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 }) => {
|
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 seedUnifiedSettings(page);
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await addAccount(page, bob);
|
await addAccount(page, bob);
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
// Unified Inbox = alice(1) + bob(1) = 2. The active account's own Inbox
|
||||||
// (bob) still reports just its own 1.
|
// (bob) still reports just its own 1.
|
||||||
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
await expect(folderRow(page, { name: 'unified-inbox' }).first()).toBeVisible();
|
||||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 2);
|
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 2, total: 2 });
|
||||||
await expectFolderTotal(page, { name: 'unified-inbox' }, 2);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 1 });
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 1);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// fixme: the unified counter for a *background* (non-active) account is not
|
test('a delivery to a background account bumps the Unified Inbox counter', async ({ page }) => {
|
||||||
// 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 }) => {
|
|
||||||
await seedUnifiedSettings(page);
|
await seedUnifiedSettings(page);
|
||||||
await login(page, alice);
|
await login(page, alice);
|
||||||
await addAccount(page, bob); // bob is now the active account
|
await addAccount(page, bob); // bob is now the active account
|
||||||
await forceSync(page);
|
await expectFolderCountsSynced(page, { name: 'unified-inbox' }, { unread: 0 });
|
||||||
await expectFolderUnread(page, { name: 'unified-inbox' }, 0);
|
|
||||||
|
|
||||||
// Mail lands in alice's inbox while bob is the active account.
|
// Mail lands in alice's inbox while bob is the active account.
|
||||||
await send(alice, subj('bg'));
|
await send(alice, subj('bg'));
|
||||||
await forceSync(page);
|
|
||||||
|
|
||||||
// The unified counter reflects the background account's new mail.
|
// 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.
|
// bob (active) own Inbox is unaffected.
|
||||||
await expectFolderUnread(page, { role: 'inbox', name: 'Inbox' }, 0);
|
await expectFolderCountsSynced(page, { role: 'inbox', name: 'Inbox' }, { unread: 0 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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:/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
* Seed the persisted settings store before the app boots. Merges over the
|
||||||
* persisted settings store. Requires the `unifiedCrossAccountEnabled` admin
|
* store defaults on rehydrate. Must be called before {@link login} so the init
|
||||||
* feature gate (provided by integration/webmail-config/policy.json). Must be
|
* script is registered before the first navigation.
|
||||||
* 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> {
|
export async function seedUnifiedSettings(page: Page): Promise<void> {
|
||||||
await page.addInitScript(() => {
|
await seedSettings(page, {
|
||||||
localStorage.setItem(
|
enableUnifiedMailbox: true,
|
||||||
'settings-storage',
|
unifiedCrossAccount: true,
|
||||||
JSON.stringify({
|
includeGroupInUnified: true,
|
||||||
state: { enableUnifiedMailbox: true, unifiedCrossAccount: true, includeGroupInUnified: true },
|
});
|
||||||
version: 7,
|
}
|
||||||
}),
|
|
||||||
);
|
/**
|
||||||
|
* 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')));
|
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 {
|
export interface FolderSelector {
|
||||||
role?: string;
|
role?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
mailboxId?: string;
|
mailboxId?: string;
|
||||||
|
/** true = only shared-account folders, false = only own folders. */
|
||||||
|
shared?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Locator for a sidebar folder row. */
|
/** 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.role) s += `[data-folder-role="${sel.role}"]`;
|
||||||
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
if (sel.name) s += `[data-folder-name="${sel.name}"]`;
|
||||||
if (sel.mailboxId) s += `[data-mailbox-id="${sel.mailboxId}"]`;
|
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);
|
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 {
|
export interface FolderCounts {
|
||||||
unread: number;
|
unread: number;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -157,6 +257,31 @@ export async function expectFolderUnread(page: Page, sel: FolderSelector, expect
|
|||||||
.toBe(expected);
|
.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`. */
|
/** Poll until a folder's total count reaches `expected`. */
|
||||||
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
export async function expectFolderTotal(page: Page, sel: FolderSelector, expected: number, timeout = 30000): Promise<void> {
|
||||||
await expect
|
await expect
|
||||||
@@ -164,36 +289,39 @@ export async function expectFolderTotal(page: Page, sel: FolderSelector, expecte
|
|||||||
.toBe(expected);
|
.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. */
|
/** Click a folder row to select it. */
|
||||||
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
export async function openFolder(page: Page, sel: FolderSelector): Promise<void> {
|
||||||
await folderRow(page, sel).first().click();
|
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. */
|
/** Locator for an email row by (exact) subject. */
|
||||||
export function emailItem(page: Page, subject: string): Locator {
|
export function emailItem(page: Page, subject: string): Locator {
|
||||||
return page.locator(`[data-testid="email-list-item"][data-subject="${subject}"]`);
|
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> {
|
export async function expectEmailVisible(page: Page, subject: string, timeout = 20000): Promise<void> {
|
||||||
await expect(emailItem(page, subject).first()).toBeVisible({ timeout });
|
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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,9 +12,22 @@ import { JMAP_URL } from './config';
|
|||||||
|
|
||||||
const CORE = 'urn:ietf:params:jmap:core';
|
const CORE = 'urn:ietf:params:jmap:core';
|
||||||
const MAIL = 'urn:ietf:params:jmap:mail';
|
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';
|
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 {
|
interface JmapMailbox {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -65,16 +78,91 @@ export class JmapClient {
|
|||||||
.map(([, name]) => name);
|
.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, {
|
const res = await fetch(this.apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: this.authHeader, 'Content-Type': 'application/json' },
|
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()}`);
|
if (!res.ok) throw new Error(`JMAP request failed: ${res.status} ${await res.text()}`);
|
||||||
return res.json();
|
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[]> {
|
async mailboxes(): Promise<JmapMailbox[]> {
|
||||||
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
const r = await this.request([['Mailbox/get', { accountId: this.accountId }, '0']]);
|
||||||
return r.methodResponses[0][1].list as JmapMailbox[];
|
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. */
|
/** Look up an email id by subject within an optional mailbox. */
|
||||||
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
async findEmailBySubject(subject: string, mailboxId?: string): Promise<any | undefined> {
|
||||||
const filter: Record<string, unknown> = { subject };
|
const filter: Record<string, unknown> = { subject };
|
||||||
@@ -139,7 +273,7 @@ export class JmapClient {
|
|||||||
['Email/get', {
|
['Email/get', {
|
||||||
accountId: this.accountId,
|
accountId: this.accountId,
|
||||||
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
'#ids': { resultOf: '0', name: 'Email/query', path: '/ids' },
|
||||||
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'preview'],
|
properties: ['id', 'subject', 'keywords', 'mailboxIds', 'from', 'to', 'preview'],
|
||||||
}, '1'],
|
}, '1'],
|
||||||
]);
|
]);
|
||||||
return r.methodResponses[1][1].list[0];
|
return r.methodResponses[1][1].list[0];
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ interface SendOptions {
|
|||||||
body: string;
|
body: string;
|
||||||
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
|
/** Extra headers (e.g. custom Message-ID / In-Reply-To for threading). */
|
||||||
headers?: Record<string, string>;
|
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 {}
|
class SmtpError extends Error {}
|
||||||
@@ -110,14 +117,57 @@ export async function sendMail(opts: SendOptions): Promise<void> {
|
|||||||
From: opts.from,
|
From: opts.from,
|
||||||
To: recipients.join(', '),
|
To: recipients.join(', '),
|
||||||
Subject: opts.subject,
|
Subject: opts.subject,
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
|
||||||
...opts.headers,
|
...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)
|
const headerBlock = Object.entries(headers)
|
||||||
.map(([k, v]) => `${k}: ${v}`)
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
.join('\r\n');
|
.join('\r\n');
|
||||||
// Dot-stuff any line that begins with '.'
|
// 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.`);
|
send(`${headerBlock}\r\n\r\n${safeBody}\r\n.`);
|
||||||
await waitReply('250');
|
await waitReply('250');
|
||||||
send('QUIT');
|
send('QUIT');
|
||||||
|
|||||||
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
|
|||||||
).rejects.toThrow('Failed to fetch blob: 404');
|
).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,
|
buildCrossFilter,
|
||||||
getCrossUnreadTotal,
|
getCrossUnreadTotal,
|
||||||
fetchCrossViewEmails,
|
fetchCrossViewEmails,
|
||||||
|
advancedSearchCrossViewEmails,
|
||||||
resolveSourceFolderName,
|
resolveSourceFolderName,
|
||||||
type UnifiedAccountClient,
|
type UnifiedAccountClient,
|
||||||
} from '@/lib/unified-mailbox';
|
} from '@/lib/unified-mailbox';
|
||||||
@@ -42,6 +43,26 @@ describe('getCrossIncludedMailboxes', () => {
|
|||||||
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
|
||||||
expect(ids).toEqual(['inbox', 'projects']);
|
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', () => {
|
describe('buildCrossFilter', () => {
|
||||||
@@ -86,6 +107,22 @@ describe('getCrossUnreadTotal', () => {
|
|||||||
});
|
});
|
||||||
expect(getCrossUnreadTotal([a, b])).toBe(10);
|
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', () => {
|
describe('resolveSourceFolderName', () => {
|
||||||
@@ -167,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
|
|||||||
expect(result.errors.get('bad')).toBe('boom');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -33,12 +33,12 @@ class ConfigManager {
|
|||||||
this.adminConfig = await this.readJsonFile('config.json') || {};
|
this.adminConfig = await this.readJsonFile('config.json') || {};
|
||||||
const policy = await this.readJsonFile('policy.json');
|
const policy = await this.readJsonFile('policy.json');
|
||||||
if (policy) {
|
if (policy) {
|
||||||
this.policyCache = {
|
this.policyCache = ConfigManager.normalizePolicy({
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
};
|
});
|
||||||
} else {
|
} else {
|
||||||
this.policyCache = { ...DEFAULT_POLICY };
|
this.policyCache = { ...DEFAULT_POLICY };
|
||||||
}
|
}
|
||||||
@@ -166,15 +166,28 @@ class ConfigManager {
|
|||||||
*/
|
*/
|
||||||
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
async setPolicy(policy: SettingsPolicy): Promise<void> {
|
||||||
assertWritable('update settings policy');
|
assertWritable('update settings policy');
|
||||||
this.policyCache = {
|
this.policyCache = ConfigManager.normalizePolicy({
|
||||||
...DEFAULT_POLICY,
|
...DEFAULT_POLICY,
|
||||||
...policy,
|
...policy,
|
||||||
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
|
||||||
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
|
||||||
};
|
});
|
||||||
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
|
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).
|
* Reload config from disk (for manual file edits or multi-instance).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import type { AdminConfigData, AdminStateData } from './types';
|
import type { AdminConfigData, AdminStateData } from './types';
|
||||||
|
|
||||||
const MIGRATION_MARKER = '.migrated-v2';
|
const MIGRATION_MARKER = '.migrated-v2';
|
||||||
|
const POLICY_UNIFIED_MARKER = '.migrated-unified-mailbox';
|
||||||
|
|
||||||
interface LegacyAdminData {
|
interface LegacyAdminData {
|
||||||
passwordHash: string;
|
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),
|
* If the existing admin.json carries timestamp fields (legacy mixed layout),
|
||||||
* split them into admin-state.json and rewrite admin.json without them.
|
* split them into admin-state.json and rewrite admin.json without them.
|
||||||
|
|||||||
@@ -60,10 +60,12 @@ export interface FeatureGates {
|
|||||||
hoverActionsConfigEnabled: boolean;
|
hoverActionsConfigEnabled: boolean;
|
||||||
filesEnabled: boolean;
|
filesEnabled: boolean;
|
||||||
contactsEnabled: boolean;
|
contactsEnabled: boolean;
|
||||||
|
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
|
||||||
allMailViewEnabled: boolean;
|
allMailViewEnabled: boolean;
|
||||||
crossUnreadViewEnabled: boolean;
|
crossUnreadViewEnabled: boolean;
|
||||||
crossStarredViewEnabled: boolean;
|
crossStarredViewEnabled: boolean;
|
||||||
crossAllViewEnabled: boolean;
|
crossAllViewEnabled: boolean;
|
||||||
|
unifiedCrossAccountEnabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
||||||
@@ -89,6 +91,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
|
|||||||
crossUnreadViewEnabled: false,
|
crossUnreadViewEnabled: false,
|
||||||
crossStarredViewEnabled: false,
|
crossStarredViewEnabled: false,
|
||||||
crossAllViewEnabled: false,
|
crossAllViewEnabled: false,
|
||||||
|
unifiedCrossAccountEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemePolicy {
|
export interface ThemePolicy {
|
||||||
|
|||||||
@@ -224,9 +224,9 @@ export interface IJMAPClient {
|
|||||||
): Promise<{ blobId: string; size: number; type: string }>;
|
): Promise<{ blobId: string; size: number; type: string }>;
|
||||||
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
|
||||||
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
|
||||||
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
|
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
|
||||||
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
|
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
|
||||||
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
|
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
|
||||||
|
|
||||||
// ── Identities ────────────────────────────────────────────────
|
// ── Identities ────────────────────────────────────────────────
|
||||||
getIdentities(): Promise<Identity[]>;
|
getIdentities(): Promise<Identity[]>;
|
||||||
|
|||||||
+100
-29
@@ -1968,6 +1968,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
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 };
|
return { emails, hasMore, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@@ -2008,6 +2015,13 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const total = queryResponse?.total || 0;
|
const total = queryResponse?.total || 0;
|
||||||
const hasMore = computeHasMore(position, emails.length, total, limit);
|
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 };
|
return { emails, hasMore, total };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Advanced search failed:', error);
|
console.error('Advanced search failed:', error);
|
||||||
@@ -3448,8 +3462,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return response.blob();
|
return response.blob();
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
|
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
|
||||||
const blob = await this.fetchBlob(blobId, name, type);
|
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||||||
return URL.createObjectURL(blob);
|
return URL.createObjectURL(blob);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5728,8 +5742,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return created as FileNode;
|
return created as FileNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
|
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
|
||||||
const blob = await this.fetchBlob(blobId, name, type);
|
const blob = await this.fetchBlob(blobId, name, type, accountId);
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -5742,6 +5756,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private pollingInterval: NodeJS.Timeout | null = null;
|
private pollingInterval: NodeJS.Timeout | null = null;
|
||||||
|
private secondaryPollInterval: NodeJS.Timeout | null = null;
|
||||||
private pollingStates: { [key: string]: string } = {};
|
private pollingStates: { [key: string]: string } = {};
|
||||||
private sseAbortController: AbortController | null = null;
|
private sseAbortController: AbortController | null = null;
|
||||||
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
private sseReconnectTimeout: NodeJS.Timeout | null = null;
|
||||||
@@ -5759,6 +5774,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static readonly POLLING_INTERVAL = 3_000;
|
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_RECONNECT_DELAY = 3_000;
|
||||||
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
|
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();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
if (eventSourceUrl) {
|
if (eventSourceUrl) {
|
||||||
this.connectSSE(eventSourceUrl);
|
this.connectSSE(eventSourceUrl);
|
||||||
|
// SSE covers the primary account only; keep shared accounts fresh too.
|
||||||
|
this.startSecondaryAccountPoll();
|
||||||
} else {
|
} else {
|
||||||
|
// The fallback poll already covers every session account.
|
||||||
this.startPollingFallback();
|
this.startPollingFallback();
|
||||||
}
|
}
|
||||||
this.setupBrowserEventListeners();
|
this.setupBrowserEventListeners();
|
||||||
return true;
|
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 {
|
private connectSSE(templateUrl: string): void {
|
||||||
if (this.isRateLimited()) {
|
if (this.isRateLimited()) {
|
||||||
this.scheduleSSEReconnect();
|
this.scheduleSSEReconnect();
|
||||||
@@ -5899,12 +5939,30 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
}, JMAPClient.POLLING_INTERVAL);
|
}, 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[] } {
|
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
|
||||||
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
|
||||||
const methodCalls: JMAPMethodCall[] = [
|
const methodCalls: JMAPMethodCall[] = [];
|
||||||
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
|
for (const acctId of this.pollAccountIds()) {
|
||||||
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
|
methodCalls.push(
|
||||||
];
|
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
|
||||||
|
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (this.supportsCalendars()) {
|
if (this.supportsCalendars()) {
|
||||||
using.push('urn:ietf:params:jmap:calendars');
|
using.push('urn:ietf:params:jmap:calendars');
|
||||||
@@ -5925,6 +5983,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
return { using, methodCalls };
|
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> {
|
private async fetchCurrentStates(): Promise<void> {
|
||||||
if (this.isRateLimited()) {
|
if (this.isRateLimited()) {
|
||||||
return;
|
return;
|
||||||
@@ -5939,10 +6007,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
for (const [method, result] of data.methodResponses) {
|
for (const [method, result, callId] of data.methodResponses) {
|
||||||
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
const resolved = this.resolvePolledState(method, callId);
|
||||||
if (stateKey && result.state) {
|
if (resolved && result?.state) {
|
||||||
this.pollingStates[stateKey] = result.state;
|
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5965,25 +6033,24 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const changes: { [key: string]: string } = {};
|
// Build a per-account changed map so a background change in a shared
|
||||||
let hasChanges = false;
|
// (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) {
|
for (const [method, result, callId] of data.methodResponses) {
|
||||||
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
|
const resolved = this.resolvePolledState(method, callId);
|
||||||
if (stateKey && result.state) {
|
if (!resolved || !result?.state) continue;
|
||||||
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
|
const key = `${resolved.accountId}:${resolved.stateKey}`;
|
||||||
changes[stateKey] = result.state;
|
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
|
||||||
hasChanges = true;
|
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
|
||||||
}
|
|
||||||
this.pollingStates[stateKey] = result.state;
|
|
||||||
}
|
}
|
||||||
|
this.pollingStates[key] = result.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasChanges && this.stateChangeCallback) {
|
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
|
||||||
this.stateChangeCallback({
|
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
|
||||||
'@type': 'StateChange',
|
|
||||||
changed: { [this.accountId]: changes },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -5996,6 +6063,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
clearInterval(this.pollingInterval);
|
clearInterval(this.pollingInterval);
|
||||||
this.pollingInterval = null;
|
this.pollingInterval = null;
|
||||||
}
|
}
|
||||||
|
if (this.secondaryPollInterval) {
|
||||||
|
clearInterval(this.secondaryPollInterval);
|
||||||
|
this.secondaryPollInterval = null;
|
||||||
|
}
|
||||||
if (this.sseAbortController) {
|
if (this.sseAbortController) {
|
||||||
this.sseAbortController.abort();
|
this.sseAbortController.abort();
|
||||||
this.sseAbortController = null;
|
this.sseAbortController = null;
|
||||||
@@ -6162,8 +6233,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
// ── S/MIME raw-email helpers ─────────────────────────────────────
|
// ── S/MIME raw-email helpers ─────────────────────────────────────
|
||||||
|
|
||||||
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
|
||||||
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
|
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer> {
|
||||||
const url = this.getBlobDownloadUrl(blobId, name, type);
|
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
|
||||||
const response = await this.authenticatedFetch(url, {});
|
const response = await this.authenticatedFetch(url, {});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||||
|
|||||||
+6
-13
@@ -892,19 +892,12 @@ export function isUnifiedMailboxId(id: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Virtual mailbox id for the gated "All Mail" view: every folder of a single
|
* Cross views shown in the unified ("Unified Mailbox") section: All mail /
|
||||||
* account merged into one date-sorted list. Distinct from the unified mailbox
|
* Unread / Starred. Each merges messages across the account boundary (the active
|
||||||
* ids above, which merge one role across multiple accounts. Which folders are
|
* account + its shared folders by default, or every logged-in account when the
|
||||||
* included is a per-user setting (see `allMailFolderIds`).
|
* 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
|
||||||
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
|
* accounts).
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-account "All …" views shown in the unified ("All accounts") section.
|
|
||||||
* Each merges messages across EVERY account (including shared/group folders),
|
|
||||||
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
|
|
||||||
* one date-sorted list. Distinct from the per-role unified ids (one role across
|
|
||||||
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
|
|
||||||
*/
|
*/
|
||||||
export const CROSS_UNREAD = '__cross_unread__';
|
export const CROSS_UNREAD = '__cross_unread__';
|
||||||
export const CROSS_STARRED = '__cross_starred__';
|
export const CROSS_STARRED = '__cross_starred__';
|
||||||
|
|||||||
+51
-3
@@ -22,6 +22,18 @@ export interface UnifiedAccountClient {
|
|||||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||||
// so the server routes to the owner's data.
|
// so the server routes to the owner's data.
|
||||||
isShared?: boolean;
|
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 {
|
export interface UnifiedFetchResult {
|
||||||
@@ -49,7 +61,12 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
|
|||||||
*/
|
*/
|
||||||
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
|
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
|
||||||
for (const m of mailboxes) {
|
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;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -313,10 +330,18 @@ export function fetchUnifiedMailboxCounts(
|
|||||||
// filter is built from each account's included-mailbox ids.
|
// filter is built from each account's included-mailbox ids.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mailboxes of an account included in the cross-account views: everything whose
|
* Mailboxes of an account included in the cross views (All mail / Unread /
|
||||||
* role is not excluded (inbox + custom/no-role folders).
|
* 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[] {
|
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 ?? ''));
|
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
|
* Returns the list of unified roles that exist in at least one account's
|
||||||
* mailboxes.
|
* mailboxes.
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Všechny archivy",
|
"unified_archive": "Všechny archivy",
|
||||||
"unified_junk": "Všechen spam",
|
"unified_junk": "Všechen spam",
|
||||||
"all_accounts": "Všechny účty",
|
"all_accounts": "Všechny účty",
|
||||||
|
"unified_mailbox": "Sjednocená schránka",
|
||||||
"expand": "Rozbalit",
|
"expand": "Rozbalit",
|
||||||
"collapse": "Sbalit",
|
"collapse": "Sbalit",
|
||||||
"expand_tooltip": "Rozbalit",
|
"expand_tooltip": "Rozbalit",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Sjednocená schránka",
|
"label": "Sjednocená schránka",
|
||||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
|
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Zahrnout skupinové schránky",
|
"label": "Zahrnout skupinové schránky",
|
||||||
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
|
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Alle arkiver",
|
"unified_archive": "Alle arkiver",
|
||||||
"unified_junk": "Alt spam",
|
"unified_junk": "Alt spam",
|
||||||
"all_accounts": "Alle konti",
|
"all_accounts": "Alle konti",
|
||||||
|
"unified_mailbox": "Samlet postkasse",
|
||||||
"expand": "Udvid",
|
"expand": "Udvid",
|
||||||
"collapse": "Skjul",
|
"collapse": "Skjul",
|
||||||
"expand_tooltip": "Udvid",
|
"expand_tooltip": "Udvid",
|
||||||
@@ -927,14 +928,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Samlet postkasse",
|
"label": "Samlet postkasse",
|
||||||
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
|
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Inkluder gruppepostkasser",
|
"label": "Inkluder gruppepostkasser",
|
||||||
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
|
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Alle Archive",
|
"unified_archive": "Alle Archive",
|
||||||
"unified_junk": "Alle Spam",
|
"unified_junk": "Alle Spam",
|
||||||
"all_accounts": "Alle Konten",
|
"all_accounts": "Alle Konten",
|
||||||
|
"unified_mailbox": "Gemeinsames Postfach",
|
||||||
"expand": "Erweitern",
|
"expand": "Erweitern",
|
||||||
"collapse": "Einklappen",
|
"collapse": "Einklappen",
|
||||||
"expand_tooltip": "Erweitern",
|
"expand_tooltip": "Erweitern",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Gemeinsames Postfach",
|
"label": "Gemeinsames Postfach",
|
||||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
|
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Über alle Konten",
|
||||||
|
"description": "Den vereinheitlichten Posteingang über alle verbundenen Konten zusammenführen, statt beim aktiven Konto zu bleiben."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Gruppenpostfächer einbeziehen",
|
"label": "Gruppenpostfächer einbeziehen",
|
||||||
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
|
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Gilt für {account}.",
|
"account_hint": "Gilt für {account}.",
|
||||||
|
|||||||
+14
-11
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "All Archive",
|
"unified_archive": "All Archive",
|
||||||
"unified_junk": "All Junk",
|
"unified_junk": "All Junk",
|
||||||
"all_accounts": "All Accounts",
|
"all_accounts": "All Accounts",
|
||||||
|
"unified_mailbox": "Unified Mailbox",
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"expand_tooltip": "Expand",
|
"expand_tooltip": "Expand",
|
||||||
@@ -926,17 +927,19 @@
|
|||||||
},
|
},
|
||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "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": {
|
"include_group": {
|
||||||
"label": "Include group inboxes",
|
"label": "Include group inboxes",
|
||||||
"description": "Also merge shared/group inboxes into the unified view."
|
"description": "Also merge shared/group inboxes into the unified view."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"all_mail": {
|
||||||
"label": "All Mail",
|
"folders_label": "Folders in the unified lists",
|
||||||
"description": "Show an \"All Mail\" entry above your folders that merges messages from across this account's folders into one list.",
|
"folders_description": "Choose which of this account's folders are merged into the All mail / Unread / Starred lists.",
|
||||||
"folders_label": "Folders in All Mail",
|
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
"no_folders": "No folders available."
|
"no_folders": "No folders available."
|
||||||
},
|
},
|
||||||
@@ -963,16 +966,16 @@
|
|||||||
"back_to_standard": "Back to standard"
|
"back_to_standard": "Back to standard"
|
||||||
},
|
},
|
||||||
"cross_unread": {
|
"cross_unread": {
|
||||||
"label": "All accounts: Unread",
|
"label": "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."
|
"description": "Show an Unread entry in the Unified Mailbox listing unread mail across the selected folders."
|
||||||
},
|
},
|
||||||
"cross_starred": {
|
"cross_starred": {
|
||||||
"label": "All accounts: Starred",
|
"label": "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."
|
"description": "Show a Starred entry in the Unified Mailbox listing flagged/starred mail across the selected folders."
|
||||||
},
|
},
|
||||||
"cross_all": {
|
"cross_all": {
|
||||||
"label": "All accounts: All mail",
|
"label": "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."
|
"description": "Show an All mail entry in the Unified Mailbox listing all mail across the selected folders."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"keywords": {
|
"keywords": {
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Todos los archivos",
|
"unified_archive": "Todos los archivos",
|
||||||
"unified_junk": "Todo el spam",
|
"unified_junk": "Todo el spam",
|
||||||
"all_accounts": "Todas las cuentas",
|
"all_accounts": "Todas las cuentas",
|
||||||
|
"unified_mailbox": "Buzón unificado",
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"collapse": "Contraer",
|
"collapse": "Contraer",
|
||||||
"expand_tooltip": "Expandir",
|
"expand_tooltip": "Expandir",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Buzón unificado",
|
"label": "Buzón unificado",
|
||||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
|
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Incluir buzones de grupo",
|
"label": "Incluir buzones de grupo",
|
||||||
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
|
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "همه بایگانیها",
|
"unified_archive": "همه بایگانیها",
|
||||||
"unified_junk": "همه هرزنامهها",
|
"unified_junk": "همه هرزنامهها",
|
||||||
"all_accounts": "همه حسابها",
|
"all_accounts": "همه حسابها",
|
||||||
|
"unified_mailbox": "صندوق پستی یکپارچه",
|
||||||
"expand": "باز کردن",
|
"expand": "باز کردن",
|
||||||
"collapse": "جمع کردن",
|
"collapse": "جمع کردن",
|
||||||
"expand_tooltip": "باز کردن",
|
"expand_tooltip": "باز کردن",
|
||||||
@@ -927,14 +928,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "صندوق پستی یکپارچه",
|
"label": "صندوق پستی یکپارچه",
|
||||||
"description": "نمایش پوشههای ترکیبی در همه حسابهای متصل",
|
"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": {
|
"include_group": {
|
||||||
"label": "شامل صندوق ورودی گروهها",
|
"label": "شامل صندوق ورودی گروهها",
|
||||||
"description": "صندوق ورودی گروههای اشتراکی را هم ادغام کن"
|
"description": "صندوق ورودی گروههای اشتراکی را هم ادغام کن"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"all_mail": {
|
||||||
"label": "همه ایمیلها",
|
|
||||||
"description": "نمایش گزینه \"همه ایمیلها\" بالای پوشهها",
|
|
||||||
"folders_label": "پوشهها در همه ایمیلها",
|
"folders_label": "پوشهها در همه ایمیلها",
|
||||||
"folders_description": "انتخاب کنید کدام پوشهها ادغام شوند",
|
"folders_description": "انتخاب کنید کدام پوشهها ادغام شوند",
|
||||||
"no_folders": "پوشهای موجود نیست",
|
"no_folders": "پوشهای موجود نیست",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Toutes les archives",
|
"unified_archive": "Toutes les archives",
|
||||||
"unified_junk": "Tous les indésirables",
|
"unified_junk": "Tous les indésirables",
|
||||||
"all_accounts": "Tous les comptes",
|
"all_accounts": "Tous les comptes",
|
||||||
|
"unified_mailbox": "Boîte aux lettres unifiée",
|
||||||
"expand": "Développer",
|
"expand": "Développer",
|
||||||
"collapse": "Réduire",
|
"collapse": "Réduire",
|
||||||
"expand_tooltip": "Développer",
|
"expand_tooltip": "Développer",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Boîte aux lettres unifiée",
|
"label": "Boîte aux lettres unifiée",
|
||||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
|
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Inclure les boîtes de groupe",
|
"label": "Inclure les boîtes de groupe",
|
||||||
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
|
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"jmap_server_auto_picked": "שרת שנבחר מדומיין הדוא״ל שלך."
|
"jmap_server_auto_picked": "שרת שנבחר מדומיין הדוא״ל שלך."
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"unified_mailbox": "תיבת דואר מאוחדת",
|
||||||
"close": "סגור",
|
"close": "סגור",
|
||||||
"compose": "כתיבה",
|
"compose": "כתיבה",
|
||||||
"compose_hint": "כתיבה (c)",
|
"compose_hint": "כתיבה (c)",
|
||||||
@@ -889,14 +890,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "תיבת דואר מאוחדת",
|
"label": "תיבת דואר מאוחדת",
|
||||||
"description": "הצג תיקיות משולבות (דואר נכנס, נשלח וכו׳) בכל החשבונות המחוברים",
|
"description": "הצג תיקיות משולבות (דואר נכנס, נשלח וכו׳) בכל החשבונות המחוברים",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "בכל החשבונות",
|
||||||
|
"description": "מזג את תיבת הדואר המאוחדת בכל החשבונות המחוברים במקום להישאר בחשבון הפעיל."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "כלול תיבות דואר קבוצתיות",
|
"label": "כלול תיבות דואר קבוצתיות",
|
||||||
"description": "גם מזג תיבות דואר משותפות/קבוצתיות לתצוגה המאוחדת."
|
"description": "גם מזג תיבות דואר משותפות/קבוצתיות לתצוגה המאוחדת."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"all_mail": {
|
||||||
"label": "כל הדוא״ל",
|
|
||||||
"description": "הצג ערך ״כל הדוא״ל״ מעל התיקיות שלך המחברות הודעות מכל תיקיות החשבון לרשימה אחת.",
|
|
||||||
"folders_label": "תיקיות בכל הדוא״ל",
|
"folders_label": "תיקיות בכל הדוא״ל",
|
||||||
"folders_description": "בחר אילו תיקיות מוזגות לתצוגת כל הדוא״ל.",
|
"folders_description": "בחר אילו תיקיות מוזגות לתצוגת כל הדוא״ל.",
|
||||||
"account_hint": "חל על {account}.",
|
"account_hint": "חל על {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Összes archívum",
|
"unified_archive": "Összes archívum",
|
||||||
"unified_junk": "Összes spam",
|
"unified_junk": "Összes spam",
|
||||||
"all_accounts": "Minden fiók",
|
"all_accounts": "Minden fiók",
|
||||||
|
"unified_mailbox": "Egységes postafiók",
|
||||||
"expand": "Kibontás",
|
"expand": "Kibontás",
|
||||||
"collapse": "Összecsukás",
|
"collapse": "Összecsukás",
|
||||||
"expand_tooltip": "Kibontás",
|
"expand_tooltip": "Kibontás",
|
||||||
@@ -927,14 +928,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Egységes postafiók",
|
"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",
|
"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": {
|
"include_group": {
|
||||||
"label": "Csoportos postafiókok belefoglalása",
|
"label": "Csoportos postafiókok belefoglalása",
|
||||||
"description": "Megosztott/csoportos postafiókok egyesítése az egységes nézetbe."
|
"description": "Megosztott/csoportos postafiókok egyesítése az egységes nézetbe."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Tutti gli archivi",
|
"unified_archive": "Tutti gli archivi",
|
||||||
"unified_junk": "Tutto lo spam",
|
"unified_junk": "Tutto lo spam",
|
||||||
"all_accounts": "Tutti gli account",
|
"all_accounts": "Tutti gli account",
|
||||||
|
"unified_mailbox": "Casella di posta unificata",
|
||||||
"expand": "Espandi",
|
"expand": "Espandi",
|
||||||
"collapse": "Comprimi",
|
"collapse": "Comprimi",
|
||||||
"expand_tooltip": "Espandi",
|
"expand_tooltip": "Espandi",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Casella di posta unificata",
|
"label": "Casella di posta unificata",
|
||||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
|
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Includi le caselle di gruppo",
|
"label": "Includi le caselle di gruppo",
|
||||||
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
|
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "すべてのアーカイブ",
|
"unified_archive": "すべてのアーカイブ",
|
||||||
"unified_junk": "すべての迷惑メール",
|
"unified_junk": "すべての迷惑メール",
|
||||||
"all_accounts": "すべてのアカウント",
|
"all_accounts": "すべてのアカウント",
|
||||||
|
"unified_mailbox": "統合メールボックス",
|
||||||
"expand": "展開",
|
"expand": "展開",
|
||||||
"collapse": "折りたたむ",
|
"collapse": "折りたたむ",
|
||||||
"expand_tooltip": "展開",
|
"expand_tooltip": "展開",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "統合メールボックス",
|
"label": "統合メールボックス",
|
||||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
|
"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": {
|
"include_group": {
|
||||||
"label": "グループ受信トレイを含める",
|
"label": "グループ受信トレイを含める",
|
||||||
"description": "共有/グループ受信トレイも統合ビューに含めます。"
|
"description": "共有/グループ受信トレイも統合ビューに含めます。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "모든 보관함",
|
"unified_archive": "모든 보관함",
|
||||||
"unified_junk": "모든 스팸함",
|
"unified_junk": "모든 스팸함",
|
||||||
"all_accounts": "모든 계정",
|
"all_accounts": "모든 계정",
|
||||||
|
"unified_mailbox": "통합 메일함",
|
||||||
"expand": "펼치기",
|
"expand": "펼치기",
|
||||||
"collapse": "접기",
|
"collapse": "접기",
|
||||||
"expand_tooltip": "펼치기",
|
"expand_tooltip": "펼치기",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "통합 메일함",
|
"label": "통합 메일함",
|
||||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
|
"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": {
|
"include_group": {
|
||||||
"label": "그룹 받은편지함 포함",
|
"label": "그룹 받은편지함 포함",
|
||||||
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
|
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Visi arhīvi",
|
"unified_archive": "Visi arhīvi",
|
||||||
"unified_junk": "Viss mēstules",
|
"unified_junk": "Viss mēstules",
|
||||||
"all_accounts": "Visi konti",
|
"all_accounts": "Visi konti",
|
||||||
|
"unified_mailbox": "Apvienotā pastkaste",
|
||||||
"expand": "Izvērst",
|
"expand": "Izvērst",
|
||||||
"collapse": "Sairt",
|
"collapse": "Sairt",
|
||||||
"expand_tooltip": "Izvērst",
|
"expand_tooltip": "Izvērst",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Apvienotā pastkaste",
|
"label": "Apvienotā pastkaste",
|
||||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
|
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Iekļaut grupas pastkastes",
|
"label": "Iekļaut grupas pastkastes",
|
||||||
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
|
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Alle archieven",
|
"unified_archive": "Alle archieven",
|
||||||
"unified_junk": "Alle spam",
|
"unified_junk": "Alle spam",
|
||||||
"all_accounts": "Alle accounts",
|
"all_accounts": "Alle accounts",
|
||||||
|
"unified_mailbox": "Gecombineerd postvak",
|
||||||
"expand": "Uitklappen",
|
"expand": "Uitklappen",
|
||||||
"collapse": "Inklappen",
|
"collapse": "Inklappen",
|
||||||
"expand_tooltip": "Uitklappen",
|
"expand_tooltip": "Uitklappen",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Gecombineerd postvak",
|
"label": "Gecombineerd postvak",
|
||||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
|
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Groepspostvakken meenemen",
|
"label": "Groepspostvakken meenemen",
|
||||||
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
|
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Wszystkie archiwa",
|
"unified_archive": "Wszystkie archiwa",
|
||||||
"unified_junk": "Wszystkie spam",
|
"unified_junk": "Wszystkie spam",
|
||||||
"all_accounts": "Wszystkie konta",
|
"all_accounts": "Wszystkie konta",
|
||||||
|
"unified_mailbox": "Wspólna skrzynka",
|
||||||
"expand": "Rozwiń",
|
"expand": "Rozwiń",
|
||||||
"collapse": "Zwiń",
|
"collapse": "Zwiń",
|
||||||
"expand_tooltip": "Rozwiń",
|
"expand_tooltip": "Rozwiń",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Wspólna skrzynka",
|
"label": "Wspólna skrzynka",
|
||||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
|
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Uwzględnij skrzynki grupowe",
|
"label": "Uwzględnij skrzynki grupowe",
|
||||||
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
|
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Todos os arquivos",
|
"unified_archive": "Todos os arquivos",
|
||||||
"unified_junk": "Todo o spam",
|
"unified_junk": "Todo o spam",
|
||||||
"all_accounts": "Todas as contas",
|
"all_accounts": "Todas as contas",
|
||||||
|
"unified_mailbox": "Caixa de correio unificada",
|
||||||
"expand": "Expandir",
|
"expand": "Expandir",
|
||||||
"collapse": "Recolher",
|
"collapse": "Recolher",
|
||||||
"expand_tooltip": "Expandir",
|
"expand_tooltip": "Expandir",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Caixa de correio unificada",
|
"label": "Caixa de correio unificada",
|
||||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
|
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Incluir caixas de grupo",
|
"label": "Incluir caixas de grupo",
|
||||||
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
|
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Arhivă completă",
|
"unified_archive": "Arhivă completă",
|
||||||
"unified_junk": "Toate mesajele nedorite",
|
"unified_junk": "Toate mesajele nedorite",
|
||||||
"all_accounts": "Toate conturile",
|
"all_accounts": "Toate conturile",
|
||||||
|
"unified_mailbox": "Căsuță poștală unificată",
|
||||||
"expand": "Extindeți",
|
"expand": "Extindeți",
|
||||||
"collapse": "Reduceți",
|
"collapse": "Reduceți",
|
||||||
"expand_tooltip": "Extindeți",
|
"expand_tooltip": "Extindeți",
|
||||||
@@ -927,14 +928,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Căsuță poștală unificată",
|
"label": "Căsuță poștală unificată",
|
||||||
"description": "Afișați folderele combinate (Mesaje primite, Mesaje trimise etc.) pentru toate conturile conectate",
|
"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": {
|
"include_group": {
|
||||||
"label": "Includeți căsuțele de e-mail de grup",
|
"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ă."
|
"description": "De asemenea, integrați căsuțele de e-mail partajate/de grup în vizualizarea unificată."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Dosare în „Toate mesajele”",
|
||||||
"folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.",
|
"folders_description": "Alegeți ce dosare să fie incluse în vizualizarea „Toate mesajele”.",
|
||||||
"account_hint": "Se aplică pentru {account}.",
|
"account_hint": "Se aplică pentru {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Все архивы",
|
"unified_archive": "Все архивы",
|
||||||
"unified_junk": "Весь спам",
|
"unified_junk": "Весь спам",
|
||||||
"all_accounts": "Все аккаунты",
|
"all_accounts": "Все аккаунты",
|
||||||
|
"unified_mailbox": "Общий почтовый ящик",
|
||||||
"expand": "Развернуть",
|
"expand": "Развернуть",
|
||||||
"collapse": "Свернуть",
|
"collapse": "Свернуть",
|
||||||
"expand_tooltip": "Развернуть",
|
"expand_tooltip": "Развернуть",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Общий почтовый ящик",
|
"label": "Общий почтовый ящик",
|
||||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
|
"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": {
|
"include_group": {
|
||||||
"label": "Включать групповые ящики",
|
"label": "Включать групповые ящики",
|
||||||
"description": "Также объединять общие/групповые ящики в едином представлении."
|
"description": "Также объединять общие/групповые ящики в едином представлении."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sidebar": {
|
"sidebar": {
|
||||||
|
"unified_mailbox": "Zjednotená schránka",
|
||||||
"close": "Zavrieť",
|
"close": "Zavrieť",
|
||||||
"compose": "Napísať",
|
"compose": "Napísať",
|
||||||
"compose_hint": "Napísať (c)",
|
"compose_hint": "Napísať (c)",
|
||||||
@@ -927,14 +928,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Zjednotená schránka",
|
"label": "Zjednotená schránka",
|
||||||
"description": "Zobrazovať zlúčené priečinky (Doručené, Odoslané atd.) zo všetkých pripojených účtov",
|
"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": {
|
"include_group": {
|
||||||
"label": "Zahrnúť skupinové schránky",
|
"label": "Zahrnúť skupinové schránky",
|
||||||
"description": "Zahrnúť do zjednoteného zobrazenia aj zdieľané/skupinové schránky."
|
"description": "Zahrnúť do zjednoteného zobrazenia aj zdieľané/skupinové schránky."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Priečinky vo Všetkej pošte",
|
||||||
"folders_description": "Vyberte, ktoré priečinky sa spoja do zobrazenia Všetka pošta.",
|
"folders_description": "Vyberte, ktoré priečinky sa spoja do zobrazenia Všetka pošta.",
|
||||||
"account_hint": "Platí pre {account}.",
|
"account_hint": "Platí pre {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Tüm Arşiv",
|
"unified_archive": "Tüm Arşiv",
|
||||||
"unified_junk": "Tüm Önemsiz",
|
"unified_junk": "Tüm Önemsiz",
|
||||||
"all_accounts": "Tüm Hesaplar",
|
"all_accounts": "Tüm Hesaplar",
|
||||||
|
"unified_mailbox": "Birleşik Posta Kutusu",
|
||||||
"expand": "Genişlet",
|
"expand": "Genişlet",
|
||||||
"collapse": "Daralt",
|
"collapse": "Daralt",
|
||||||
"expand_tooltip": "Genişlet",
|
"expand_tooltip": "Genişlet",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Birleşik Posta Kutusu",
|
"label": "Birleşik Posta Kutusu",
|
||||||
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
|
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
|
||||||
|
"cross_account": {
|
||||||
|
"label": "Across all accounts",
|
||||||
|
"description": "Merge the unified mailbox across every connected account instead of staying within the active account."
|
||||||
|
},
|
||||||
"include_group": {
|
"include_group": {
|
||||||
"label": "Grup gelen kutularını dahil et",
|
"label": "Grup gelen kutularını dahil et",
|
||||||
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
|
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "Усі архіви",
|
"unified_archive": "Усі архіви",
|
||||||
"unified_junk": "Весь спам",
|
"unified_junk": "Весь спам",
|
||||||
"all_accounts": "Усі облікові записи",
|
"all_accounts": "Усі облікові записи",
|
||||||
|
"unified_mailbox": "Спільна поштова скринька",
|
||||||
"expand": "Розгорнути",
|
"expand": "Розгорнути",
|
||||||
"collapse": "Згорнути",
|
"collapse": "Згорнути",
|
||||||
"expand_tooltip": "Розгорнути",
|
"expand_tooltip": "Розгорнути",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "Спільна поштова скринька",
|
"label": "Спільна поштова скринька",
|
||||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
|
"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": {
|
"include_group": {
|
||||||
"label": "Включати групові скриньки",
|
"label": "Включати групові скриньки",
|
||||||
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
|
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -114,6 +114,7 @@
|
|||||||
"unified_archive": "所有归档",
|
"unified_archive": "所有归档",
|
||||||
"unified_junk": "所有垃圾邮件",
|
"unified_junk": "所有垃圾邮件",
|
||||||
"all_accounts": "所有账户",
|
"all_accounts": "所有账户",
|
||||||
|
"unified_mailbox": "统一邮箱",
|
||||||
"expand": "展开",
|
"expand": "展开",
|
||||||
"collapse": "收起",
|
"collapse": "收起",
|
||||||
"expand_tooltip": "展开",
|
"expand_tooltip": "展开",
|
||||||
@@ -924,14 +925,16 @@
|
|||||||
"unified_mailbox": {
|
"unified_mailbox": {
|
||||||
"label": "统一邮箱",
|
"label": "统一邮箱",
|
||||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
|
"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": {
|
"include_group": {
|
||||||
"label": "包含群组收件箱",
|
"label": "包含群组收件箱",
|
||||||
"description": "在统一视图中也合并共享/群组收件箱。"
|
"description": "在统一视图中也合并共享/群组收件箱。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"all_mail": {
|
"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_label": "Folders in All Mail",
|
||||||
"folders_description": "Choose which folders are merged into the All Mail view.",
|
"folders_description": "Choose which folders are merged into the All Mail view.",
|
||||||
"account_hint": "Applies to {account}.",
|
"account_hint": "Applies to {account}.",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useEmailStore } from '../email-store';
|
|||||||
import { useAuthStore } from '../auth-store';
|
import { useAuthStore } from '../auth-store';
|
||||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
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
|
// 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
|
// 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),
|
toggleStar: vi.fn().mockResolvedValue(undefined),
|
||||||
moveEmail: vi.fn().mockResolvedValue(undefined),
|
moveEmail: vi.fn().mockResolvedValue(undefined),
|
||||||
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
batchMarkAsRead: vi.fn().mockResolvedValue(undefined),
|
||||||
|
batchDeleteEmails: vi.fn().mockResolvedValue(undefined),
|
||||||
|
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||||||
} as unknown as IJMAPClient;
|
} as unknown as IJMAPClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,9 @@ describe('unified-view single-email action routing (#281)', () => {
|
|||||||
processingReadStatus: new Set(),
|
processingReadStatus: new Set(),
|
||||||
selectedEmail: null,
|
selectedEmail: null,
|
||||||
selectedEmailIds: new Set(),
|
selectedEmailIds: new Set(),
|
||||||
|
unifiedScope: [],
|
||||||
|
unifiedCounts: [],
|
||||||
|
crossUnreadCount: 0,
|
||||||
emails: [
|
emails: [
|
||||||
// Second direct login: sourceClientAccountId === sourceAccountId === 'account-b'.
|
// 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 } }),
|
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');
|
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 () => {
|
it('still uses the active/passed client outside unified view', async () => {
|
||||||
useEmailStore.setState({
|
useEmailStore.setState({
|
||||||
isUnifiedView: false,
|
isUnifiedView: false,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
import { useSettingsStore } from '../settings-store';
|
import { useSettingsStore, migrateSettings } from '../settings-store';
|
||||||
|
|
||||||
describe('settings-store per-account allMailFolderIds', () => {
|
describe('settings-store per-account allMailFolderIds', () => {
|
||||||
beforeEach(() => {
|
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 { 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)', () => {
|
describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -55,4 +55,27 @@ describe('settings-store per-account preferredIdentityIds (issue #507)', () => {
|
|||||||
expect(useSettingsStore.getState().preferredIdentityIds).toEqual({ 'acct-9': 'a' });
|
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
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
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 { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
|
||||||
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
||||||
import { useSettingsStore } from "@/stores/settings-store";
|
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 { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||||
import { emailHooks } from "@/lib/plugin-hooks";
|
import { emailHooks } from "@/lib/plugin-hooks";
|
||||||
import type { ExternalSearchResult } from "@/lib/plugin-types";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import { useAccountStore } from "@/stores/account-store";
|
import { useAccountStore } from "@/stores/account-store";
|
||||||
|
|
||||||
@@ -82,9 +82,23 @@ interface EmailStore {
|
|||||||
// isUnifiedView.
|
// isUnifiedView.
|
||||||
crossView: CrossView | null;
|
crossView: CrossView | null;
|
||||||
unifiedErrors: Map<string, string>; // accountId -> error message
|
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[];
|
unifiedCounts: UnifiedMailboxCounts[];
|
||||||
// Unread total across the cross-view included folders (badge for unread/all).
|
// Unread total across the cross-view included folders (badge for unread/all).
|
||||||
crossUnreadCount: number;
|
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
|
// Scheduled send state
|
||||||
scheduledEmails: ScheduledEmail[];
|
scheduledEmails: ScheduledEmail[];
|
||||||
@@ -330,34 +344,19 @@ function resolveActionMailboxes(): Mailbox[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the JMAP mailbox ids that make up the gated "All Mail" view for the
|
* Resolves the store-side mailbox ids that make up a personal account's
|
||||||
* active/viewing account. Honors that account's `allMailFolderIds` entry; when
|
* contribution to the unified cross views (All mail / Unread / Starred), honoring
|
||||||
* not configured it defaults to every non-special (no-role) folder. Shared
|
* that account's `allMailFolderIds` folder selection. Returns `undefined` when the
|
||||||
* folders are excluded - All Mail is scoped to a single account. Returns
|
* account has no explicit selection, so the cross views fall back to their
|
||||||
* JMAP-side ids (originalId for namespaced mailboxes).
|
* 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[] {
|
function resolveCrossIncludedMailboxIds(accountId: string, ownMailboxes: Mailbox[]): string[] | undefined {
|
||||||
const mailboxes = resolveActionMailboxes().filter((mb) => !mb.isShared);
|
const configured = useSettingsStore.getState().allMailFolderIds[accountId];
|
||||||
// Per-account selection: read the entry for the account the view is scoped to
|
if (configured === undefined) return undefined;
|
||||||
// (the Pro viewing override, else the global active account). A missing entry
|
const selected = new Set(configured);
|
||||||
// = "not configured" -> all no-role folders; an explicit [] = no folders.
|
return ownMailboxes.filter((mb) => selected.has(mb.id)).map((mb) => mb.id);
|
||||||
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 })) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -418,12 +417,24 @@ function resolveEmailActionContext(
|
|||||||
* owner account reachable through each logged-in client. The shared entries
|
* owner account reachable through each logged-in client. The shared entries
|
||||||
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
|
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
|
||||||
* requests via `originalId` + owner accountId.
|
* 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(
|
export async function buildUnifiedAccountClients(
|
||||||
opts: { includeGroup?: boolean } = {},
|
opts: { includeGroup?: boolean; scopeToClientAccountId?: string } = {},
|
||||||
): Promise<UnifiedAccountClient[]> {
|
): Promise<UnifiedAccountClient[]> {
|
||||||
const { includeGroup = false } = opts;
|
const { includeGroup = false, scopeToClientAccountId } = opts;
|
||||||
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
const authAccounts = useAccountStore.getState().accounts.filter(
|
||||||
|
(a) => a.isConnected && (!scopeToClientAccountId || a.id === scopeToClientAccountId),
|
||||||
|
);
|
||||||
const allClients = useAuthStore.getState().getAllConnectedClients();
|
const allClients = useAuthStore.getState().getAllConnectedClients();
|
||||||
const built: UnifiedAccountClient[] = [];
|
const built: UnifiedAccountClient[] = [];
|
||||||
// Per-account mailbox lists gathered here are cached into `accountMailboxes`
|
// 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
|
// no-op (no namespacing) — keeps personal behavior identical while making
|
||||||
// resolution branch-free against shared sources.
|
// resolution branch-free against shared sources.
|
||||||
const primaryJmapId = c.getAccountId();
|
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;
|
fetchedMailboxes[a.id] = ownMailboxes;
|
||||||
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
|
// Also cache under the JMAP id so `accountMailboxes[email.sourceAccountId]`
|
||||||
// resolves uniformly for personal and shared sources alike.
|
// 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.
|
// Whether an email belongs to a given mailbox, for local counter math.
|
||||||
// Shared/group-account emails carry NAMESPACED mailboxIds (`${ownerId}:${origId}`,
|
// Own-account emails carry bare ids (equal to both `id` and `originalId`).
|
||||||
// which equals the shared mailbox's `id`), while own-account emails carry bare ids
|
// As of #281 V3, EVERY client fetch path (getEmails/getEmail/getThreadEmails/
|
||||||
// (equal to both `id` and `originalId`). Matching `mailbox.id` covers both; the
|
// searchEmails/advancedSearchEmails) namespaces shared/delegated emails'
|
||||||
// `originalId` fallback is restricted to non-shared mailboxes so a bare own-account
|
// `mailboxIds` to the store id (`${ownerId}:${origId}`), so the `ids[mailbox.id]`
|
||||||
// id can't collide with another account's shared folder. (#281)
|
// 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(
|
function emailInMailbox(
|
||||||
email: { mailboxIds?: Record<string, boolean> },
|
email: { mailboxIds?: Record<string, boolean>; sourceAccountId?: string },
|
||||||
mailbox: Mailbox,
|
mailbox: Mailbox,
|
||||||
): boolean {
|
): boolean {
|
||||||
const ids = email.mailboxIds;
|
const ids = email.mailboxIds;
|
||||||
if (!ids) return false;
|
if (!ids) return false;
|
||||||
if (ids[mailbox.id]) return true;
|
if (ids[mailbox.id]) return true;
|
||||||
if (!mailbox.isShared && mailbox.originalId) return !!ids[mailbox.originalId];
|
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;
|
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
|
// 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
|
// falls back to name matching ("trash" / "deleted") so users with custom or
|
||||||
// pre-existing folders (e.g. "Deleted Items") aren't silently destroyed.
|
// 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(),
|
unifiedErrors: new Map(),
|
||||||
unifiedCounts: [],
|
unifiedCounts: [],
|
||||||
crossUnreadCount: 0,
|
crossUnreadCount: 0,
|
||||||
|
unifiedScope: [],
|
||||||
|
|
||||||
// Scheduled send state
|
// Scheduled send state
|
||||||
scheduledEmails: [],
|
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)
|
// doesn't exist in the fetched list (e.g. after an account switch)
|
||||||
const currentSelectedMailbox = get().selectedMailbox;
|
const currentSelectedMailbox = get().selectedMailbox;
|
||||||
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|
const selectionValid = currentSelectedMailbox === VIRTUAL_SCHEDULED_MAILBOX_ID
|
||||||
|| currentSelectedMailbox === ALL_MAIL_MAILBOX_ID
|
|
||||||
// Unified per-role views (All Inbox/Drafts/Junk/…) and cross-account views
|
// 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
|
// (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
|
// 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);
|
await get().fetchScheduledEmails(client);
|
||||||
return;
|
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);
|
const effectiveClient = resolveActionClient(client);
|
||||||
|
|
||||||
// Find the mailbox to get its accountId (for shared folder support)
|
// 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 includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||||
const position = emails.length;
|
const position = emails.length;
|
||||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||||
const result = searchQuery
|
const hasFilters = !isFilterEmpty(get().searchFilters);
|
||||||
? await searchCrossViewEmails(built, crossView, searchQuery, emailsPerPage, position)
|
const result = hasFilters
|
||||||
: await fetchCrossViewEmails(built, crossView, emailsPerPage, position);
|
? 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 currentEmails = get().emails;
|
||||||
const existingIds = new Set(currentEmails.map(e => e.id));
|
const existingIds = new Set(currentEmails.map(e => e.id));
|
||||||
const newEmails = result.emails.filter(e => !existingIds.has(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 { searchFilters } = get();
|
||||||
const hasFilters = !isFilterEmpty(searchFilters);
|
const hasFilters = !isFilterEmpty(searchFilters);
|
||||||
|
|
||||||
if (selectedMailbox === ALL_MAIL_MAILBOX_ID) {
|
if (searchQuery || hasFilters) {
|
||||||
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) {
|
|
||||||
const mailboxes = resolveActionMailboxes();
|
const mailboxes = resolveActionMailboxes();
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
const jmapMailboxId = mailbox?.originalId || 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);
|
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
|
// Use fresh state when merging to avoid overwriting concurrent updates
|
||||||
// (e.g. refreshCurrentMailbox running during the load)
|
// (e.g. refreshCurrentMailbox running during the load)
|
||||||
const currentEmails = get().emails;
|
const currentEmails = get().emails;
|
||||||
@@ -1815,24 +1853,22 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
unifiedErrors = result.errors;
|
unifiedErrors = result.errors;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// Get the current mailbox to scope the search. In the All Mail view the
|
// Get the current mailbox to scope the search.
|
||||||
// search spans every folder of the account (no inMailbox constraint).
|
|
||||||
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
|
|
||||||
const mailboxes = resolveActionMailboxes();
|
const mailboxes = resolveActionMailboxes();
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
// Use originalId for shared mailboxes
|
// 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
|
// 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);
|
result = await resolveActionClient(client).searchEmails(query, jmapMailboxId, accountId, emailsPerPage, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const hookEdit = await emailHooks.onSearchResults.transform({
|
const hookEdit = await emailHooks.onSearchResults.transform({
|
||||||
newEmailIds: [] as string[],
|
newEmailIds: [] as string[],
|
||||||
result: result,
|
result: result,
|
||||||
query: query,
|
query: query,
|
||||||
filters: searchFilters
|
filters: searchFilters
|
||||||
});
|
});
|
||||||
|
|
||||||
result = hookEdit.result;
|
result = hookEdit.result;
|
||||||
@@ -1843,7 +1879,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
result.total += newEmails.length;
|
result.total += newEmails.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
|
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], {
|
||||||
query,
|
query,
|
||||||
filters: searchFilters
|
filters: searchFilters
|
||||||
});
|
});
|
||||||
@@ -1895,7 +1931,11 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
if (isUnifiedView && crossView) {
|
if (isUnifiedView && crossView) {
|
||||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
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;
|
unifiedErrors = result.errors;
|
||||||
|
|
||||||
} else if (isUnifiedView && unifiedRole) {
|
} else if (isUnifiedView && unifiedRole) {
|
||||||
@@ -1911,10 +1951,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
unifiedErrors = result.errors;
|
unifiedErrors = result.errors;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const isAllMail = selectedMailbox === ALL_MAIL_MAILBOX_ID;
|
|
||||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||||
const jmapMailboxId = isAllMail ? undefined : (mailbox?.originalId || selectedMailbox);
|
const jmapMailboxId = mailbox?.originalId || selectedMailbox;
|
||||||
accountId = isAllMail ? undefined : (mailbox?.isShared ? mailbox.accountId : undefined);
|
accountId = mailbox?.isShared ? mailbox.accountId : undefined;
|
||||||
|
|
||||||
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
const filter = buildJMAPFilter(searchQuery, searchFilters, jmapMailboxId);
|
||||||
result = await resolveActionClient(client).advancedSearchEmails(filter, accountId, emailsPerPage, 0);
|
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)
|
// Get the current account ID from the client (assuming primary account)
|
||||||
const accountId = client.getAccountId();
|
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];
|
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
|
// Handle Email state changes - refresh current mailbox
|
||||||
if (accountChanges.Email) {
|
if (accountChanges?.Email) {
|
||||||
await get().refreshCurrentMailbox(client);
|
await get().refreshCurrentMailbox(client);
|
||||||
get().fetchTagCounts(client);
|
get().fetchTagCounts(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (accountChanges.EmailSubmission) {
|
if (accountChanges?.EmailSubmission) {
|
||||||
await get().refreshScheduledMetadata(client);
|
await get().refreshScheduledMetadata(client);
|
||||||
if (get().isScheduledView) {
|
if (get().isScheduledView) {
|
||||||
await get().fetchScheduledEmails(client);
|
await get().fetchScheduledEmails(client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Mailbox state changes - refresh mailbox list
|
// Handle Mailbox state changes - refresh mailbox list (own + delegated
|
||||||
if (accountChanges.Mailbox) {
|
// shared folders), so both the active account's and its shared folders'
|
||||||
|
// counters follow background activity.
|
||||||
|
if (anyMailboxChanged) {
|
||||||
await get().fetchMailboxes(client);
|
await get().fetchMailboxes(client);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
// Handle Calendar/CalendarEvent state changes - refresh calendar data
|
||||||
if (accountChanges.Calendar || accountChanges.CalendarEvent) {
|
if (accountChanges?.Calendar || accountChanges?.CalendarEvent) {
|
||||||
const calendarStore = useCalendarStore.getState();
|
const calendarStore = useCalendarStore.getState();
|
||||||
if (calendarStore.supportsCalendar) {
|
if (calendarStore.supportsCalendar) {
|
||||||
calendarStore.fetchCalendars(client);
|
calendarStore.fetchCalendars(client);
|
||||||
@@ -2665,7 +2711,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle SieveScript state changes - refresh filter rules
|
// Handle SieveScript state changes - refresh filter rules
|
||||||
if (accountChanges.SieveScript) {
|
if (accountChanges?.SieveScript) {
|
||||||
const { useFilterStore } = await import('./filter-store');
|
const { useFilterStore } = await import('./filter-store');
|
||||||
const filterStore = useFilterStore.getState();
|
const filterStore = useFilterStore.getState();
|
||||||
if (filterStore.isSupported) {
|
if (filterStore.isSupported) {
|
||||||
@@ -3293,8 +3339,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
refreshUnifiedCounts: async (accounts) => {
|
refreshUnifiedCounts: async (accounts) => {
|
||||||
try {
|
try {
|
||||||
const counts = fetchUnifiedMailboxCounts(accounts);
|
// Store the scope and project live counters over it. The badges then track
|
||||||
set({ unifiedCounts: counts });
|
// 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) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh unified counts:', error);
|
console.error('Failed to refresh unified counts:', error);
|
||||||
}
|
}
|
||||||
@@ -3333,7 +3384,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
|
|
||||||
refreshCrossCounts: (accounts) => {
|
refreshCrossCounts: (accounts) => {
|
||||||
try {
|
try {
|
||||||
set({ crossUnreadCount: getCrossUnreadTotal(accounts) });
|
set((state) => {
|
||||||
|
const next = { ...state, unifiedScope: accounts };
|
||||||
|
return { unifiedScope: accounts, ...projectUnifiedCounts(next) };
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to refresh cross-account counts:', 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,
|
selectedMailbox: isScheduledView ? VIRTUAL_SCHEDULED_MAILBOX_ID : leavingScheduled ? "" : state.selectedMailbox,
|
||||||
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
selectedEmail: leavingScheduled ? null : state.selectedEmail,
|
||||||
selectedEmailIds: leavingScheduled ? new Set<string>() : state.selectedEmailIds,
|
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 }),
|
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
@@ -233,22 +233,26 @@ interface SettingsState {
|
|||||||
|
|
||||||
// Unified Mailbox
|
// Unified Mailbox
|
||||||
enableUnifiedMailbox: boolean;
|
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;
|
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
|
// Unified Mailbox entries, each gated per-view by the admin policy. These show
|
||||||
// of folder ids merged into the virtual "All Mail" mailbox. `null` = never
|
// the All mail / Unread / Starred lists, scoped by `unifiedCrossAccount` and
|
||||||
// configured, in which case the view defaults to all non-special (no-role)
|
// narrowed by the `allMailFolderIds` folder selection.
|
||||||
// folders of the active account.
|
|
||||||
enableAllMailView: boolean;
|
|
||||||
|
|
||||||
// Cross-account "All accounts" views (gated per-view by the admin policy)
|
|
||||||
enableCrossUnreadView: boolean;
|
enableCrossUnreadView: boolean;
|
||||||
enableCrossStarredView: boolean;
|
enableCrossStarredView: boolean;
|
||||||
enableCrossAllView: boolean;
|
enableCrossAllView: boolean;
|
||||||
|
|
||||||
// Per-account "All Mail" folder selection, keyed by AccountEntry.id. A
|
// Per-account folder selection narrowing the unified All mail / Unread /
|
||||||
// missing entry = "not configured" -> defaults to every no-role folder; an
|
// Starred lists, keyed by AccountEntry.id. A missing entry = "not configured"
|
||||||
// explicit [] = "no folders". (Replaced the legacy global string[] | null.)
|
// -> defaults to inbox + custom folders; an explicit [] = "no own folders".
|
||||||
allMailFolderIds: Record<string, string[]>;
|
allMailFolderIds: Record<string, string[]>;
|
||||||
|
|
||||||
// Per-account default sender identity, keyed by AccountEntry.id -> JMAP
|
// Per-account default sender identity, keyed by AccountEntry.id -> JMAP
|
||||||
@@ -444,10 +448,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
|
|
||||||
// Unified Mailbox
|
// Unified Mailbox
|
||||||
enableUnifiedMailbox: false,
|
enableUnifiedMailbox: false,
|
||||||
includeGroupInUnified: false,
|
includeGroupInUnified: true,
|
||||||
|
unifiedCrossAccount: false,
|
||||||
|
|
||||||
// All Mail view (gated)
|
|
||||||
enableAllMailView: false,
|
|
||||||
allMailFolderIds: {} as Record<string, string[]>,
|
allMailFolderIds: {} as Record<string, string[]>,
|
||||||
preferredIdentityIds: {} 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.
|
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
|
||||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||||
includeGroupInUnified: state.includeGroupInUnified,
|
includeGroupInUnified: state.includeGroupInUnified,
|
||||||
enableAllMailView: state.enableAllMailView,
|
unifiedCrossAccount: state.unifiedCrossAccount,
|
||||||
allMailFolderIds: state.allMailFolderIds,
|
allMailFolderIds: state.allMailFolderIds,
|
||||||
preferredIdentityIds: state.preferredIdentityIds,
|
preferredIdentityIds: state.preferredIdentityIds,
|
||||||
enableCrossUnreadView: state.enableCrossUnreadView,
|
enableCrossUnreadView: state.enableCrossUnreadView,
|
||||||
@@ -893,9 +896,36 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'settings-storage',
|
name: 'settings-storage',
|
||||||
version: 6,
|
version: 7,
|
||||||
migrate: (persisted, version) => {
|
migrate: migrateSettings,
|
||||||
const state = persisted as Record<string, unknown>;
|
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) {
|
if (version < 2 && state.listDensity) {
|
||||||
state.density = state.listDensity;
|
state.density = state.listDensity;
|
||||||
delete state.listDensity;
|
delete state.listDensity;
|
||||||
@@ -921,34 +951,40 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
|
if (version < 5 || !isPlainRecord(state.allMailFolderIds)) {
|
||||||
state.allMailFolderIds = {};
|
state.allMailFolderIds = {};
|
||||||
}
|
}
|
||||||
// v6: introduced the per-account default-identity map (issue #507).
|
// "All accounts" was reworked into the account-bounded "Unified
|
||||||
// Coerce any missing/legacy value to an empty record.
|
// 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)) {
|
if (version < 6 || !isPlainRecord(state.preferredIdentityIds)) {
|
||||||
state.preferredIdentityIds = {};
|
state.preferredIdentityIds = {};
|
||||||
}
|
}
|
||||||
return state as unknown as SettingsState;
|
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
|
// Helper functions to apply settings to DOM
|
||||||
function applyFontSize(size: FontSize) {
|
function applyFontSize(size: FontSize) {
|
||||||
|
|||||||
+4
-1
@@ -8,7 +8,10 @@ export default defineConfig({
|
|||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
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: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user