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) => {
|
||||
|
||||
@@ -21,7 +21,10 @@ const FEATURE_GATE_LABELS: Partial<Record<keyof FeatureGates, { label: string; d
|
||||
folderIconsEnabled: { label: 'Folder Icons', description: 'Allow custom folder icon picker' },
|
||||
hoverActionsConfigEnabled: { label: 'Hover Actions Config', description: 'Allow users to customize email hover actions' },
|
||||
filesEnabled: { label: 'Files (WebDAV)', description: 'Enable file storage via WebDAV. WARNING: Large uploads can cause Stalwart/RocksDB instability. Not recommended for production.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included.' },
|
||||
allMailViewEnabled: { label: 'All Mail View', description: 'Show a virtual "All Mail" folder that merges messages from across an account’s folders into one list. Users choose which folders are included. Requires the per-user toggle in Settings → Appearance.' },
|
||||
crossUnreadViewEnabled: { label: 'All Accounts: Unread', description: 'Allow an "All unread" entry in the All accounts section that lists unread mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossStarredViewEnabled: { label: 'All Accounts: Starred', description: 'Allow an "All starred" entry in the All accounts section that lists flagged/starred mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
crossAllViewEnabled: { label: 'All Accounts: All Mail', description: 'Allow an "All mail" entry in the All accounts section that lists all mail across every account (incl. shared folders), spanning all folders except junk, sent, archive, trash and drafts. Requires the matching per-user toggle in Settings → Appearance.' },
|
||||
};
|
||||
|
||||
const RESTRICTABLE_SETTINGS = [
|
||||
|
||||
Reference in New Issue
Block a user