feat: cross-account "All accounts" views + full group/shared-account support
Add cross-account aggregate mail views and make group/shared (delegated) accounts first-class in every aggregate view. (The unified mailbox, the "All Mail" view, and "include group inboxes" already exist on main; this branch adds the cross-account views and the shared-account correctness work.) New views (admin-gated + per-user toggle, nested under Unified Mailbox): - Cross-account "All accounts": All unread / All starred / All mail across every connected account, including shared/group folders. Each list labels the source folder of every message. Source reference on aggregated emails (the core of the shared-account work): - Replace the overloaded `accountId` with two explicit, always-set fields: `sourceClientAccountId` (the login the mail is reachable through) and `sourceAccountId` (the owning JMAP account). `accountId` stays display-only. - Resolution is branch-free everywhere: pick the client by sourceClientAccountId, pass sourceAccountId as the JMAP accountId (no-op for personal), read the owner's mailbox list cached by JMAP id. No capability scan. Shared/group-account correctness across all aggregate views: - Route open (click + auto-fetch), thread/conversation open + reply-refresh, mark read, star, move, delete (account-scoped trash), archive (owner-routed createMailbox / fetchAccountMailboxes), and spam + undo via the source ref. - Add accountId params to toggleStar / batchMarkAsRead / batchDeleteEmails / createMailbox where missing. - Fix local unread/total counter math for shared folders via emailInMailbox() (matches namespaced shared ids and bare own ids). - Keep the unified/cross virtual selection on background mailbox refresh (no jump back to inbox after deleting in All Drafts/Junk). Junk UX: - In "All Junk" the spam action becomes "not spam" in the viewer, context menu, and list hover icons; undo routes shared mail back to its own inbox. Admin: - Policy gates crossUnread/Starred/AllViewEnabled, each noting the matching per-user toggle (allMailViewEnabled clarified too). i18n / docs / tests: - locales (19): cross-view labels + descriptions and hover not_spam, translated in all shipped languages. - FEATURES.md + README.md document the new views and group-account support. - Tests for shared-account routing (single + batch + undoSpam), decoration, and unified-selection preservation.
This commit is contained in:
+139
-44
@@ -11,7 +11,7 @@ import type { ComposerDraftData } from "@/components/email/email-composer";
|
||||
import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker";
|
||||
import { ThreadConversationView } from "@/components/email/thread-conversation-view";
|
||||
import { MobileHeader } from "@/components/layout/mobile-header";
|
||||
import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID } from "@/lib/jmap/types";
|
||||
import { ThreadGroup, Email, Mailbox, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID, ALL_MAIL_MAILBOX_ID, CROSS_VIEW_BY_ID, isCrossViewId } from "@/lib/jmap/types";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
@@ -319,7 +319,10 @@ export default function Home() {
|
||||
clearPendingUndoSend,
|
||||
pendingUndoSend,
|
||||
fetchUnifiedEmails: fetchUnifiedEmailsAction,
|
||||
fetchCrossView: fetchCrossViewAction,
|
||||
refreshUnifiedCounts,
|
||||
refreshCrossCounts,
|
||||
crossUnreadCount,
|
||||
exitUnifiedView,
|
||||
emptyMailbox,
|
||||
markMailboxAsRead,
|
||||
@@ -347,6 +350,19 @@ export default function Home() {
|
||||
const delayedSendSupported = client?.hasDelayedSend() ?? true;
|
||||
const allMailViewEnabled = usePolicyStore((s) => s.isFeatureEnabled('allMailViewEnabled'));
|
||||
const showAllMailMailbox = allMailViewEnabled && enableAllMailView;
|
||||
|
||||
// Cross-account "All accounts" views: a sub-feature of the unified mailbox, so
|
||||
// they require Unified Mailbox to be enabled, plus the admin gate and the
|
||||
// per-user setting. Hooks are called unconditionally.
|
||||
const crossUnreadGate = usePolicyStore((s) => s.isFeatureEnabled('crossUnreadViewEnabled'));
|
||||
const crossStarredGate = usePolicyStore((s) => s.isFeatureEnabled('crossStarredViewEnabled'));
|
||||
const crossAllGate = usePolicyStore((s) => s.isFeatureEnabled('crossAllViewEnabled'));
|
||||
const enableCrossUnreadView = useSettingsStore((s) => s.enableCrossUnreadView);
|
||||
const enableCrossStarredView = useSettingsStore((s) => s.enableCrossStarredView);
|
||||
const enableCrossAllView = useSettingsStore((s) => s.enableCrossAllView);
|
||||
const showCrossUnread = enableUnifiedMailbox && crossUnreadGate && enableCrossUnreadView;
|
||||
const showCrossStarred = enableUnifiedMailbox && crossStarredGate && enableCrossStarredView;
|
||||
const showCrossAll = enableUnifiedMailbox && crossAllGate && enableCrossAllView;
|
||||
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
@@ -986,14 +1002,16 @@ export default function Home() {
|
||||
// recounting happened"). The Pro shell always renders the unified mailbox
|
||||
// regardless of the user setting, so refresh when embedded too.
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||
const anyCross = showCrossUnread || showCrossStarred || showCrossAll;
|
||||
if (!enableUnifiedMailbox && !isEmbedded && !anyCross) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
buildPopulatedUnifiedAccounts().then((built) => {
|
||||
const hasGroupEntry = built.some((b) => b.isShared);
|
||||
if (anyCross) refreshCrossCounts(built);
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts, refreshCrossCounts, showCrossUnread, showCrossStarred, showCrossAll]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1039,8 +1057,8 @@ export default function Home() {
|
||||
// Skip when handleEmailSelect already started a fetch (it sets isLoadingEmail before
|
||||
// calling selectEmail on the stub), to avoid a duplicate request.
|
||||
if (!selectedEmail.bodyValues && !isLoadingEmail) {
|
||||
const perAccountClient = isUnifiedView && selectedEmail.accountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.accountId)
|
||||
const perAccountClient = isUnifiedView && selectedEmail.sourceClientAccountId
|
||||
? useAuthStore.getState().getClientForAccount(selectedEmail.sourceClientAccountId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
setLoadingEmail(true);
|
||||
@@ -1177,8 +1195,15 @@ export default function Home() {
|
||||
const emailState = useEmailStore.getState();
|
||||
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||
const accountId = client.getAccountId();
|
||||
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
// Route to the email's own account so shared/group threads refresh
|
||||
// from the right server, not the active one. (#281)
|
||||
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||
? repliedEmail.sourceAccountId
|
||||
: client.getAccountId();
|
||||
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
if (fullEmails.length > 0) {
|
||||
useEmailStore.setState((state) => {
|
||||
const c = new Map(state.threadEmailsCache);
|
||||
@@ -1414,10 +1439,11 @@ export default function Home() {
|
||||
if (!client || !emailToDelete) return;
|
||||
|
||||
// In unified view the trash destination and current-folder check must come
|
||||
// from the email's own account, not the active one. (#281)
|
||||
// from the email's own account, not the active one. The owning account's
|
||||
// mailbox list is cached under its JMAP id (`sourceAccountId`). (#281)
|
||||
const actionMailboxes =
|
||||
isUnifiedView && emailToDelete.accountId
|
||||
? (accountMailboxes[emailToDelete.accountId] ?? mailboxes)
|
||||
isUnifiedView && emailToDelete.sourceAccountId
|
||||
? (accountMailboxes[emailToDelete.sourceAccountId] ?? mailboxes)
|
||||
: mailboxes;
|
||||
|
||||
// Check if we're currently in the trash or junk folder. In unified view the
|
||||
@@ -1446,11 +1472,17 @@ export default function Home() {
|
||||
console.error("Failed to permanently delete email:", error);
|
||||
}
|
||||
} else {
|
||||
// Not in trash: always move to trash (in the email's own account).
|
||||
// Not in trash: always move to trash (in the email's own account). Scope
|
||||
// the trash lookup to the email's account: for a shared/group source every
|
||||
// mailbox in the list is `isShared`, so we match by accountId instead of
|
||||
// excluding shared (otherwise no trash is found and the delete fails). (#281)
|
||||
const sourceAccountId = isUnifiedView ? emailToDelete.sourceAccountId : undefined;
|
||||
const matchesScope = (m: Mailbox) =>
|
||||
sourceAccountId ? m.accountId === sourceAccountId : !m.isShared;
|
||||
const trashMailbox =
|
||||
actionMailboxes.find(m => m.role === 'trash' && !m.isShared) ??
|
||||
actionMailboxes.find(m => m.role === 'trash' && matchesScope(m)) ??
|
||||
actionMailboxes.find(m => {
|
||||
if (m.isShared) return false;
|
||||
if (!matchesScope(m)) return false;
|
||||
const lower = m.name.toLowerCase();
|
||||
return lower.includes('trash') || lower.includes('deleted');
|
||||
});
|
||||
@@ -1473,11 +1505,14 @@ export default function Home() {
|
||||
if (!client || !emailToArchive) return;
|
||||
|
||||
// In unified view the archive folder (and any year/month subfolders we
|
||||
// create) must live in the email's own account, reached through that
|
||||
// account's client. (#281)
|
||||
const archiveAccountId = isUnifiedView ? emailToArchive.accountId : undefined;
|
||||
const archiveClient = archiveAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(archiveAccountId) ?? client)
|
||||
// create) must live in the email's own account, reached through the login it
|
||||
// is reachable via (`sourceClientAccountId`) and routed to its owning JMAP
|
||||
// account (`sourceAccountId`). For personal sources these resolve to the
|
||||
// account itself, so behavior is unchanged. (#281)
|
||||
const archiveClientId = isUnifiedView ? emailToArchive.sourceClientAccountId : undefined;
|
||||
const archiveAccountId = isUnifiedView ? emailToArchive.sourceAccountId : undefined;
|
||||
const archiveClient = archiveClientId
|
||||
? (useAuthStore.getState().getClientForAccount(archiveClientId) ?? client)
|
||||
: client;
|
||||
// Read fresh mailboxes from the store – batch archive calls this in a loop,
|
||||
// and each iteration needs to see folders created by prior iterations.
|
||||
@@ -1512,7 +1547,7 @@ export default function Home() {
|
||||
m => m.name === year && m.parentId === archiveId
|
||||
);
|
||||
if (!yearMailbox) {
|
||||
yearMailbox = await archiveClient.createMailbox(year, archiveId);
|
||||
yearMailbox = await archiveClient.createMailbox(year, archiveId, archiveAccountId);
|
||||
await refreshMailboxes();
|
||||
}
|
||||
|
||||
@@ -1525,7 +1560,7 @@ export default function Home() {
|
||||
m => m.name === month && m.parentId === yearId
|
||||
);
|
||||
if (!monthMailbox) {
|
||||
monthMailbox = await archiveClient.createMailbox(month, yearId);
|
||||
monthMailbox = await archiveClient.createMailbox(month, yearId, archiveAccountId);
|
||||
await refreshMailboxes();
|
||||
}
|
||||
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||
@@ -1618,7 +1653,7 @@ export default function Home() {
|
||||
});
|
||||
} else {
|
||||
const jmapKey = `$label:${color}`;
|
||||
if (keywords[jmapKey] === true) {
|
||||
if (keywords[jmapKey]) {
|
||||
// Toggle off if already active
|
||||
keywords[jmapKey] = false;
|
||||
} else {
|
||||
@@ -1714,6 +1749,28 @@ export default function Home() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCrossViewId(mailboxId)) {
|
||||
setScheduledView(false);
|
||||
const view = CROSS_VIEW_BY_ID[mailboxId];
|
||||
if (!view) return;
|
||||
|
||||
selectMailbox(mailboxId);
|
||||
selectEmail(null);
|
||||
|
||||
if (isMobile) {
|
||||
setSidebarOpen(false);
|
||||
setActiveView("list");
|
||||
}
|
||||
if (isTablet) {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchCrossViewAction(populated, view);
|
||||
refreshCrossCounts(populated);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isUnifiedView) {
|
||||
exitUnifiedView();
|
||||
}
|
||||
@@ -2227,8 +2284,15 @@ export default function Home() {
|
||||
const emailState = useEmailStore.getState();
|
||||
const repliedEmail = emailState.emails.find(e => e.id === originalEmailId);
|
||||
if (repliedEmail?.threadId && emailState.expandedThreadIds.has(repliedEmail.threadId)) {
|
||||
const accountId = client.getAccountId();
|
||||
const fullEmails = await client.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
// Route to the email's own account so shared/group threads refresh from
|
||||
// the right server, not the active one. (#281)
|
||||
const threadClient = emailState.isUnifiedView && repliedEmail.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(repliedEmail.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const accountId = emailState.isUnifiedView && repliedEmail.sourceAccountId
|
||||
? repliedEmail.sourceAccountId
|
||||
: client.getAccountId();
|
||||
const fullEmails = await threadClient.getThreadEmails(repliedEmail.threadId, accountId);
|
||||
if (fullEmails.length > 0) {
|
||||
useEmailStore.setState((state) => {
|
||||
const c = new Map(state.threadEmailsCache);
|
||||
@@ -2292,21 +2356,25 @@ export default function Home() {
|
||||
|
||||
// Fetch the full content
|
||||
try {
|
||||
// In unified view each email carries its own accountId. Use that
|
||||
// account's client so we fetch from the server that actually owns it.
|
||||
const emailAccountId = isUnifiedView ? listEmail?.accountId : undefined;
|
||||
const perAccountClient = emailAccountId
|
||||
? useAuthStore.getState().getClientForAccount(emailAccountId)
|
||||
// In unified view each email carries its source reference: the login it is
|
||||
// reachable through (`sourceClientAccountId`) and its owning JMAP account
|
||||
// (`sourceAccountId`). Resolve both so we fetch from the server that actually
|
||||
// owns it — works uniformly for personal and shared/group sources, since for
|
||||
// personal the owning account equals the client's primary (no-op). (#281)
|
||||
const sourceClientId = isUnifiedView ? listEmail?.sourceClientAccountId : undefined;
|
||||
const perAccountClient = sourceClientId
|
||||
? useAuthStore.getState().getClientForAccount(sourceClientId)
|
||||
: undefined;
|
||||
const fetchClient = perAccountClient ?? client;
|
||||
|
||||
// For shared folders on the primary client, we still need to pass the
|
||||
// shared account's id. In unified view we use the per-account client
|
||||
// directly, so no explicit accountId is needed.
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
const accountId = perAccountClient
|
||||
? undefined
|
||||
: mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
const accountId = isUnifiedView
|
||||
? listEmail?.sourceAccountId
|
||||
: (() => {
|
||||
// Non-unified: shared folders on the active client still need their
|
||||
// owner accountId passed explicitly.
|
||||
const mailbox = mailboxes.find(mb => mb.id === selectedMailbox);
|
||||
return mailbox?.isShared ? mailbox.accountId : undefined;
|
||||
})();
|
||||
|
||||
const fullEmail = await fetchClient.getEmail(email.id, accountId);
|
||||
if (fullEmail) {
|
||||
@@ -2318,9 +2386,13 @@ export default function Home() {
|
||||
fullEmail.isScheduled = true;
|
||||
fullEmail.isSmimeScheduled = listEmail.isSmimeScheduled;
|
||||
}
|
||||
if (emailAccountId) {
|
||||
fullEmail.accountId = emailAccountId;
|
||||
fullEmail.accountLabel = listEmail?.accountLabel;
|
||||
// Re-stamp the source reference so later actions on the open email
|
||||
// resolve to the right account (the fetched object lacks these).
|
||||
if (isUnifiedView && listEmail) {
|
||||
fullEmail.accountId = listEmail.accountId;
|
||||
fullEmail.accountLabel = listEmail.accountLabel;
|
||||
fullEmail.sourceClientAccountId = listEmail.sourceClientAccountId;
|
||||
fullEmail.sourceAccountId = listEmail.sourceAccountId;
|
||||
}
|
||||
selectEmail(fullEmail);
|
||||
// Mark-as-read logic is now handled by useEffect
|
||||
@@ -2369,8 +2441,27 @@ export default function Home() {
|
||||
setActiveView("viewer");
|
||||
|
||||
try {
|
||||
// Fetch complete thread emails
|
||||
const emails = await client.getThreadEmails(thread.threadId);
|
||||
// In unified/aggregate views the thread may belong to another (possibly
|
||||
// shared/group) account. Route the fetch to the login it's reachable
|
||||
// through (`sourceClientAccountId`) and pass its owning JMAP account
|
||||
// (`sourceAccountId`) so the thread loads from the right server instead of
|
||||
// the active one (which doesn't have it → empty/body-less). (#281)
|
||||
const ref = thread.emails?.[0];
|
||||
const threadClient = isUnifiedView && ref?.sourceClientAccountId
|
||||
? (useAuthStore.getState().getClientForAccount(ref.sourceClientAccountId) ?? client)
|
||||
: client;
|
||||
const threadAccountId = isUnifiedView ? ref?.sourceAccountId : undefined;
|
||||
const emails = await threadClient.getThreadEmails(thread.threadId, threadAccountId);
|
||||
// Re-stamp the source reference so conversation actions (reply/move/…)
|
||||
// resolve to the right account; the fetched objects don't carry it.
|
||||
if (isUnifiedView && ref) {
|
||||
for (const e of emails) {
|
||||
e.accountId = ref.accountId;
|
||||
e.accountLabel = ref.accountLabel;
|
||||
e.sourceClientAccountId = ref.sourceClientAccountId;
|
||||
e.sourceAccountId = ref.sourceAccountId;
|
||||
}
|
||||
}
|
||||
setConversationEmails(emails);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch thread emails:', error);
|
||||
@@ -2518,6 +2609,10 @@ export default function Home() {
|
||||
scheduledTotal={scheduledTotal}
|
||||
showScheduledMailbox={delayedSendSupported}
|
||||
showAllMailMailbox={showAllMailMailbox}
|
||||
showCrossUnread={showCrossUnread}
|
||||
showCrossStarred={showCrossStarred}
|
||||
showCrossAll={showCrossAll}
|
||||
crossUnreadCount={crossUnreadCount}
|
||||
onMailboxSelect={handleMailboxSelect}
|
||||
onTagSelect={handleTagSelect}
|
||||
onUnreadFilterClick={handleUnreadFilterClick}
|
||||
@@ -2685,19 +2780,19 @@ export default function Home() {
|
||||
icon={<Paperclip className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.has_attachment")}
|
||||
value={searchFilters.hasAttachment}
|
||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment === true ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.hasAttachment === null ? true : searchFilters.hasAttachment ? false : null; setSearchFilters({ hasAttachment: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={<Star className="w-3.5 h-3.5" />}
|
||||
label={t("advanced_search.starred")}
|
||||
value={searchFilters.isStarred}
|
||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred === true ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.isStarred === null ? true : searchFilters.isStarred ? false : null; setSearchFilters({ isStarred: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
<ToggleChip
|
||||
icon={searchFilters.isUnread === false ? <MailOpen className="w-3.5 h-3.5" /> : <Mail className="w-3.5 h-3.5" />}
|
||||
label={searchFilters.isUnread === false ? t("advanced_search.read") : t("advanced_search.unread")}
|
||||
value={searchFilters.isUnread}
|
||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread === true ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||
onClick={() => { const next = searchFilters.isUnread === null ? true : searchFilters.isUnread ? false : null; setSearchFilters({ isUnread: next }); handleAdvancedSearch(); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -3142,7 +3237,7 @@ export default function Home() {
|
||||
}}
|
||||
currentUserEmail={client?.getUsername()}
|
||||
currentUserName={client?.getUsername()?.split("@")[0]}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role}
|
||||
currentMailboxRole={mailboxes.find(m => m.id === selectedMailbox)?.role ?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined)}
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onMoveToMailbox={async (mailboxId) => {
|
||||
|
||||
Reference in New Issue
Block a user