feat: configurable message-list ordering #718

This commit is contained in:
Linus Rath
2026-08-25 19:20:48 +02:00
parent 9f32fc879b
commit 3ef2b45fb4
42 changed files with 2697 additions and 55 deletions
+1
View File
@@ -22,6 +22,7 @@
- Each tag can be configured to show always, only when there are unread mails or always be hidden
- Tag names and colors live in the browser, while the tags themselves ride on your messages as JMAP keywords. A scan finds keywords no local tag explains and adds them back.
- Star or unstar, with a configurable mark-as-read delay
- Configurable message-list order: presets (unread first, starred first, tagged first) or up to three custom sort levels, applied server-side through the JMAP sort so the whole folder is ordered, for the Inbox only or every folder
- Large mailboxes scroll virtually, and the first page of mail prefetches at login
- A refresh button in the list toolbar that spins while the fetch is in flight, alongside the F5 / Ctrl+R / pull-to-refresh gestures it shares a code path with
- Quick reply, hover actions, favicon-based sender avatars, recipient popovers
+8 -2
View File
@@ -126,11 +126,17 @@ export function EmailList({
?? (isUnifiedView ? (unifiedRole ?? undefined) : undefined);
const disableThreading = useSettingsStore((state) => state.disableThreading);
// The order the current folder view was fetched in (#718), so thread
// grouping mirrors the server order instead of re-sorting the page by date.
// Search results and cross-account views are always chronological.
const fetchedListOrder = useEmailStore((state) => state.listOrder);
const crossView = useEmailStore((state) => state.crossView);
const threadGroups = useMemo(() => {
const listOrder = searchQuery || crossView || !isFilterEmpty(searchFilters) ? [] : fetchedListOrder;
const groups = groupEmailsByThread(emails, disableThreading || isScheduledView, threadEmailCounts);
return sortThreadGroups(groups);
}, [emails, disableThreading, isScheduledView, threadEmailCounts]);
return sortThreadGroups(groups, listOrder);
}, [emails, disableThreading, isScheduledView, threadEmailCounts, fetchedListOrder, searchQuery, crossView, searchFilters]);
const { contextMenu, openContextMenu, closeContextMenu, menuRef } = useContextMenu<Email>();
/**
+6 -3
View File
@@ -9,7 +9,7 @@ import { EmailViewer } from "@/components/email/email-viewer";
import { ProEmailView } from "@/components/pro/pro-email-tab-body";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { getMessageListOrderFor, useSettingsStore } from "@/stores/settings-store";
import { useProTabStore, type ProFolderTabData } from "@/stores/pro-tab-store";
import { useDeviceDetection } from "@/hooks/use-media-query";
import { usePaneId } from "@/hooks/use-pane-context";
@@ -78,12 +78,15 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [folderLabel, tabId, updateTabTitle]);
// The configured list order (#718) depends on the folder's role (Inbox-only
// by default), and every page of the tab must ask for the same order.
const mailboxRole = mailbox?.role;
const loadPage = useCallback(async (position: number) => {
if (!client) return;
const seq = ++fetchSeqRef.current;
if (position === 0) setIsLoading(true); else setIsLoadingMore(true);
// getEmails never throws - it reports failures as an empty page.
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true);
const result = await client.getEmails(jmapMailboxId, jmapAccountId, emailsPerPage, position, undefined, true, undefined, getMessageListOrderFor(mailboxRole));
if (seq !== fetchSeqRef.current) return;
setEmails((prev) => {
if (position === 0) return result.emails;
@@ -93,7 +96,7 @@ export function ProFolderTabBody({ tabId, data }: ProFolderTabBodyProps) {
setTotal(result.total);
setHasMore(result.hasMore);
if (position === 0) setIsLoading(false); else setIsLoadingMore(false);
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage]);
}, [client, jmapMailboxId, jmapAccountId, emailsPerPage, mailboxRole]);
useEffect(() => {
setSelectedEmailId(null);
@@ -0,0 +1,124 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageListOrderSettings } from '../message-list-order-settings';
import { useSettingsStore, DEFAULT_KEYWORDS } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// Settings → Appearance → "Message list order" (#718): a preset dropdown over
// the same level list the advanced editor exposes, persisted through the
// synced settings store.
describe('MessageListOrderSettings', () => {
beforeEach(() => {
useSettingsStore.setState({
messageListOrder: [],
messageListOrderScope: 'inbox',
emailKeywords: [...DEFAULT_KEYWORDS],
});
useAuthStore.setState({ client: null });
});
const presetSelect = () => screen.getAllByRole('combobox')[0] as HTMLSelectElement;
it('shows the chronological preset by default and keeps the editor closed', () => {
render(<MessageListOrderSettings />);
expect(presetSelect().value).toBe('chronological');
expect(screen.queryByTestId('message-list-order-editor')).toBeNull();
// No "custom" entry unless the order actually is custom.
expect(screen.queryByText('preset.custom')).toBeNull();
});
it('applies a simple preset as a single sort level', () => {
render(<MessageListOrderSettings />);
fireEvent.change(presetSelect(), { target: { value: 'unread_first' } });
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]);
expect(presetSelect().value).toBe('unread_first');
fireEvent.change(presetSelect(), { target: { value: 'chronological' } });
expect(useSettingsStore.getState().messageListOrder).toEqual([]);
});
it('uses the first tag for "tagged first" and lets the tag be changed', () => {
render(<MessageListOrderSettings />);
fireEvent.change(presetSelect(), { target: { value: 'tagged_first' } });
expect(useSettingsStore.getState().messageListOrder).toEqual([
{ criterion: 'tag', direction: 'desc', tagId: DEFAULT_KEYWORDS[0].id },
]);
const tagSelect = screen.getByLabelText('tag_label') as HTMLSelectElement;
fireEvent.change(tagSelect, { target: { value: DEFAULT_KEYWORDS[1].id } });
expect(useSettingsStore.getState().messageListOrder[0].tagId).toBe(DEFAULT_KEYWORDS[1].id);
});
it('opens the advanced editor on a custom order and reports it as custom', () => {
useSettingsStore.setState({
messageListOrder: [
{ criterion: 'unread', direction: 'desc' },
{ criterion: 'starred', direction: 'desc' },
],
});
render(<MessageListOrderSettings />);
expect(presetSelect().value).toBe('custom');
expect(screen.getByTestId('message-list-order-editor')).toBeInTheDocument();
expect(screen.getByTestId('message-list-order-level-0')).toBeInTheDocument();
expect(screen.getByTestId('message-list-order-level-1')).toBeInTheDocument();
});
it('adds, edits, reorders and removes levels in the editor', () => {
render(<MessageListOrderSettings />);
fireEvent.click(screen.getByTestId('message-list-order-advanced-toggle'));
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'unread', direction: 'desc' }]);
// Switching the criterion resets the direction to that criterion's default.
fireEvent.change(screen.getByLabelText('criterion_label'), { target: { value: 'from' } });
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'asc' }]);
fireEvent.change(screen.getByLabelText('direction_label'), { target: { value: 'desc' } });
expect(useSettingsStore.getState().messageListOrder).toEqual([{ criterion: 'from', direction: 'desc' }]);
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
fireEvent.click(screen.getByTestId('message-list-order-add-level'));
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'unread', 'starred']);
// Capped at three levels.
expect(screen.queryByTestId('message-list-order-add-level')).toBeNull();
fireEvent.click(screen.getAllByLabelText('move_up')[2]);
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['from', 'starred', 'unread']);
fireEvent.click(screen.getAllByLabelText('remove_level')[0]);
expect(useSettingsStore.getState().messageListOrder.map(l => l.criterion)).toEqual(['starred', 'unread']);
});
it('toggles the all-folders scope', () => {
render(<MessageListOrderSettings />);
fireEvent.click(screen.getByTestId('message-list-order-scope'));
expect(useSettingsStore.getState().messageListOrderScope).toBe('all');
fireEvent.click(screen.getByTestId('message-list-order-scope'));
expect(useSettingsStore.getState().messageListOrderScope).toBe('inbox');
});
it('warns when the server advertises sort options without hasKeyword', () => {
useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] });
useAuthStore.setState({
client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'size']) } as unknown as IJMAPClient,
});
render(<MessageListOrderSettings />);
expect(screen.getByText('unsupported_note')).toBeInTheDocument();
});
it('stays quiet when the server supports keyword sorting or does not say', () => {
useSettingsStore.setState({ messageListOrder: [{ criterion: 'unread', direction: 'desc' }] });
useAuthStore.setState({
client: { getEmailQuerySortOptions: vi.fn(() => ['receivedAt', 'hasKeyword']) } as unknown as IJMAPClient,
});
const { unmount } = render(<MessageListOrderSettings />);
expect(screen.queryByText('unsupported_note')).toBeNull();
unmount();
useAuthStore.setState({ client: { getEmailQuerySortOptions: vi.fn(() => null) } as unknown as IJMAPClient });
render(<MessageListOrderSettings />);
expect(screen.queryByText('unsupported_note')).toBeNull();
});
});
@@ -9,6 +9,7 @@ import { useTour } from '@/components/tour/tour-provider';
import { Button } from '@/components/ui/button';
import { PlayCircle } from 'lucide-react';
import { usePolicyStore } from '@/stores/policy-store';
import { MessageListOrderSettings } from './message-list-order-settings';
const DENSITY_PREVIEW: Record<Density, { py: string; gap: string; showAvatar: boolean; showPreview: boolean }> = {
'extra-compact': { py: 'py-0.5', gap: 'gap-1.5', showAvatar: false, showPreview: false },
@@ -117,6 +118,8 @@ export function AppearanceSettings() {
</SettingItem>
)}
<MessageListOrderSettings />
{!isSettingHidden('animationsEnabled') && (
<SettingItem label={t('animations.label')} description={t('animations.description')} locked={isSettingLocked('animationsEnabled')}>
<ToggleSwitch
@@ -0,0 +1,286 @@
"use client";
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { ArrowDown, ArrowUp, GripVertical, Plus, X } from 'lucide-react';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore } from '@/stores/policy-store';
import { SettingItem, Select, ToggleSwitch } from './settings-section';
import { cn } from '@/lib/utils';
import {
MAX_SORT_LEVELS,
ORDER_PRESETS,
SORT_CRITERIA,
defaultDirection,
detectPreset,
isKeywordCriterion,
presetLevels,
sanitizeSortLevels,
type OrderPreset,
type SortCriterion,
type SortDirection,
type SortLevel,
} from '@/lib/message-list-order';
/** Semantic direction labels per criterion: [desc, asc] translation keys. */
const DIRECTION_LABELS: Record<SortCriterion, [string, string]> = {
unread: ['unread_first', 'read_first'],
starred: ['starred_first', 'unstarred_first'],
tag: ['tagged_first', 'untagged_first'],
receivedAt: ['newest_first', 'oldest_first'],
sentAt: ['newest_first', 'oldest_first'],
from: ['z_to_a', 'a_to_z'],
subject: ['z_to_a', 'a_to_z'],
size: ['largest_first', 'smallest_first'],
};
/**
* Re-fetches the current folder so a changed order shows up without leaving
* settings and coming back (the Pro interface keeps the mail tab mounted).
*/
function refetchCurrentList() {
const client = useAuthStore.getState().client;
const store = useEmailStore.getState();
if (client && store.selectedMailbox) {
void store.fetchEmails(client, store.selectedMailbox, { background: true });
}
}
/**
* Settings → Appearance → "Message list order" (#718). A preset dropdown for
* the common cases and an advanced editor for up to MAX_SORT_LEVELS ordered
* {criterion, direction} levels, mapped onto the JMAP Email/query sort.
*/
export function MessageListOrderSettings() {
const t = useTranslations('settings.appearance.message_list_order');
const { messageListOrder, messageListOrderScope, emailKeywords, updateSetting } = useSettingsStore();
const { isSettingLocked, isSettingHidden } = usePolicyStore();
const client = useAuthStore((s) => s.client);
const preset = detectPreset(messageListOrder);
const [advanced, setAdvanced] = useState(preset === 'custom');
const [dragIndex, setDragIndex] = useState<number | null>(null);
// A server that lists its sort options without hasKeyword cannot order by
// read state, stars or tags; say so rather than silently showing a
// chronological list the user believes is prioritised.
const sortOptions = client?.getEmailQuerySortOptions?.() ?? null;
const keywordSortUnsupported = sortOptions !== null && !sortOptions.includes('hasKeyword');
if (isSettingHidden('messageListOrder')) return null;
const locked = isSettingLocked('messageListOrder');
const apply = (next: SortLevel[]) => {
updateSetting('messageListOrder', sanitizeSortLevels(next));
refetchCurrentList();
};
const firstTagId = emailKeywords[0]?.id;
const presetTagId = messageListOrder.find((l) => l.criterion === 'tag')?.tagId ?? firstTagId;
const onPresetChange = (value: string) => {
const next = value as OrderPreset;
if (next === 'custom') return;
apply(presetLevels(next, presetTagId));
};
const updateLevel = (index: number, patch: Partial<SortLevel>) => {
apply(messageListOrder.map((level, i) => {
if (i !== index) return level;
const merged = { ...level, ...patch };
if (patch.criterion && patch.criterion !== level.criterion) {
merged.direction = defaultDirection(patch.criterion);
if (patch.criterion === 'tag') merged.tagId = merged.tagId ?? firstTagId;
else delete merged.tagId;
}
return merged;
}));
};
const moveLevel = (from: number, to: number) => {
if (to < 0 || to >= messageListOrder.length || from === to) return;
const next = [...messageListOrder];
const [moved] = next.splice(from, 1);
next.splice(to, 0, moved);
apply(next);
};
const removeLevel = (index: number) => apply(messageListOrder.filter((_, i) => i !== index));
const usedCriteria = new Set(messageListOrder.map((l) => l.criterion));
const addLevel = () => {
if (messageListOrder.length >= MAX_SORT_LEVELS) return;
const criterion = SORT_CRITERIA.find((c) => !usedCriteria.has(c) && (c !== 'tag' || firstTagId));
if (!criterion) return;
const level: SortLevel = { criterion, direction: defaultDirection(criterion) };
if (criterion === 'tag') level.tagId = firstTagId;
apply([...messageListOrder, level]);
};
const presetOptions = [
...ORDER_PRESETS
.filter((p) => p !== 'tagged_first' || emailKeywords.length > 0)
.map((p) => ({ value: p, label: t(`preset.${p}`) })),
...(preset === 'custom' ? [{ value: 'custom', label: t('preset.custom') }] : []),
];
const tagOptions = emailKeywords.map((k) => ({ value: k.id, label: k.label }));
const criterionOptions = (current: SortCriterion) =>
SORT_CRITERIA
.filter((c) => c === current || (!usedCriteria.has(c) && (c !== 'tag' || emailKeywords.length > 0)))
.map((c) => ({ value: c, label: t(`criteria.${c}`) }));
const directionOptions = (criterion: SortCriterion) => {
const [desc, asc] = DIRECTION_LABELS[criterion];
return [
{ value: 'desc', label: t(`direction.${desc}`) },
{ value: 'asc', label: t(`direction.${asc}`) },
];
};
return (
<>
<SettingItem label={t('label')} description={t('description')} locked={locked}>
<div className="flex flex-col items-start sm:items-end gap-2">
<Select value={preset} onChange={onPresetChange} options={presetOptions} disabled={locked} />
{preset === 'tagged_first' && !advanced && tagOptions.length > 0 && (
<Select
value={presetTagId ?? ''}
onChange={(tagId) => apply(presetLevels('tagged_first', tagId))}
options={tagOptions}
disabled={locked}
ariaLabel={t('tag_label')}
/>
)}
<button
type="button"
onClick={() => setAdvanced((v) => !v)}
className="text-xs text-primary hover:underline"
aria-expanded={advanced}
data-testid="message-list-order-advanced-toggle"
>
{advanced ? t('advanced_hide') : t('advanced_show')}
</button>
</div>
</SettingItem>
{advanced && (
<div className="py-3 border-b border-border" data-testid="message-list-order-editor">
<ol className="flex flex-col gap-2">
{messageListOrder.map((level, index) => (
<li
key={`${level.criterion}-${level.tagId ?? ''}-${index}`}
draggable={!locked}
onDragStart={(e) => { setDragIndex(index); e.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
onDrop={(e) => { e.preventDefault(); if (dragIndex !== null) moveLevel(dragIndex, index); setDragIndex(null); }}
onDragEnd={() => setDragIndex(null)}
className={cn(
'flex flex-wrap items-center gap-2 rounded-md border border-border bg-muted/40 px-2 py-1.5',
dragIndex === index && 'opacity-50',
)}
data-testid={`message-list-order-level-${index}`}
>
<span className="flex items-center gap-1 text-xs text-muted-foreground w-10 flex-shrink-0">
<GripVertical className="w-4 h-4 cursor-grab" aria-hidden="true" />
{index + 1}.
</span>
<Select
value={level.criterion}
onChange={(criterion) => updateLevel(index, { criterion: criterion as SortCriterion })}
options={criterionOptions(level.criterion)}
disabled={locked}
ariaLabel={t('criterion_label', { n: index + 1 })}
/>
{level.criterion === 'tag' && (
<Select
value={level.tagId ?? ''}
onChange={(tagId) => updateLevel(index, { tagId })}
options={tagOptions}
disabled={locked}
ariaLabel={t('tag_label')}
/>
)}
<Select
value={level.direction}
onChange={(direction) => updateLevel(index, { direction: direction as SortDirection })}
options={directionOptions(level.criterion)}
disabled={locked}
ariaLabel={t('direction_label', { n: index + 1 })}
/>
<span className="flex items-center gap-0.5 ms-auto">
<button
type="button"
onClick={() => moveLevel(index, index - 1)}
disabled={locked || index === 0}
className="p-1 rounded hover:bg-muted disabled:opacity-40"
aria-label={t('move_up')}
title={t('move_up')}
>
<ArrowUp className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => moveLevel(index, index + 1)}
disabled={locked || index === messageListOrder.length - 1}
className="p-1 rounded hover:bg-muted disabled:opacity-40"
aria-label={t('move_down')}
title={t('move_down')}
>
<ArrowDown className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => removeLevel(index)}
disabled={locked}
className="p-1 rounded hover:bg-muted disabled:opacity-40"
aria-label={t('remove_level')}
title={t('remove_level')}
>
<X className="w-3.5 h-3.5" />
</button>
</span>
</li>
))}
</ol>
<div className="flex flex-wrap items-center gap-3 mt-2">
{messageListOrder.length < MAX_SORT_LEVELS && (
<button
type="button"
onClick={addLevel}
disabled={locked}
className="inline-flex items-center gap-1 text-xs text-primary hover:underline disabled:opacity-40"
data-testid="message-list-order-add-level"
>
<Plus className="w-3.5 h-3.5" />
{t('add_level')}
</button>
)}
<span className="text-xs text-muted-foreground">{t('tiebreak_note')}</span>
</div>
</div>
)}
{keywordSortUnsupported && messageListOrder.some((l) => isKeywordCriterion(l.criterion)) && (
<p className="py-2 text-xs text-amber-600 dark:text-amber-400" role="status">
{t('unsupported_note')}
</p>
)}
<SettingItem label={t('scope.label')} description={t('scope.description')} locked={locked}>
<ToggleSwitch
checked={messageListOrderScope === 'all'}
onChange={(checked) => {
updateSetting('messageListOrderScope', checked ? 'all' : 'inbox');
refetchCurrentList();
}}
disabled={locked}
testId="message-list-order-scope"
/>
</SettingItem>
</>
);
}
+194
View File
@@ -0,0 +1,194 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
import type { SortLevel } from '../message-list-order';
// Configurable message-list ordering (#718) maps onto Email/query sort
// comparators. Stalwart 0.16.8 reads `isAscending` on hasKeyword the other
// way round from RFC 8621, so the client probes the server once per account
// and flips the keyword comparators accordingly - instead of showing users the
// inverse of what they picked.
type MethodCall = [string, Record<string, unknown>, string];
const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }];
function makeSession(mailCaps: Record<string, unknown> = {}) {
return {
capabilities: { 'urn:ietf:params:jmap:core': {} },
accounts: { 'acct-1': { name: 'test', isPersonal: true, accountCapabilities: { 'urn:ietf:params:jmap:mail': mailCaps } } },
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const seenEmail = { id: 'read-1', keywords: { $seen: true }, receivedAt: '2026-01-02T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
const unseenEmail = { id: 'unread-1', keywords: {}, receivedAt: '2026-01-01T00:00:00Z', mailboxIds: {}, threadId: 't', size: 1, hasAttachment: false };
/**
* Answers the polarity probe (call ids asc/desc/get) per `server`, and every
* folder query (call ids 0/1) with a fixed page, optionally refusing keyword
* comparators with unsupportedSort.
*/
function replyFor(server: 'rfc' | 'inverted' | 'homogeneous', opts: { refuseKeywordSort?: boolean } = {}) {
return (methodCalls: MethodCall[]) => {
if (methodCalls[0][2] === 'asc') {
const ascFirst = server === 'inverted' ? seenEmail : unseenEmail;
const descFirst = server === 'homogeneous' ? ascFirst : (server === 'inverted' ? unseenEmail : seenEmail);
return {
methodResponses: [
['Email/query', { ids: [ascFirst.id] }, 'asc'],
['Email/query', { ids: [descFirst.id] }, 'desc'],
['Email/get', { list: [{ id: ascFirst.id, keywords: ascFirst.keywords }] }, 'get'],
],
};
}
const sort = methodCalls[0][1].sort as Array<{ property: string }>;
if (opts.refuseKeywordSort && sort.some(s => s.property === 'hasKeyword')) {
return { methodResponses: [['error', { type: 'unsupportedSort' }, '0'], ['error', { type: 'invalidResultReference' }, '1']] };
}
return {
methodResponses: [
['Email/query', { ids: [seenEmail.id, unseenEmail.id], total: 2 }, '0'],
['Email/get', { list: [seenEmail, unseenEmail] }, '1'],
],
};
};
}
describe('JMAPClient list ordering', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
fetchSpy.mockRestore();
vi.restoreAllMocks();
});
async function connectedClient(mailCaps?: Record<string, unknown>): Promise<JMAPClient> {
fetchSpy.mockResolvedValueOnce(jsonResponse(makeSession(mailCaps)));
const client = JMAPClient.withBearer('https://mail.example.com', 'token123', 'user@test.com');
await client.connect();
fetchSpy.mockReset();
return client;
}
function recordRequests(reply: (methodCalls: MethodCall[]) => unknown) {
const sent: MethodCall[][] = [];
fetchSpy.mockImplementation((async (_url: string, init: RequestInit) => {
const body = JSON.parse(init.body as string);
sent.push(body.methodCalls);
return jsonResponse(reply(body.methodCalls));
}) as never);
return sent;
}
const folderQueries = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === '0').map(calls => calls[0][1].sort);
const probes = (sent: MethodCall[][]) => sent.filter(calls => calls[0][2] === 'asc');
it('sends the RFC polarity to a server that follows RFC 8621', async () => {
const client = await connectedClient();
const sent = recordRequests(replyFor('rfc'));
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
expect(probes(sent)).toHaveLength(1);
expect(folderQueries(sent)).toEqual([[
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
{ property: 'receivedAt', isAscending: false },
]]);
});
it('flips the keyword comparators for a server with inverted polarity (Stalwart 0.16.8)', async () => {
const client = await connectedClient();
const sent = recordRequests(replyFor('inverted'));
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
// One probe per account and session, not per page.
expect(probes(sent)).toHaveLength(1);
expect(folderQueries(sent)).toEqual([
[
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
{ property: 'receivedAt', isAscending: false },
],
[
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
{ property: 'receivedAt', isAscending: false },
],
]);
// The within-page safety sort mirrors the requested order.
expect(emails.map(e => e.id)).toEqual(['unread-1', 'read-1']);
});
it('falls back to the RFC polarity when every message is in the same read state', async () => {
const client = await connectedClient();
const sent = recordRequests(replyFor('homogeneous'));
await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
expect(folderQueries(sent)[0]).toEqual([
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
{ property: 'receivedAt', isAscending: false },
]);
});
it('skips the probe and the comparators when the order needs no keyword sort', async () => {
const client = await connectedClient();
const sent = recordRequests(replyFor('rfc'));
await client.getEmails('inbox', undefined, 50, 0, undefined, false, undefined, [{ criterion: 'size', direction: 'desc' }]);
expect(probes(sent)).toHaveLength(0);
expect(folderQueries(sent)).toEqual([[
{ property: 'size', isAscending: false },
{ property: 'receivedAt', isAscending: false },
]]);
});
it('omits keyword comparators when the server advertises sort options without hasKeyword', async () => {
const client = await connectedClient({ emailQuerySortOptions: ['receivedAt', 'size'] });
const sent = recordRequests(replyFor('rfc'));
expect(client.getEmailQuerySortOptions()).toEqual(['receivedAt', 'size']);
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
expect(probes(sent)).toHaveLength(0);
expect(folderQueries(sent)).toEqual([[{ property: 'receivedAt', isAscending: false }]]);
expect(emails).toHaveLength(2);
});
it('retries without keyword comparators after an unsupportedSort refusal and remembers it', async () => {
const client = await connectedClient();
const sent = recordRequests(replyFor('rfc', { refuseKeywordSort: true }));
const { emails } = await client.getEmails('inbox', undefined, 50, 0, undefined, true, undefined, UNREAD_FIRST);
await client.getEmails('inbox', undefined, 50, 50, undefined, true, undefined, UNREAD_FIRST);
expect(emails).toHaveLength(2);
expect(folderQueries(sent)).toEqual([
[
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
{ property: 'receivedAt', isAscending: false },
],
[{ property: 'receivedAt', isAscending: false }],
[{ property: 'receivedAt', isAscending: false }],
]);
});
});
+235
View File
@@ -0,0 +1,235 @@
import { describe, it, expect } from 'vitest';
import {
buildEmailSort,
compareEmails,
detectPreset,
hasKeywordLevels,
isDefaultOrder,
keywordFirst,
levelKeyword,
orderForMailbox,
presetLevels,
sanitizeSortLevels,
MAX_SORT_LEVELS,
type SortLevel,
} from '../message-list-order';
import type { Email } from '../jmap/types';
const UNREAD_FIRST: SortLevel[] = [{ criterion: 'unread', direction: 'desc' }];
const STARRED_FIRST: SortLevel[] = [{ criterion: 'starred', direction: 'desc' }];
function makeEmail(overrides: Partial<Email> = {}): Email {
return {
id: overrides.id ?? Math.random().toString(36).slice(2),
threadId: 't',
mailboxIds: {},
keywords: {},
size: 100,
receivedAt: '2026-01-01T00:00:00Z',
hasAttachment: false,
...overrides,
};
}
describe('sanitizeSortLevels', () => {
it('returns an empty list for anything that is not an array', () => {
expect(sanitizeSortLevels(undefined)).toEqual([]);
expect(sanitizeSortLevels('unread')).toEqual([]);
expect(sanitizeSortLevels({ criterion: 'unread' })).toEqual([]);
});
it('drops unknown criteria, defaults a missing direction and caps the list', () => {
const levels = sanitizeSortLevels([
{ criterion: 'unread' },
{ criterion: 'importance', direction: 'desc' },
{ criterion: 'from', direction: 'sideways' },
{ criterion: 'size', direction: 'asc' },
{ criterion: 'subject', direction: 'asc' },
]);
expect(levels).toEqual([
{ criterion: 'unread', direction: 'desc' },
{ criterion: 'from', direction: 'asc' },
{ criterion: 'size', direction: 'asc' },
]);
expect(levels).toHaveLength(MAX_SORT_LEVELS);
});
it('drops a tag level without a tag id and duplicate criteria', () => {
expect(sanitizeSortLevels([
{ criterion: 'tag', direction: 'desc' },
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
{ criterion: 'tag', direction: 'asc', tagId: 'blue' },
{ criterion: 'unread', direction: 'desc' },
{ criterion: 'unread', direction: 'asc' },
])).toEqual([
{ criterion: 'tag', direction: 'desc', tagId: 'red' },
{ criterion: 'tag', direction: 'asc', tagId: 'blue' },
{ criterion: 'unread', direction: 'desc' },
]);
});
});
describe('keyword semantics', () => {
it('maps the boolean criteria onto JMAP keywords', () => {
expect(levelKeyword({ criterion: 'unread', direction: 'desc' })).toBe('$seen');
expect(levelKeyword({ criterion: 'starred', direction: 'desc' })).toBe('$flagged');
expect(levelKeyword({ criterion: 'tag', direction: 'desc', tagId: 'red' })).toBe('$label:red');
expect(levelKeyword({ criterion: 'receivedAt', direction: 'desc' })).toBeNull();
});
it('"unread first" means messages WITHOUT $seen first', () => {
expect(keywordFirst({ criterion: 'unread', direction: 'desc' })).toBe(false);
expect(keywordFirst({ criterion: 'unread', direction: 'asc' })).toBe(true);
expect(keywordFirst({ criterion: 'starred', direction: 'desc' })).toBe(true);
expect(keywordFirst({ criterion: 'tag', direction: 'desc', tagId: 'x' })).toBe(true);
});
it('knows when a keyword comparator is needed', () => {
expect(hasKeywordLevels([])).toBe(false);
expect(hasKeywordLevels([], true)).toBe(true);
expect(hasKeywordLevels([{ criterion: 'size', direction: 'desc' }])).toBe(false);
expect(hasKeywordLevels(UNREAD_FIRST)).toBe(true);
});
});
describe('buildEmailSort', () => {
it('is plain newest-first with no levels', () => {
expect(buildEmailSort([])).toEqual([{ property: 'receivedAt', isAscending: false }]);
});
it('follows the RFC 8621 polarity by default: has-keyword first is descending', () => {
// RFC example: {someInThreadHaveKeyword, $flagged, isAscending: false} sorts flagged first.
expect(buildEmailSort(STARRED_FIRST)).toEqual([
{ property: 'hasKeyword', keyword: '$flagged', isAscending: false },
{ property: 'receivedAt', isAscending: false },
]);
// Unread first = $seen absent first = $seen ascending.
expect(buildEmailSort(UNREAD_FIRST)).toEqual([
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
{ property: 'receivedAt', isAscending: false },
]);
});
it('flips only the keyword comparators for an inverted server (Stalwart 0.16.8)', () => {
expect(buildEmailSort(UNREAD_FIRST, { polarity: 'inverted' })).toEqual([
{ property: 'hasKeyword', keyword: '$seen', isAscending: false },
{ property: 'receivedAt', isAscending: false },
]);
expect(buildEmailSort([{ criterion: 'size', direction: 'asc' }], { polarity: 'inverted' })).toEqual([
{ property: 'size', isAscending: true },
{ property: 'receivedAt', isAscending: false },
]);
});
it('keeps pinned-first ahead of the configured order', () => {
expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true })).toEqual([
{ property: 'hasKeyword', keyword: '$pinned', isAscending: false },
{ property: 'hasKeyword', keyword: '$seen', isAscending: true },
{ property: 'receivedAt', isAscending: false },
]);
expect(buildEmailSort([], { pinnedFirst: true, polarity: 'inverted' })[0]).toEqual(
{ property: 'hasKeyword', keyword: '$pinned', isAscending: true },
);
});
it('drops keyword comparators when the server cannot sort on them', () => {
expect(buildEmailSort(UNREAD_FIRST, { pinnedFirst: true, keywordSortSupported: false })).toEqual([
{ property: 'receivedAt', isAscending: false },
]);
});
it('does not add a second receivedAt comparator when the user ordered by date', () => {
expect(buildEmailSort([
{ criterion: 'from', direction: 'asc' },
{ criterion: 'receivedAt', direction: 'asc' },
])).toEqual([
{ property: 'from', isAscending: true },
{ property: 'receivedAt', isAscending: true },
]);
});
});
describe('compareEmails', () => {
const unreadOld = makeEmail({ id: 'unread-old', receivedAt: '2026-01-01T00:00:00Z' });
const unreadNew = makeEmail({ id: 'unread-new', receivedAt: '2026-01-03T00:00:00Z' });
const readNewest = makeEmail({ id: 'read-newest', receivedAt: '2026-01-05T00:00:00Z', keywords: { $seen: true } });
const readMid = makeEmail({ id: 'read-mid', receivedAt: '2026-01-02T00:00:00Z', keywords: { $seen: true } });
it('is newest-first with no levels', () => {
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([]));
expect(sorted.map(e => e.id)).toEqual(['read-newest', 'unread-new', 'read-mid', 'unread-old']);
});
it('puts unread first, each group newest first', () => {
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails(UNREAD_FIRST));
expect(sorted.map(e => e.id)).toEqual(['unread-new', 'unread-old', 'read-newest', 'read-mid']);
});
it('honours "read first"', () => {
const sorted = [unreadOld, readNewest, unreadNew, readMid].sort(compareEmails([{ criterion: 'unread', direction: 'asc' }]));
expect(sorted.map(e => e.id)).toEqual(['read-newest', 'read-mid', 'unread-new', 'unread-old']);
});
it('keeps pinned mail on top of any order', () => {
const pinnedRead = makeEmail({ id: 'pinned', receivedAt: '2025-01-01T00:00:00Z', keywords: { $seen: true, $pinned: true } });
const sorted = [unreadNew, pinnedRead, readNewest].sort(compareEmails(UNREAD_FIRST, { pinnedFirst: true }));
expect(sorted.map(e => e.id)).toEqual(['pinned', 'unread-new', 'read-newest']);
});
it('sorts by starred, tag, sender, subject and size', () => {
const a = makeEmail({ id: 'a', size: 10, from: [{ name: 'Zoe', email: 'z@x' }], subject: 'Re: beta', keywords: { '$label:red': true } });
const b = makeEmail({ id: 'b', size: 30, from: [{ name: '', email: 'alpha@x' }], subject: 'Fwd: alpha', keywords: { $flagged: true } });
const c = makeEmail({ id: 'c', size: 20, from: [{ name: 'Mia', email: 'm@x' }], subject: 'gamma' });
expect([a, b, c].sort(compareEmails(STARRED_FIRST)).map(e => e.id)).toEqual(['b', 'a', 'c']);
expect([a, b, c].sort(compareEmails([{ criterion: 'tag', direction: 'desc', tagId: 'red' }])).map(e => e.id)).toEqual(['a', 'b', 'c']);
expect([a, b, c].sort(compareEmails([{ criterion: 'from', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']);
expect([a, b, c].sort(compareEmails([{ criterion: 'subject', direction: 'asc' }])).map(e => e.id)).toEqual(['b', 'a', 'c']);
expect([a, b, c].sort(compareEmails([{ criterion: 'size', direction: 'desc' }])).map(e => e.id)).toEqual(['b', 'c', 'a']);
});
it('applies levels in priority order', () => {
const starredOld = makeEmail({ id: 'starred-old', receivedAt: '2026-01-01T00:00:00Z', keywords: { $flagged: true, $seen: true } });
const sorted = [readNewest, starredOld, unreadNew].sort(compareEmails([
{ criterion: 'unread', direction: 'desc' },
{ criterion: 'starred', direction: 'desc' },
]));
expect(sorted.map(e => e.id)).toEqual(['unread-new', 'starred-old', 'read-newest']);
});
});
describe('orderForMailbox', () => {
it('applies to the Inbox only by default', () => {
expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'inbox')).toEqual(UNREAD_FIRST);
expect(orderForMailbox(UNREAD_FIRST, 'inbox', 'archive')).toEqual([]);
expect(orderForMailbox(UNREAD_FIRST, 'inbox', null)).toEqual([]);
});
it('applies everywhere under the all-folders scope', () => {
expect(orderForMailbox(UNREAD_FIRST, 'all', 'archive')).toEqual(UNREAD_FIRST);
expect(orderForMailbox(UNREAD_FIRST, 'all', undefined)).toEqual(UNREAD_FIRST);
expect(orderForMailbox([], 'all', 'inbox')).toEqual([]);
});
});
describe('presets', () => {
it('round-trips the simple presets', () => {
expect(detectPreset(presetLevels('chronological'))).toBe('chronological');
expect(detectPreset(presetLevels('unread_first'))).toBe('unread_first');
expect(detectPreset(presetLevels('starred_first'))).toBe('starred_first');
expect(detectPreset(presetLevels('tagged_first', 'red'))).toBe('tagged_first');
expect(presetLevels('tagged_first')).toEqual([]);
});
it('treats an explicit trailing newest-first level as part of the preset', () => {
expect(detectPreset([...UNREAD_FIRST, { criterion: 'receivedAt', direction: 'desc' }])).toBe('unread_first');
expect(isDefaultOrder([{ criterion: 'receivedAt', direction: 'desc' }])).toBe(true);
});
it('reports anything else as custom', () => {
expect(detectPreset([{ criterion: 'unread', direction: 'asc' }])).toBe('custom');
expect(detectPreset([...UNREAD_FIRST, ...STARRED_FIRST])).toBe('custom');
expect(detectPreset([{ criterion: 'size', direction: 'desc' }])).toBe('custom');
});
});
+30
View File
@@ -152,6 +152,36 @@ describe('sortThreadGroups', () => {
expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']);
});
it('mirrors a configured list order instead of re-sorting by date (#718)', () => {
// makeEmail defaults to $seen, so the unread group needs bare keywords.
const unreadOld = { ...makeGroup('unread-old', '2024-01-01T00:00:00Z'), hasUnread: true };
unreadOld.emails = [makeEmail({ id: 'u', receivedAt: '2024-01-01T00:00:00Z', keywords: {} })];
const readNew = makeGroup('read-new', '2024-06-01T00:00:00Z');
const sorted = sortThreadGroups([readNew, unreadOld], [{ criterion: 'unread', direction: 'desc' }]);
expect(sorted.map(g => g.threadId)).toEqual(['unread-old', 'read-new']);
});
it('places a thread where its best-ranked email sorts, not its latest', () => {
// An old unread reply keeps the thread in the unread block even though
// the newest email in it has been read.
const mixed = makeGroup('mixed', '2024-06-01T00:00:00Z');
mixed.emails = [
makeEmail({ id: 'newest-read', receivedAt: '2024-06-01T00:00:00Z', keywords: { $seen: true } }),
makeEmail({ id: 'old-unread', receivedAt: '2024-02-01T00:00:00Z', keywords: {} }),
];
const readNewer = makeGroup('read-newer', '2024-07-01T00:00:00Z');
const sorted = sortThreadGroups([readNewer, mixed], [{ criterion: 'unread', direction: 'desc' }]);
expect(sorted.map(g => g.threadId)).toEqual(['mixed', 'read-newer']);
});
it('keeps pinned threads on top of a configured order', () => {
const pinnedRead = makeGroup('pinned-read', '2024-01-01T00:00:00Z', true);
const unread = makeGroup('unread', '2024-06-01T00:00:00Z');
unread.emails = [makeEmail({ id: 'u', receivedAt: '2024-06-01T00:00:00Z', keywords: {} })];
const sorted = sortThreadGroups([unread, pinnedRead], [{ criterion: 'unread', direction: 'desc' }]);
expect(sorted.map(g => g.threadId)).toEqual(['pinned-read', 'unread']);
});
it('detects hasPinned from the $pinned keyword', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
+10 -6
View File
@@ -3,6 +3,7 @@ import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, Emai
import type { SieveScript, SieveCapabilities } from '@/lib/jmap/sieve-types';
import { getDemoData, type DemoData } from './demo-data';
import { generateDemoId } from './demo-utils';
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
/**
* In-memory JMAP client for demo mode.
@@ -178,7 +179,7 @@ export class DemoJMAPClient implements IJMAPClient {
return true;
}
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
let filtered = this.data.emails;
if (mailboxId) {
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
@@ -189,11 +190,9 @@ export class DemoJMAPClient implements IJMAPClient {
if (extraFilter) {
filtered = filtered.filter(e => this.matchesFilter(e, extraFilter));
}
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
filtered.sort((a, b) =>
pinRank(b) - pinRank(a) ||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
// Same order the JMAP client asks the server for (pinned first, then the
// configured list order, then newest first).
filtered.sort(compareEmails(order, { pinnedFirst }));
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
@@ -212,6 +211,11 @@ export class DemoJMAPClient implements IJMAPClient {
return filtered;
}
getEmailQuerySortOptions(_accountId?: string): string[] | null {
// The demo sorts client-side and supports every criterion.
return null;
}
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
}
+11 -1
View File
@@ -1,5 +1,6 @@
import type { Email, Mailbox, StateChange, AccountStates, Thread, Identity, EmailAddress, ContactCard, AddressBook, AddressBookRights, VacationResponse, Calendar, CreateCalendarOptions, CalendarRights, CalendarEvent, CalendarEventFilter, CalendarTask, FileNode, FileNodeRights, Principal, PushSubscription, ScheduledEmail, SendEmailResult, SharedAccount } from "./types";
import type { SieveScript, SieveCapabilities } from "./sieve-types";
import type { SortLevel } from "@/lib/message-list-order";
/** What `migrateKeyword` managed to do. */
export interface KeywordMigration {
@@ -112,7 +113,16 @@ export interface IJMAPClient {
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
// `extraFilter` is an arbitrary JMAP FilterCondition/FilterOperator ANDed
// into the view - used by the message-list category tabs (search-based).
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
// `order` is the user's configured message-list order (#718), applied
// server-side after pinned-first; see lib/message-list-order.ts.
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order?: SortLevel[]): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
/**
* Sort properties the account's server advertises for Email/query
* (`emailQuerySortOptions` in the mail capability, RFC 8621 §1.3), or null
* when the server does not say. Used to grey out keyword-based ordering
* criteria the server cannot honour.
*/
getEmailQuerySortOptions?(accountId?: string): string[] | null;
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
+135 -17
View File
@@ -13,6 +13,7 @@ import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectio
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { getEffectiveTimeZone } from "@/lib/timezone";
import { buildEmailSort, compareEmails, hasKeywordLevels, type KeywordSortPolarity, type SortLevel } from "@/lib/message-list-order";
// Names of nodes created over WebDAV come back percent-encoded from
// FileNode/get (see decodeFileNodeName / #869). Normalize at the boundary so
@@ -202,6 +203,10 @@ const EMAIL_LIST_PROPERTIES = [
*/
export const DEFAULT_KEYWORD_SCAN_LIMIT = 25000;
// How long an inconclusive keyword-sort polarity probe (#718) is trusted
// before the next folder fetch tries again.
const KEYWORD_SORT_PROBE_RETRY_MS = 5 * 60 * 1000;
// Stalwart's default property list for Calendar/get omits shareWith, isVisible,
// includeInAvailability, and the default-alerts properties. Without an explicit
// `properties` list the share indicator and share dialog can't see existing
@@ -658,6 +663,13 @@ export class JMAPClient implements IJMAPClient {
private rateLimitedUntil: number = 0;
private rateLimitCallback: ((rateLimited: boolean, retryAfterMs: number) => void) | null = null;
private rateLimitTimeout: NodeJS.Timeout | null = null;
// Per-account result of the keyword sort polarity probe (#718). `null` means
// the probe was inconclusive (every message read, or unread) and is retried
// after KEYWORD_SORT_PROBE_RETRY_MS; a determined value lives for the session.
private keywordSortPolarity = new Map<string, { value: KeywordSortPolarity | null; at: number }>();
private keywordSortProbes = new Map<string, Promise<KeywordSortPolarity>>();
// Accounts whose server refused a hasKeyword comparator with unsupportedSort.
private keywordSortUnsupported = new Set<string>();
private lastRateLimitNoticeAt: number = 0;
// Serialises the first calendar/contacts request per account so Stalwart's
// lazy default-calendar/address-book creation can't run twice (#907).
@@ -1326,7 +1338,108 @@ export class JMAPClient implements IJMAPClient {
}
}
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
/**
* Sort properties the server advertises for Email/query on this account
* (RFC 8621 §1.3 `emailQuerySortOptions`), or null when it doesn't say.
*/
getEmailQuerySortOptions(accountId?: string): string[] | null {
const id = accountId || this.accountId;
const caps = this.session?.accounts?.[id]?.accountCapabilities?.["urn:ietf:params:jmap:mail"] as
| { emailQuerySortOptions?: unknown }
| undefined;
const options = caps?.emailQuerySortOptions;
return Array.isArray(options) ? options.filter((o): o is string => typeof o === "string") : null;
}
/**
* Whether keyword comparators (hasKeyword) can be sent to this account's
* server: not when it advertises a sort-option list without them, and not
* after it has refused one with `unsupportedSort`. Unknown counts as yes.
*/
private keywordSortSupported(accountId: string): boolean {
if (this.keywordSortUnsupported.has(accountId)) return false;
const options = this.getEmailQuerySortOptions(accountId);
return options === null || options.includes("hasKeyword");
}
/**
* Determines, once per account and session, which way the server reads
* `isAscending` on hasKeyword comparators (see KeywordSortPolarity in
* lib/message-list-order.ts). Two one-item queries with opposite
* directions: if they return different messages both groups exist and the
* keywords of the ascending winner settle the polarity; if they return the
* same message the mailbox is homogeneous and the probe is inconclusive.
* Any failure falls back to the RFC reading without caching a verdict.
*/
private resolveKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity> {
const cached = this.keywordSortPolarity.get(accountId);
if (cached && (cached.value !== null || Date.now() - cached.at < KEYWORD_SORT_PROBE_RETRY_MS)) {
return Promise.resolve(cached.value ?? "rfc");
}
const pending = this.keywordSortProbes.get(accountId);
if (pending) return pending;
const probe = this.probeKeywordSortPolarity(accountId)
.then((value) => {
this.keywordSortPolarity.set(accountId, { value, at: Date.now() });
return value ?? "rfc";
})
.catch((error) => {
debug.warn("jmap", "Keyword sort polarity probe failed:", error);
this.keywordSortPolarity.set(accountId, { value: null, at: Date.now() });
return "rfc" as const;
})
.finally(() => {
this.keywordSortProbes.delete(accountId);
});
this.keywordSortProbes.set(accountId, probe);
return probe;
}
private async probeKeywordSortPolarity(accountId: string): Promise<KeywordSortPolarity | null> {
const sortFor = (isAscending: boolean) => [
{ property: "hasKeyword", keyword: "$seen", isAscending },
{ property: "receivedAt", isAscending: false },
];
const response = await this.request([
["Email/query", { accountId, sort: sortFor(true), limit: 1 }, "asc"],
["Email/query", { accountId, sort: sortFor(false), limit: 1 }, "desc"],
["Email/get", {
accountId,
"#ids": { resultOf: "asc", name: "Email/query", path: "/ids" },
properties: ["id", "keywords"],
}, "get"],
]);
const [ascMethod, ascResult] = response.methodResponses?.[0] ?? [];
if (ascMethod === "error") {
if ((ascResult as { type?: string })?.type === "unsupportedSort") {
this.keywordSortUnsupported.add(accountId);
return "rfc";
}
throw new Error((ascResult as { description?: string })?.description || "Email/query failed");
}
const ascIds = ((ascResult as { ids?: string[] })?.ids) ?? [];
const descIds = ((response.methodResponses?.[1]?.[1] as { ids?: string[] })?.ids) ?? [];
if (ascIds.length === 0 || descIds.length === 0 || ascIds[0] === descIds[0]) return null;
const first = ((response.methodResponses?.[2]?.[1] as { list?: Email[] })?.list ?? [])[0];
if (!first || first.id !== ascIds[0]) return null;
// Ascending put a $seen message first: the server sorts has-keyword first
// on isAscending: true, the inverse of the RFC's false < true reading.
return first.keywords?.$seen ? "inverted" : "rfc";
}
/** The Email/query sort for a folder view, with the server's polarity applied. */
private async buildListSort(accountId: string, pinnedFirst: boolean, order: SortLevel[]): Promise<{ sort: ReturnType<typeof buildEmailSort>; keywordSortSupported: boolean }> {
if (!hasKeywordLevels(order, pinnedFirst)) {
return { sort: buildEmailSort(order), keywordSortSupported: true };
}
if (!this.keywordSortSupported(accountId)) {
return { sort: buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }), keywordSortSupported: false };
}
const polarity = await this.resolveKeywordSortPolarity(accountId);
return { sort: buildEmailSort(order, { pinnedFirst, polarity }), keywordSortSupported: true };
}
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean, extraFilter?: Record<string, unknown>, order: SortLevel[] = []): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const simple: { inMailbox?: string; hasKeyword?: string } = {};
@@ -1347,16 +1460,12 @@ export class JMAPClient implements IJMAPClient {
],
}
: simple;
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
// every page of a view must use the same sort or pagination tears.
const sort = pinnedFirst
? [
{ property: "hasKeyword", keyword: "$pinned", isAscending: false },
{ property: "receivedAt", isAscending: false },
]
: [{ property: "receivedAt", isAscending: false }];
const response = await this.request([
// Pinned-first and the configured list order (#718) use the hasKeyword
// sort comparator (RFC 8621 §4.4.2); every page of a view must use the
// same sort or pagination tears, so the sort is built from settings the
// same way on the first page, load-more and the push refresh.
const built = await this.buildListSort(targetAccountId, pinnedFirst === true, order);
const query = (sort: ReturnType<typeof buildEmailSort>) => this.request([
["Email/query", {
accountId: targetAccountId,
filter,
@@ -1372,6 +1481,19 @@ export class JMAPClient implements IJMAPClient {
}, "1"],
]);
let response = await query(built.sort);
// A server that advertises nothing but refuses hasKeyword fails the whole
// query. Remember that and retry once without the keyword comparators
// rather than showing an empty folder.
if (
built.keywordSortSupported &&
response.methodResponses?.[0]?.[0] === "error" &&
(response.methodResponses[0][1] as { type?: string })?.type === "unsupportedSort"
) {
this.keywordSortUnsupported.add(targetAccountId);
response = await query(buildEmailSort(order, { pinnedFirst, keywordSortSupported: false }));
}
const queryResponse = response.methodResponses?.[0]?.[1];
const getResponse = response.methodResponses?.[1]?.[1];
@@ -1379,12 +1501,8 @@ export class JMAPClient implements IJMAPClient {
const emails = (getResponse.list || []) as Email[];
// Sort client-side as safety net - some servers may not honour
// the query sort for large mailboxes without additional filters.
// Must mirror the query sort, or it would undo the pinned-first order.
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
emails.sort((a: Email, b: Email) =>
pinRank(b) - pinRank(a) ||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
// Must mirror the query sort, or it would undo the configured order.
emails.sort(compareEmails(order, { pinnedFirst }));
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
+311
View File
@@ -0,0 +1,311 @@
import type { Email } from './jmap/types';
/**
* Configurable message-list ordering (#718).
*
* An ordering is a prioritised list of up to MAX_SORT_LEVELS sort levels. Each
* level is a {criterion, direction} pair; the list maps 1:1 onto the JMAP
* Email/query `sort` array (RFC 8621 §4.4.2), so the server orders the whole
* mailbox and pagination stays consistent. This module owns every piece of
* that mapping so the UI only ever deals in semantic choices ("unread first",
* "newest first") and the isAscending polarity is decided in exactly one place.
*/
export type SortCriterion =
| 'unread'
| 'starred'
| 'tag'
| 'receivedAt'
| 'sentAt'
| 'from'
| 'subject'
| 'size';
/**
* Direction of the criterion's *value*: `desc` puts the higher / true value
* first. For the boolean criteria the value is "is unread" / "is starred" /
* "has the tag", so `desc` means "unread first", "starred first", "tagged
* first". For dates `desc` is newest first; for size largest first; for
* sender / subject `asc` is A→Z.
*/
export type SortDirection = 'asc' | 'desc';
export interface SortLevel {
criterion: SortCriterion;
direction: SortDirection;
/** Only for criterion 'tag': the KeywordDefinition id (JMAP keyword $label:<id>). */
tagId?: string;
}
/** Which folders the configured order applies to. */
export type MessageListOrderScope = 'inbox' | 'all';
/**
* How the server interprets `isAscending` on the keyword comparators.
*
* RFC 8621 §4.4.2: hasKeyword is a boolean value (false < true), so
* `isAscending: false` puts messages that HAVE the keyword first - the RFC's
* own `$flagged` example "would sort Emails in flagged Threads first" with
* isAscending false. Stalwart (verified on 0.16.8, reported upstream) does the
* opposite: `isAscending: true` puts has-keyword first. The client probes the
* server once per account (see JMAPClient.resolveKeywordSortPolarity) and
* feeds the result in here, so a fixed server flips back automatically.
*/
export type KeywordSortPolarity = 'rfc' | 'inverted';
export const MAX_SORT_LEVELS = 3;
export const SORT_CRITERIA: readonly SortCriterion[] = [
'unread',
'starred',
'tag',
'receivedAt',
'sentAt',
'from',
'subject',
'size',
];
const KEYWORD_CRITERIA: ReadonlySet<SortCriterion> = new Set(['unread', 'starred', 'tag']);
export function isKeywordCriterion(criterion: SortCriterion): boolean {
return KEYWORD_CRITERIA.has(criterion);
}
/** The direction a freshly added level gets: the one people mean by default. */
export function defaultDirection(criterion: SortCriterion): SortDirection {
return criterion === 'from' || criterion === 'subject' ? 'asc' : 'desc';
}
export interface JMAPEmailComparator {
property: string;
isAscending: boolean;
keyword?: string;
}
export const DEFAULT_COMPARATOR: JMAPEmailComparator = { property: 'receivedAt', isAscending: false };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Coerces an untrusted value (persisted state, a synced settings blob, a file
* import) into a valid level list: unknown criteria and directions are dropped,
* a tag level without a tag id is dropped, duplicate criteria are dropped (a
* second "unread" level can never change the order), and the list is capped.
*/
export function sanitizeSortLevels(value: unknown): SortLevel[] {
if (!Array.isArray(value)) return [];
const out: SortLevel[] = [];
const seen = new Set<string>();
for (const raw of value) {
if (!isRecord(raw)) continue;
const criterion = raw.criterion;
if (typeof criterion !== 'string' || !SORT_CRITERIA.includes(criterion as SortCriterion)) continue;
const direction: SortDirection = raw.direction === 'asc' || raw.direction === 'desc'
? raw.direction
: defaultDirection(criterion as SortCriterion);
const level: SortLevel = { criterion: criterion as SortCriterion, direction };
if (criterion === 'tag') {
if (typeof raw.tagId !== 'string' || raw.tagId.length === 0) continue;
level.tagId = raw.tagId;
}
const key = criterion === 'tag' ? `tag:${level.tagId}` : criterion;
if (seen.has(key)) continue;
seen.add(key);
out.push(level);
if (out.length >= MAX_SORT_LEVELS) break;
}
return out;
}
/** The JMAP keyword a boolean level sorts on, or null for value criteria. */
export function levelKeyword(level: SortLevel): string | null {
switch (level.criterion) {
case 'unread': return '$seen';
case 'starred': return '$flagged';
case 'tag': return level.tagId ? `$label:${level.tagId}` : null;
default: return null;
}
}
/**
* Whether messages that HAVE the level's keyword should come first. "Unread
* first" is the absence of $seen first, so it inverts relative to the others.
*/
export function keywordFirst(level: SortLevel): boolean {
const wantsTrueFirst = level.direction === 'desc';
return level.criterion === 'unread' ? !wantsTrueFirst : wantsTrueFirst;
}
/** True when the order (or pinned-first) needs a keyword comparator server-side. */
export function hasKeywordLevels(levels: SortLevel[], pinnedFirst = false): boolean {
return pinnedFirst || levels.some(l => levelKeyword(l) !== null);
}
/** A "chronological" order: nothing configured, or only newest-first. */
export function isDefaultOrder(levels: SortLevel[]): boolean {
return levels.length === 0
|| (levels.length === 1 && levels[0].criterion === 'receivedAt' && levels[0].direction === 'desc');
}
/**
* Builds the Email/query `sort` array for a level list.
*
* - `pinnedFirst` prepends the existing $pinned-on-top comparator.
* - `polarity` is the server's keyword comparator polarity (see
* KeywordSortPolarity); defaults to the RFC reading.
* - `keywordSortSupported: false` drops every keyword comparator (the server
* advertises no hasKeyword support or refused it with unsupportedSort); the
* remaining value comparators still apply.
* - A trailing receivedAt-desc tie-breaker is always present so the order is
* total and stable across pages.
*/
export function buildEmailSort(
levels: SortLevel[],
opts: { pinnedFirst?: boolean; polarity?: KeywordSortPolarity; keywordSortSupported?: boolean } = {},
): JMAPEmailComparator[] {
const polarity = opts.polarity ?? 'rfc';
const keywordsOk = opts.keywordSortSupported !== false;
const keywordComparator = (keyword: string, first: boolean): JMAPEmailComparator => ({
property: 'hasKeyword',
keyword,
// RFC: false < true, so has-keyword first is descending. Inverted servers
// read it the other way round.
isAscending: polarity === 'rfc' ? !first : first,
});
const sort: JMAPEmailComparator[] = [];
if (opts.pinnedFirst && keywordsOk) {
sort.push(keywordComparator('$pinned', true));
}
let hasDateLevel = false;
for (const level of levels) {
const keyword = levelKeyword(level);
if (keyword) {
if (keywordsOk) sort.push(keywordComparator(keyword, keywordFirst(level)));
continue;
}
if (level.criterion === 'receivedAt') hasDateLevel = true;
sort.push({ property: level.criterion, isAscending: level.direction === 'asc' });
}
if (!hasDateLevel) sort.push({ ...DEFAULT_COMPARATOR });
return sort;
}
/** RFC 8621 "from"/"to" sort value: the first address's name, else its email. */
function addressSortValue(email: Email): string {
const first = email.from?.[0];
if (!first) return '';
return (first.name || first.email || '').toLowerCase();
}
/** RFC 5256 §2.1 base subject, approximated: strip reply/forward prefixes. */
function baseSubject(subject: string | undefined): string {
return (subject ?? '')
.replace(/^\s*(?:(?:re|fwd?|aw|wg|sv|vs|tr)\s*(?:\[\d+\])?\s*:\s*)+/i, '')
.trim()
.toLowerCase();
}
function time(value: string | undefined): number {
if (!value) return 0;
const t = new Date(value).getTime();
return Number.isNaN(t) ? 0 : t;
}
function levelValue(level: SortLevel, email: Email): number | string {
switch (level.criterion) {
case 'unread': return email.keywords?.$seen ? 0 : 1;
case 'starred': return email.keywords?.$flagged ? 1 : 0;
case 'tag': return level.tagId && email.keywords?.[`$label:${level.tagId}`] ? 1 : 0;
case 'receivedAt': return time(email.receivedAt);
case 'sentAt': return time(email.sentAt || email.receivedAt);
case 'size': return email.size ?? 0;
case 'from': return addressSortValue(email);
case 'subject': return baseSubject(email.subject);
}
}
function compareValues(a: number | string, b: number | string): number {
if (typeof a === 'string' || typeof b === 'string') {
return String(a).localeCompare(String(b));
}
return a - b;
}
/**
* Client-side mirror of `buildEmailSort` for the same level list. Used as the
* within-page safety net (some servers ignore the sort on unfiltered queries),
* for merging per-account pages in the unified view, for the demo client, and
* to keep thread grouping from undoing the server order.
*/
export function compareEmails(
levels: SortLevel[],
opts: { pinnedFirst?: boolean } = {},
): (a: Email, b: Email) => number {
const hasDateLevel = levels.some(l => l.criterion === 'receivedAt');
return (a, b) => {
if (opts.pinnedFirst) {
const pa = a.keywords?.['$pinned'] ? 1 : 0;
const pb = b.keywords?.['$pinned'] ? 1 : 0;
if (pa !== pb) return pb - pa;
}
for (const level of levels) {
const diff = compareValues(levelValue(level, a), levelValue(level, b));
if (diff !== 0) return level.direction === 'asc' ? diff : -diff;
}
if (!hasDateLevel) {
const diff = time(b.receivedAt) - time(a.receivedAt);
if (diff !== 0) return diff;
}
return 0;
};
}
/**
* The order to use for a given folder: the configured levels when they apply
* to every folder, or to the Inbox when scoped to it; chronological otherwise.
*/
export function orderForMailbox(
levels: SortLevel[],
scope: MessageListOrderScope,
role: string | null | undefined,
): SortLevel[] {
if (levels.length === 0) return [];
if (scope === 'all' || role === 'inbox') return levels;
return [];
}
export type OrderPreset = 'chronological' | 'unread_first' | 'starred_first' | 'tagged_first' | 'custom';
export const ORDER_PRESETS: readonly OrderPreset[] = ['chronological', 'unread_first', 'starred_first', 'tagged_first'];
/** Levels for a simple preset; `tagId` is required for `tagged_first`. */
export function presetLevels(preset: OrderPreset, tagId?: string): SortLevel[] {
switch (preset) {
case 'unread_first': return [{ criterion: 'unread', direction: 'desc' }];
case 'starred_first': return [{ criterion: 'starred', direction: 'desc' }];
case 'tagged_first': return tagId ? [{ criterion: 'tag', direction: 'desc', tagId }] : [];
default: return [];
}
}
/** The preset a level list corresponds to, or 'custom' for anything else. */
export function detectPreset(levels: SortLevel[]): OrderPreset {
// A trailing newest-first level is implicit and doesn't make an order custom.
const core = levels.length > 1
&& levels[levels.length - 1].criterion === 'receivedAt'
&& levels[levels.length - 1].direction === 'desc'
? levels.slice(0, -1)
: levels;
if (isDefaultOrder(core)) return 'chronological';
if (core.length !== 1 || core[0].direction !== 'desc') return 'custom';
switch (core[0].criterion) {
case 'unread': return 'unread_first';
case 'starred': return 'starred_first';
case 'tag': return 'tagged_first';
default: return 'custom';
}
}
+16 -4
View File
@@ -1,4 +1,5 @@
import type { Email, ThreadGroup } from "./jmap/types";
import { compareEmails, type SortLevel } from "./message-list-order";
/**
* Groups emails by their threadId and creates ThreadGroup objects for UI display.
@@ -71,15 +72,26 @@ export function groupEmailsByThread(
}
/**
* Sorts thread groups by their latest email's receivedAt date (newest first).
* Sorts thread groups to mirror the order the email list was fetched in.
* Threads containing a pinned email ($pinned keyword) stay on top, mirroring
* the server-side pinned-first sort of the email list.
* the server-side pinned-first sort of the email list. Below that, each thread
* takes the position of whichever of its emails sorts first under `order`
* (RFC 8621 §4.4.3 thread collapsing semantics) - with the default order that
* is the latest email's receivedAt date, newest first.
*/
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
export function sortThreadGroups(groups: ThreadGroup[], order: SortLevel[] = []): ThreadGroup[] {
const compare = compareEmails(order);
const representative = new Map<ThreadGroup, Email>();
for (const group of groups) {
representative.set(
group,
group.emails.reduce((best, email) => (compare(email, best) < 0 ? email : best), group.emails[0] ?? group.latestEmail),
);
}
return [...groups].sort(
(a, b) =>
(b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) ||
new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
compare(representative.get(a)!, representative.get(b)!)
);
}
+10 -12
View File
@@ -1,6 +1,7 @@
import type { Email, Mailbox, UnifiedMailboxRole, CrossView } from '@/lib/jmap/types';
import { CROSS_EXCLUDED_ROLES } from '@/lib/jmap/types';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { compareEmails, type SortLevel } from '@/lib/message-list-order';
export interface UnifiedAccountClient {
// Display reference (avatar color / label). For personal entries this is the
@@ -91,6 +92,7 @@ export async function fetchUnifiedEmails(
role: UnifiedMailboxRole,
limit: number,
position: number,
order: SortLevel[] = [],
): Promise<UnifiedFetchResult> {
const errors = new Map<string, string>();
@@ -108,12 +110,11 @@ export async function fetchUnifiedEmails(
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
try {
const result = await account.client.getEmails(
jmapMailboxId,
jmapAccountId,
limit,
position,
);
// The configured list order (#718) rides along only when set, so the
// default call shape stays the four-argument one.
const result = order.length > 0
? await account.client.getEmails(jmapMailboxId, jmapAccountId, limit, position, undefined, undefined, undefined, order)
: await account.client.getEmails(jmapMailboxId, jmapAccountId, limit, position);
return { account, result };
} catch (err) {
errors.set(
@@ -155,12 +156,9 @@ export async function fetchUnifiedEmails(
}
}
// Sort merged emails by receivedAt descending.
mergedEmails.sort((a, b) => {
const dateA = new Date(a.receivedAt).getTime();
const dateB = new Date(b.receivedAt).getTime();
return dateB - dateA;
});
// Merge the per-account pages under the same order each server applied
// (receivedAt descending by default).
mergedEmails.sort(compareEmails(order));
return {
emails: mergedEmails,
+50
View File
@@ -924,6 +924,56 @@
"advanced": "متقدم"
},
"appearance": {
"message_list_order": {
"label": "ترتيب قائمة الرسائل",
"description": "كيفية ترتيب الرسائل في القائمة. يقوم الخادم بالترتيب، لذا يشمل المجلد بأكمله وليس الرسائل المحمّلة فقط.",
"preset": {
"chronological": "زمني (الأحدث أولاً)",
"unread_first": "غير المقروءة أولاً",
"starred_first": "المميزة بنجمة أولاً",
"tagged_first": "الموسومة أولاً",
"custom": "مخصص"
},
"tag_label": "الوسم",
"advanced_show": "متقدم: تحرير مستويات الترتيب",
"advanced_hide": "إخفاء الخيارات المتقدمة",
"criterion_label": "معيار المستوى {n}",
"direction_label": "اتجاه المستوى {n}",
"criteria": {
"unread": "غير مقروءة",
"starred": "مميزة بنجمة",
"tag": "الوسم",
"receivedAt": "تاريخ الاستلام",
"sentAt": "تاريخ الإرسال",
"from": "المرسل",
"subject": "الموضوع",
"size": "الحجم"
},
"direction": {
"unread_first": "غير المقروءة أولاً",
"read_first": "المقروءة أولاً",
"starred_first": "المميزة بنجمة أولاً",
"unstarred_first": "غير المميزة أولاً",
"tagged_first": "الموسومة أولاً",
"untagged_first": "غير الموسومة أولاً",
"newest_first": "الأحدث أولاً",
"oldest_first": "الأقدم أولاً",
"a_to_z": "أ → ي",
"z_to_a": "ي → أ",
"largest_first": "الأكبر أولاً",
"smallest_first": "الأصغر أولاً"
},
"add_level": "إضافة مستوى",
"remove_level": "إزالة المستوى",
"move_up": "نقل لأعلى",
"move_down": "نقل لأسفل",
"tiebreak_note": "حتى ثلاثة مستويات؛ تحسم المستويات اللاحقة حالات التعادل. يُطبَّق «الأحدث أولاً» دائمًا في النهاية.",
"scope": {
"label": "تطبيق على جميع المجلدات",
"description": "عند الإيقاف، يُطبَّق الترتيب على صندوق الوارد فقط؛ وتبقى المجلدات الأخرى مرتبة زمنيًا."
},
"unsupported_note": "خادم البريد لديك لا يدعم الترتيب حسب حالة القراءة أو النجمة أو الوسوم. يتم تجاهل هذه المستويات وتبقى القائمة مرتبة زمنيًا."
},
"title": "المظهر",
"description": "تخصيص شكل ومظهر البريد الإلكتروني على الويب",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Avançat"
},
"appearance": {
"message_list_order": {
"label": "Ordre de la llista de missatges",
"description": "Com s'ordenen els missatges a la llista. L'ordenació la fa el servidor, de manera que abasta tota la carpeta i no només els missatges carregats.",
"preset": {
"chronological": "Cronològic (més recents primer)",
"unread_first": "No llegits primer",
"starred_first": "Destacats primer",
"tagged_first": "Etiquetats primer",
"custom": "Personalitzat"
},
"tag_label": "Etiqueta",
"advanced_show": "Avançat: edita els nivells d'ordenació",
"advanced_hide": "Amaga les opcions avançades",
"criterion_label": "Criteri del nivell {n}",
"direction_label": "Direcció del nivell {n}",
"criteria": {
"unread": "No llegit",
"starred": "Destacat",
"tag": "Etiqueta",
"receivedAt": "Data de recepció",
"sentAt": "Data d'enviament",
"from": "Remitent",
"subject": "Assumpte",
"size": "Mida"
},
"direction": {
"unread_first": "No llegits primer",
"read_first": "Llegits primer",
"starred_first": "Destacats primer",
"unstarred_first": "No destacats primer",
"tagged_first": "Etiquetats primer",
"untagged_first": "Sense etiqueta primer",
"newest_first": "Més recents primer",
"oldest_first": "Més antics primer",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Més grans primer",
"smallest_first": "Més petits primer"
},
"add_level": "Afegeix un nivell",
"remove_level": "Elimina el nivell",
"move_up": "Mou amunt",
"move_down": "Mou avall",
"tiebreak_note": "Fins a tres nivells; els nivells posteriors desfan els empats. «Més recents primer» s'aplica sempre al final.",
"scope": {
"label": "Aplica a totes les carpetes",
"description": "Si està desactivat, l'ordre només s'aplica a la safata d'entrada; la resta de carpetes es mantenen cronològiques."
},
"unsupported_note": "El teu servidor de correu no admet ordenar per estat de lectura, destacats o etiquetes. Aquests nivells s'ignoren i la llista es manté cronològica."
},
"title": "Aparença",
"description": "Personalitzeu l'aspecte del vostre correu web",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Pokročilé"
},
"appearance": {
"message_list_order": {
"label": "Pořadí seznamu zpráv",
"description": "Jak jsou zprávy v seznamu seřazeny. Řazení provádí server, takže platí pro celou složku, ne jen pro načtené zprávy.",
"preset": {
"chronological": "Chronologicky (nejnovější první)",
"unread_first": "Nepřečtené první",
"starred_first": "S hvězdičkou první",
"tagged_first": "Se štítkem první",
"custom": "Vlastní"
},
"tag_label": "Štítek",
"advanced_show": "Pokročilé: upravit úrovně řazení",
"advanced_hide": "Skrýt pokročilé",
"criterion_label": "Kritérium úrovně {n}",
"direction_label": "Směr úrovně {n}",
"criteria": {
"unread": "Nepřečtené",
"starred": "S hvězdičkou",
"tag": "Štítek",
"receivedAt": "Datum přijetí",
"sentAt": "Datum odeslání",
"from": "Odesílatel",
"subject": "Předmět",
"size": "Velikost"
},
"direction": {
"unread_first": "Nepřečtené první",
"read_first": "Přečtené první",
"starred_first": "S hvězdičkou první",
"unstarred_first": "Bez hvězdičky první",
"tagged_first": "Se štítkem první",
"untagged_first": "Bez štítku první",
"newest_first": "Nejnovější první",
"oldest_first": "Nejstarší první",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Největší první",
"smallest_first": "Nejmenší první"
},
"add_level": "Přidat úroveň",
"remove_level": "Odebrat úroveň",
"move_up": "Posunout nahoru",
"move_down": "Posunout dolů",
"tiebreak_note": "Až tři úrovně; další úrovně rozhodují při shodě. „Nejnovější první“ se vždy uplatní jako poslední.",
"scope": {
"label": "Použít na všechny složky",
"description": "Pokud je vypnuto, pořadí platí jen pro Doručenou poštu; ostatní složky zůstávají chronologické."
},
"unsupported_note": "Váš poštovní server nepodporuje řazení podle stavu přečtení, hvězdiček ani štítků. Tyto úrovně se ignorují a seznam zůstává chronologický."
},
"title": "Vzhled",
"description": "Přizpůsobte si vzhled a chování vašeho webového e-mailu",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Avanceret"
},
"appearance": {
"message_list_order": {
"label": "Rækkefølge i beskedlisten",
"description": "Hvordan beskeder sorteres i listen. Serveren sorterer, så det gælder hele mappen og ikke kun de indlæste beskeder.",
"preset": {
"chronological": "Kronologisk (nyeste først)",
"unread_first": "Ulæste først",
"starred_first": "Stjernemarkerede først",
"tagged_first": "Med mærkat først",
"custom": "Tilpasset"
},
"tag_label": "Mærkat",
"advanced_show": "Avanceret: rediger sorteringsniveauer",
"advanced_hide": "Skjul avanceret",
"criterion_label": "Kriterium for niveau {n}",
"direction_label": "Retning for niveau {n}",
"criteria": {
"unread": "Ulæst",
"starred": "Stjernemarkeret",
"tag": "Mærkat",
"receivedAt": "Modtagelsesdato",
"sentAt": "Afsendelsesdato",
"from": "Afsender",
"subject": "Emne",
"size": "Størrelse"
},
"direction": {
"unread_first": "Ulæste først",
"read_first": "Læste først",
"starred_first": "Stjernemarkerede først",
"unstarred_first": "Uden stjerne først",
"tagged_first": "Med mærkat først",
"untagged_first": "Uden mærkat først",
"newest_first": "Nyeste først",
"oldest_first": "Ældste først",
"a_to_z": "A → Å",
"z_to_a": "Å → A",
"largest_first": "Største først",
"smallest_first": "Mindste først"
},
"add_level": "Tilføj niveau",
"remove_level": "Fjern niveau",
"move_up": "Flyt op",
"move_down": "Flyt ned",
"tiebreak_note": "Op til tre niveauer; senere niveauer afgør ved lighed. \"Nyeste først\" anvendes altid til sidst.",
"scope": {
"label": "Anvend på alle mapper",
"description": "Når den er slået fra, gælder rækkefølgen kun for Indbakken; andre mapper forbliver kronologiske."
},
"unsupported_note": "Din mailserver understøtter ikke sortering efter læsestatus, stjerner eller mærkater. Disse niveauer ignoreres, og listen forbliver kronologisk."
},
"title": "Udseende",
"description": "Tilpas udseendet af din webmail",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Erweitert"
},
"appearance": {
"message_list_order": {
"label": "Reihenfolge der Nachrichtenliste",
"description": "Wie Nachrichten in der Liste sortiert werden. Die Sortierung erfolgt auf dem Server und gilt damit für den ganzen Ordner, nicht nur für die geladenen Nachrichten.",
"preset": {
"chronological": "Chronologisch (neueste zuerst)",
"unread_first": "Ungelesene zuerst",
"starred_first": "Markierte zuerst",
"tagged_first": "Getaggte zuerst",
"custom": "Benutzerdefiniert"
},
"tag_label": "Tag",
"advanced_show": "Erweitert: Sortierebenen bearbeiten",
"advanced_hide": "Erweitert ausblenden",
"criterion_label": "Kriterium für Ebene {n}",
"direction_label": "Richtung für Ebene {n}",
"criteria": {
"unread": "Ungelesen",
"starred": "Markiert",
"tag": "Tag",
"receivedAt": "Empfangsdatum",
"sentAt": "Sendedatum",
"from": "Absender",
"subject": "Betreff",
"size": "Größe"
},
"direction": {
"unread_first": "Ungelesene zuerst",
"read_first": "Gelesene zuerst",
"starred_first": "Markierte zuerst",
"unstarred_first": "Nicht markierte zuerst",
"tagged_first": "Getaggte zuerst",
"untagged_first": "Nicht getaggte zuerst",
"newest_first": "Neueste zuerst",
"oldest_first": "Älteste zuerst",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Größte zuerst",
"smallest_first": "Kleinste zuerst"
},
"add_level": "Ebene hinzufügen",
"remove_level": "Ebene entfernen",
"move_up": "Nach oben",
"move_down": "Nach unten",
"tiebreak_note": "Bis zu drei Ebenen; spätere Ebenen entscheiden bei Gleichstand. Neueste zuerst gilt immer als letzte Ebene.",
"scope": {
"label": "Auf alle Ordner anwenden",
"description": "Wenn ausgeschaltet, gilt die Reihenfolge nur für den Posteingang; andere Ordner bleiben chronologisch."
},
"unsupported_note": "Dein Mailserver unterstützt keine Sortierung nach Lesestatus, Markierung oder Tags. Diese Ebenen werden ignoriert und die Liste bleibt chronologisch."
},
"title": "Darstellung",
"description": "Passen Sie das Aussehen Ihres Webmails an",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Advanced"
},
"appearance": {
"message_list_order": {
"label": "Message list order",
"description": "How messages are ordered in the list. The server does the ordering, so it covers the whole folder, not just the loaded messages.",
"preset": {
"chronological": "Chronological (newest first)",
"unread_first": "Unread first",
"starred_first": "Starred first",
"tagged_first": "Tagged first",
"custom": "Custom"
},
"tag_label": "Tag",
"advanced_show": "Advanced: edit sort levels",
"advanced_hide": "Hide advanced",
"criterion_label": "Level {n} criterion",
"direction_label": "Level {n} direction",
"criteria": {
"unread": "Unread",
"starred": "Starred",
"tag": "Tag",
"receivedAt": "Date received",
"sentAt": "Date sent",
"from": "Sender",
"subject": "Subject",
"size": "Size"
},
"direction": {
"unread_first": "Unread first",
"read_first": "Read first",
"starred_first": "Starred first",
"unstarred_first": "Unstarred first",
"tagged_first": "Tagged first",
"untagged_first": "Untagged first",
"newest_first": "Newest first",
"oldest_first": "Oldest first",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Largest first",
"smallest_first": "Smallest first"
},
"add_level": "Add level",
"remove_level": "Remove level",
"move_up": "Move up",
"move_down": "Move down",
"tiebreak_note": "Up to three levels; later levels break ties. Newest first is always applied last.",
"scope": {
"label": "Apply to all folders",
"description": "When off, the order applies to the Inbox only; other folders stay chronological."
},
"unsupported_note": "Your mail server does not support ordering by read state, stars or tags. Those levels are ignored and the list stays chronological."
},
"title": "Appearance",
"description": "Customize the look and feel of your webmail",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Avanzado"
},
"appearance": {
"message_list_order": {
"label": "Orden de la lista de mensajes",
"description": "Cómo se ordenan los mensajes en la lista. El servidor realiza la ordenación, por lo que abarca toda la carpeta, no solo los mensajes cargados.",
"preset": {
"chronological": "Cronológico (más recientes primero)",
"unread_first": "No leídos primero",
"starred_first": "Destacados primero",
"tagged_first": "Etiquetados primero",
"custom": "Personalizado"
},
"tag_label": "Etiqueta",
"advanced_show": "Avanzado: editar niveles de orden",
"advanced_hide": "Ocultar avanzado",
"criterion_label": "Criterio del nivel {n}",
"direction_label": "Dirección del nivel {n}",
"criteria": {
"unread": "No leído",
"starred": "Destacado",
"tag": "Etiqueta",
"receivedAt": "Fecha de recepción",
"sentAt": "Fecha de envío",
"from": "Remitente",
"subject": "Asunto",
"size": "Tamaño"
},
"direction": {
"unread_first": "No leídos primero",
"read_first": "Leídos primero",
"starred_first": "Destacados primero",
"unstarred_first": "No destacados primero",
"tagged_first": "Etiquetados primero",
"untagged_first": "Sin etiqueta primero",
"newest_first": "Más recientes primero",
"oldest_first": "Más antiguos primero",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Más grandes primero",
"smallest_first": "Más pequeños primero"
},
"add_level": "Añadir nivel",
"remove_level": "Eliminar nivel",
"move_up": "Subir",
"move_down": "Bajar",
"tiebreak_note": "Hasta tres niveles; los niveles posteriores deshacen empates. «Más recientes primero» se aplica siempre al final.",
"scope": {
"label": "Aplicar a todas las carpetas",
"description": "Si está desactivado, el orden solo se aplica a la bandeja de entrada; las demás carpetas siguen siendo cronológicas."
},
"unsupported_note": "Tu servidor de correo no admite ordenar por estado de lectura, destacados o etiquetas. Esos niveles se ignoran y la lista sigue siendo cronológica."
},
"title": "Apariencia",
"description": "Personalice la apariencia de su correo web",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "پیشرفته"
},
"appearance": {
"message_list_order": {
"label": "ترتیب فهرست پیام‌ها",
"description": "نحوهٔ مرتب‌سازی پیام‌ها در فهرست. مرتب‌سازی را سرور انجام می‌دهد، بنابراین کل پوشه را در بر می‌گیرد، نه فقط پیام‌های بارگذاری‌شده را.",
"preset": {
"chronological": "زمانی (جدیدترین ابتدا)",
"unread_first": "خوانده‌نشده‌ها ابتدا",
"starred_first": "ستاره‌دارها ابتدا",
"tagged_first": "برچسب‌دارها ابتدا",
"custom": "سفارشی"
},
"tag_label": "برچسب",
"advanced_show": "پیشرفته: ویرایش سطوح مرتب‌سازی",
"advanced_hide": "پنهان کردن پیشرفته",
"criterion_label": "معیار سطح {n}",
"direction_label": "جهت سطح {n}",
"criteria": {
"unread": "خوانده‌نشده",
"starred": "ستاره‌دار",
"tag": "برچسب",
"receivedAt": "تاریخ دریافت",
"sentAt": "تاریخ ارسال",
"from": "فرستنده",
"subject": "موضوع",
"size": "اندازه"
},
"direction": {
"unread_first": "خوانده‌نشده‌ها ابتدا",
"read_first": "خوانده‌شده‌ها ابتدا",
"starred_first": "ستاره‌دارها ابتدا",
"unstarred_first": "بدون ستاره ابتدا",
"tagged_first": "برچسب‌دارها ابتدا",
"untagged_first": "بدون برچسب ابتدا",
"newest_first": "جدیدترین ابتدا",
"oldest_first": "قدیمی‌ترین ابتدا",
"a_to_z": "الف → ی",
"z_to_a": "ی → الف",
"largest_first": "بزرگ‌ترین ابتدا",
"smallest_first": "کوچک‌ترین ابتدا"
},
"add_level": "افزودن سطح",
"remove_level": "حذف سطح",
"move_up": "انتقال به بالا",
"move_down": "انتقال به پایین",
"tiebreak_note": "حداکثر سه سطح؛ سطوح بعدی در صورت تساوی تصمیم می‌گیرند. «جدیدترین ابتدا» همیشه در آخر اعمال می‌شود.",
"scope": {
"label": "اعمال بر همهٔ پوشه‌ها",
"description": "در حالت خاموش، ترتیب فقط بر صندوق ورودی اعمال می‌شود؛ پوشه‌های دیگر زمانی می‌مانند."
},
"unsupported_note": "سرور ایمیل شما مرتب‌سازی بر اساس وضعیت خواندن، ستاره یا برچسب را پشتیبانی نمی‌کند. این سطوح نادیده گرفته می‌شوند و فهرست زمانی می‌ماند."
},
"title": "ظاهر",
"description": "ظاهر و حس ایمیل تحت وب خود را سفارشی کنید",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Avancé"
},
"appearance": {
"message_list_order": {
"label": "Ordre de la liste des messages",
"description": "Comment les messages sont ordonnés dans la liste. Le tri est effectué par le serveur et couvre donc tout le dossier, pas seulement les messages chargés.",
"preset": {
"chronological": "Chronologique (plus récents d'abord)",
"unread_first": "Non lus d'abord",
"starred_first": "Suivis d'abord",
"tagged_first": "Étiquetés d'abord",
"custom": "Personnalisé"
},
"tag_label": "Étiquette",
"advanced_show": "Avancé : modifier les niveaux de tri",
"advanced_hide": "Masquer les options avancées",
"criterion_label": "Critère du niveau {n}",
"direction_label": "Sens du niveau {n}",
"criteria": {
"unread": "Non lu",
"starred": "Suivi",
"tag": "Étiquette",
"receivedAt": "Date de réception",
"sentAt": "Date d'envoi",
"from": "Expéditeur",
"subject": "Objet",
"size": "Taille"
},
"direction": {
"unread_first": "Non lus d'abord",
"read_first": "Lus d'abord",
"starred_first": "Suivis d'abord",
"unstarred_first": "Non suivis d'abord",
"tagged_first": "Étiquetés d'abord",
"untagged_first": "Non étiquetés d'abord",
"newest_first": "Plus récents d'abord",
"oldest_first": "Plus anciens d'abord",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Plus volumineux d'abord",
"smallest_first": "Plus petits d'abord"
},
"add_level": "Ajouter un niveau",
"remove_level": "Supprimer le niveau",
"move_up": "Monter",
"move_down": "Descendre",
"tiebreak_note": "Jusqu'à trois niveaux ; les niveaux suivants départagent les égalités. « Plus récents d'abord » s'applique toujours en dernier.",
"scope": {
"label": "Appliquer à tous les dossiers",
"description": "Désactivé, l'ordre ne s'applique qu'à la boîte de réception ; les autres dossiers restent chronologiques."
},
"unsupported_note": "Votre serveur de messagerie ne prend pas en charge le tri par état de lecture, suivi ou étiquette. Ces niveaux sont ignorés et la liste reste chronologique."
},
"title": "Apparence",
"description": "Personnalisez l'apparence de votre webmail",
"theme": {
+50
View File
@@ -886,6 +886,56 @@
"advanced": "מתקדם"
},
"appearance": {
"message_list_order": {
"label": "סדר רשימת ההודעות",
"description": "איך ההודעות מסודרות ברשימה. המיון מתבצע בשרת, ולכן חל על כל התיקייה ולא רק על ההודעות שנטענו.",
"preset": {
"chronological": "כרונולוגי (החדשות תחילה)",
"unread_first": "שלא נקראו תחילה",
"starred_first": "מסומנות בכוכב תחילה",
"tagged_first": "מתויגות תחילה",
"custom": "מותאם אישית"
},
"tag_label": "תגית",
"advanced_show": "מתקדם: עריכת רמות המיון",
"advanced_hide": "הסתרת מתקדם",
"criterion_label": "קריטריון רמה {n}",
"direction_label": "כיוון רמה {n}",
"criteria": {
"unread": "לא נקרא",
"starred": "מסומן בכוכב",
"tag": "תגית",
"receivedAt": "תאריך קבלה",
"sentAt": "תאריך שליחה",
"from": "שולח",
"subject": "נושא",
"size": "גודל"
},
"direction": {
"unread_first": "שלא נקראו תחילה",
"read_first": "שנקראו תחילה",
"starred_first": "מסומנות בכוכב תחילה",
"unstarred_first": "ללא כוכב תחילה",
"tagged_first": "מתויגות תחילה",
"untagged_first": "ללא תגית תחילה",
"newest_first": "החדשות תחילה",
"oldest_first": "הישנות תחילה",
"a_to_z": "א → ת",
"z_to_a": "ת → א",
"largest_first": "הגדולות תחילה",
"smallest_first": "הקטנות תחילה"
},
"add_level": "הוספת רמה",
"remove_level": "הסרת רמה",
"move_up": "הזזה למעלה",
"move_down": "הזזה למטה",
"tiebreak_note": "עד שלוש רמות; הרמות הבאות מכריעות במקרה של שוויון. \"החדשות תחילה\" תמיד מוחל אחרון.",
"scope": {
"label": "החלה על כל התיקיות",
"description": "כשכבוי, הסדר חל על תיבת הדואר הנכנס בלבד; שאר התיקיות נשארות כרונולוגיות."
},
"unsupported_note": "שרת הדואר שלך אינו תומך במיון לפי מצב קריאה, כוכב או תגיות. רמות אלה מתעלמות והרשימה נשארת כרונולוגית."
},
"title": "מראה",
"description": "התאם אישית את המראה והתחושה של דואר האינטרנט שלך",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Speciális"
},
"appearance": {
"message_list_order": {
"label": "Üzenetlista sorrendje",
"description": "Hogyan rendeződnek az üzenetek a listában. A rendezést a kiszolgáló végzi, így a teljes mappára vonatkozik, nem csak a betöltött üzenetekre.",
"preset": {
"chronological": "Időrendi (legújabb elöl)",
"unread_first": "Olvasatlanok elöl",
"starred_first": "Csillagozottak elöl",
"tagged_first": "Címkézettek elöl",
"custom": "Egyéni"
},
"tag_label": "Címke",
"advanced_show": "Speciális: rendezési szintek szerkesztése",
"advanced_hide": "Speciális elrejtése",
"criterion_label": "{n}. szint feltétele",
"direction_label": "{n}. szint iránya",
"criteria": {
"unread": "Olvasatlan",
"starred": "Csillagozott",
"tag": "Címke",
"receivedAt": "Érkezés dátuma",
"sentAt": "Küldés dátuma",
"from": "Feladó",
"subject": "Tárgy",
"size": "Méret"
},
"direction": {
"unread_first": "Olvasatlanok elöl",
"read_first": "Olvasottak elöl",
"starred_first": "Csillagozottak elöl",
"unstarred_first": "Csillag nélküliek elöl",
"tagged_first": "Címkézettek elöl",
"untagged_first": "Címkézetlenek elöl",
"newest_first": "Legújabb elöl",
"oldest_first": "Legrégebbi elöl",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Legnagyobb elöl",
"smallest_first": "Legkisebb elöl"
},
"add_level": "Szint hozzáadása",
"remove_level": "Szint eltávolítása",
"move_up": "Feljebb",
"move_down": "Lejjebb",
"tiebreak_note": "Legfeljebb három szint; a további szintek döntenek holtverseny esetén. A „legújabb elöl” mindig utolsóként érvényesül.",
"scope": {
"label": "Alkalmazás minden mappára",
"description": "Kikapcsolva a sorrend csak a Beérkezett üzenetekre vonatkozik; a többi mappa időrendi marad."
},
"unsupported_note": "A levelezőkiszolgáló nem támogatja az olvasottság, csillag vagy címke szerinti rendezést. Ezeket a szinteket figyelmen kívül hagyja, és a lista időrendi marad."
},
"title": "Megjelenés",
"description": "Webmail megjelenésének testreszabása",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Avanzate"
},
"appearance": {
"message_list_order": {
"label": "Ordine dell'elenco messaggi",
"description": "Come vengono ordinati i messaggi nell'elenco. L'ordinamento è eseguito dal server, quindi riguarda l'intera cartella e non solo i messaggi caricati.",
"preset": {
"chronological": "Cronologico (più recenti prima)",
"unread_first": "Non letti prima",
"starred_first": "Speciali prima",
"tagged_first": "Con tag prima",
"custom": "Personalizzato"
},
"tag_label": "Tag",
"advanced_show": "Avanzate: modifica i livelli di ordinamento",
"advanced_hide": "Nascondi avanzate",
"criterion_label": "Criterio del livello {n}",
"direction_label": "Direzione del livello {n}",
"criteria": {
"unread": "Non letto",
"starred": "Speciale",
"tag": "Tag",
"receivedAt": "Data di ricezione",
"sentAt": "Data di invio",
"from": "Mittente",
"subject": "Oggetto",
"size": "Dimensione"
},
"direction": {
"unread_first": "Non letti prima",
"read_first": "Letti prima",
"starred_first": "Speciali prima",
"unstarred_first": "Non speciali prima",
"tagged_first": "Con tag prima",
"untagged_first": "Senza tag prima",
"newest_first": "Più recenti prima",
"oldest_first": "Più vecchi prima",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Più grandi prima",
"smallest_first": "Più piccoli prima"
},
"add_level": "Aggiungi livello",
"remove_level": "Rimuovi livello",
"move_up": "Sposta su",
"move_down": "Sposta giù",
"tiebreak_note": "Fino a tre livelli; i livelli successivi risolvono i pareggi. «Più recenti prima» viene sempre applicato per ultimo.",
"scope": {
"label": "Applica a tutte le cartelle",
"description": "Se disattivato, l'ordine vale solo per la Posta in arrivo; le altre cartelle restano cronologiche."
},
"unsupported_note": "Il tuo server di posta non supporta l'ordinamento per stato di lettura, speciali o tag. Questi livelli vengono ignorati e l'elenco resta cronologico."
},
"title": "Aspetto",
"description": "Personalizza l'aspetto della tua webmail",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "詳細"
},
"appearance": {
"message_list_order": {
"label": "メッセージ一覧の並び順",
"description": "一覧でメッセージをどう並べるか。並べ替えはサーバーが行うため、読み込み済みのメッセージだけでなくフォルダー全体に適用されます。",
"preset": {
"chronological": "時系列(新しい順)",
"unread_first": "未読を先に",
"starred_first": "スター付きを先に",
"tagged_first": "タグ付きを先に",
"custom": "カスタム"
},
"tag_label": "タグ",
"advanced_show": "詳細: 並べ替えレベルを編集",
"advanced_hide": "詳細を隠す",
"criterion_label": "レベル {n} の条件",
"direction_label": "レベル {n} の方向",
"criteria": {
"unread": "未読",
"starred": "スター付き",
"tag": "タグ",
"receivedAt": "受信日時",
"sentAt": "送信日時",
"from": "送信者",
"subject": "件名",
"size": "サイズ"
},
"direction": {
"unread_first": "未読を先に",
"read_first": "既読を先に",
"starred_first": "スター付きを先に",
"unstarred_first": "スターなしを先に",
"tagged_first": "タグ付きを先に",
"untagged_first": "タグなしを先に",
"newest_first": "新しい順",
"oldest_first": "古い順",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "大きい順",
"smallest_first": "小さい順"
},
"add_level": "レベルを追加",
"remove_level": "レベルを削除",
"move_up": "上へ移動",
"move_down": "下へ移動",
"tiebreak_note": "最大 3 レベル。後のレベルは同順位の場合に使われます。「新しい順」は常に最後に適用されます。",
"scope": {
"label": "すべてのフォルダーに適用",
"description": "オフの場合、並び順は受信トレイにのみ適用され、他のフォルダーは時系列のままです。"
},
"unsupported_note": "お使いのメールサーバーは既読状態・スター・タグによる並べ替えに対応していません。これらのレベルは無視され、一覧は時系列のままになります。"
},
"title": "外観",
"description": "ウェブメールの見た目をカスタマイズ",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "고급"
},
"appearance": {
"message_list_order": {
"label": "메시지 목록 정렬 순서",
"description": "목록에서 메시지를 정렬하는 방식입니다. 정렬은 서버가 수행하므로 불러온 메시지뿐 아니라 폴더 전체에 적용됩니다.",
"preset": {
"chronological": "시간순 (최신 먼저)",
"unread_first": "읽지 않음 먼저",
"starred_first": "별표 먼저",
"tagged_first": "태그 먼저",
"custom": "사용자 지정"
},
"tag_label": "태그",
"advanced_show": "고급: 정렬 단계 편집",
"advanced_hide": "고급 숨기기",
"criterion_label": "{n}단계 기준",
"direction_label": "{n}단계 방향",
"criteria": {
"unread": "읽지 않음",
"starred": "별표",
"tag": "태그",
"receivedAt": "받은 날짜",
"sentAt": "보낸 날짜",
"from": "보낸 사람",
"subject": "제목",
"size": "크기"
},
"direction": {
"unread_first": "읽지 않음 먼저",
"read_first": "읽음 먼저",
"starred_first": "별표 먼저",
"unstarred_first": "별표 없음 먼저",
"tagged_first": "태그 있음 먼저",
"untagged_first": "태그 없음 먼저",
"newest_first": "최신 먼저",
"oldest_first": "오래된 순",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "큰 순서",
"smallest_first": "작은 순서"
},
"add_level": "단계 추가",
"remove_level": "단계 제거",
"move_up": "위로 이동",
"move_down": "아래로 이동",
"tiebreak_note": "최대 3단계이며, 이후 단계는 동순위일 때 적용됩니다. '최신 먼저'는 항상 마지막에 적용됩니다.",
"scope": {
"label": "모든 폴더에 적용",
"description": "꺼져 있으면 정렬 순서가 받은편지함에만 적용되고 다른 폴더는 시간순으로 유지됩니다."
},
"unsupported_note": "메일 서버가 읽음 상태, 별표 또는 태그 기준 정렬을 지원하지 않습니다. 해당 단계는 무시되며 목록은 시간순으로 유지됩니다."
},
"title": "화면 설정",
"description": "웹메일의 디자인과 화면을 내 취향에 맞게 꾸며보세요",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Papildu"
},
"appearance": {
"message_list_order": {
"label": "Ziņu saraksta secība",
"description": "Kā ziņas tiek sakārtotas sarakstā. Kārtošanu veic serveris, tāpēc tā attiecas uz visu mapi, ne tikai uz ielādētajām ziņām.",
"preset": {
"chronological": "Hronoloģiski (jaunākās vispirms)",
"unread_first": "Nelasītās vispirms",
"starred_first": "Ar zvaigznīti vispirms",
"tagged_first": "Ar birku vispirms",
"custom": "Pielāgota"
},
"tag_label": "Birka",
"advanced_show": "Papildu: rediģēt kārtošanas līmeņus",
"advanced_hide": "Paslēpt papildu",
"criterion_label": "{n}. līmeņa kritērijs",
"direction_label": "{n}. līmeņa virziens",
"criteria": {
"unread": "Nelasīta",
"starred": "Ar zvaigznīti",
"tag": "Birka",
"receivedAt": "Saņemšanas datums",
"sentAt": "Nosūtīšanas datums",
"from": "Sūtītājs",
"subject": "Tēma",
"size": "Izmērs"
},
"direction": {
"unread_first": "Nelasītās vispirms",
"read_first": "Lasītās vispirms",
"starred_first": "Ar zvaigznīti vispirms",
"unstarred_first": "Bez zvaigznītes vispirms",
"tagged_first": "Ar birku vispirms",
"untagged_first": "Bez birkas vispirms",
"newest_first": "Jaunākās vispirms",
"oldest_first": "Vecākās vispirms",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Lielākās vispirms",
"smallest_first": "Mazākās vispirms"
},
"add_level": "Pievienot līmeni",
"remove_level": "Noņemt līmeni",
"move_up": "Pārvietot augstāk",
"move_down": "Pārvietot zemāk",
"tiebreak_note": "Līdz trim līmeņiem; nākamie līmeņi izšķir neizšķirtu. „Jaunākās vispirms” vienmēr tiek piemērots pēdējais.",
"scope": {
"label": "Piemērot visām mapēm",
"description": "Ja izslēgts, secība attiecas tikai uz Iesūtni; pārējās mapes paliek hronoloģiskas."
},
"unsupported_note": "Jūsu pasta serveris neatbalsta kārtošanu pēc lasīšanas stāvokļa, zvaigznītēm vai birkām. Šie līmeņi tiek ignorēti, un saraksts paliek hronoloģisks."
},
"title": "Izskats",
"description": "Pielāgojiet tīmekļa pasta izskatu",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Дэвшилтэт"
},
"appearance": {
"message_list_order": {
"label": "Зурвасын жагсаалтын дараалал",
"description": "Зурвасууд жагсаалтад хэрхэн эрэмбэлэгдэх. Эрэмбэлэлтийг сервер хийдэг тул зөвхөн ачаалагдсан зурвас биш, бүх хавтсанд үйлчилнэ.",
"preset": {
"chronological": "Он цагийн дарааллаар (шинэ нь эхэнд)",
"unread_first": "Уншаагүй нь эхэнд",
"starred_first": "Одтой нь эхэнд",
"tagged_first": "Шошготой нь эхэнд",
"custom": "Өөрийн"
},
"tag_label": "Шошго",
"advanced_show": "Нарийвчилсан: эрэмбийн түвшнүүдийг засах",
"advanced_hide": "Нарийвчилсныг нуух",
"criterion_label": "{n}-р түвшний шалгуур",
"direction_label": "{n}-р түвшний чиглэл",
"criteria": {
"unread": "Уншаагүй",
"starred": "Одтой",
"tag": "Шошго",
"receivedAt": "Хүлээн авсан огноо",
"sentAt": "Илгээсэн огноо",
"from": "Илгээгч",
"subject": "Гарчиг",
"size": "Хэмжээ"
},
"direction": {
"unread_first": "Уншаагүй нь эхэнд",
"read_first": "Уншсан нь эхэнд",
"starred_first": "Одтой нь эхэнд",
"unstarred_first": "Одгүй нь эхэнд",
"tagged_first": "Шошготой нь эхэнд",
"untagged_first": "Шошгогүй нь эхэнд",
"newest_first": "Шинэ нь эхэнд",
"oldest_first": "Хуучин нь эхэнд",
"a_to_z": "А → Я",
"z_to_a": "Я → А",
"largest_first": "Том нь эхэнд",
"smallest_first": "Жижиг нь эхэнд"
},
"add_level": "Түвшин нэмэх",
"remove_level": "Түвшин устгах",
"move_up": "Дээш зөөх",
"move_down": "Доош зөөх",
"tiebreak_note": "Дээд тал нь гурван түвшин; дараагийн түвшнүүд тэнцсэн үед шийднэ. «Шинэ нь эхэнд» үргэлж хамгийн сүүлд хэрэглэгдэнэ.",
"scope": {
"label": "Бүх хавтсанд хэрэглэх",
"description": "Унтраалттай үед дараалал зөвхөн Ирсэн хавтсанд үйлчилнэ; бусад хавтас он цагийн дарааллаараа үлдэнэ."
},
"unsupported_note": "Таны мэйл сервер уншсан төлөв, од эсвэл шошгоор эрэмбэлэхийг дэмждэггүй. Эдгээр түвшнийг алгасаж, жагсаалт он цагийн дарааллаараа үлдэнэ."
},
"title": "Гадаад төрх",
"description": "Вэбмэйлийнхээ харагдах байдал, мэдрэмжийг өөрчилнө",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Geavanceerd"
},
"appearance": {
"message_list_order": {
"label": "Volgorde van de berichtenlijst",
"description": "Hoe berichten in de lijst worden gesorteerd. De server sorteert, dus het geldt voor de hele map en niet alleen voor de geladen berichten.",
"preset": {
"chronological": "Chronologisch (nieuwste eerst)",
"unread_first": "Ongelezen eerst",
"starred_first": "Met ster eerst",
"tagged_first": "Met label eerst",
"custom": "Aangepast"
},
"tag_label": "Label",
"advanced_show": "Geavanceerd: sorteerniveaus bewerken",
"advanced_hide": "Geavanceerd verbergen",
"criterion_label": "Criterium van niveau {n}",
"direction_label": "Richting van niveau {n}",
"criteria": {
"unread": "Ongelezen",
"starred": "Met ster",
"tag": "Label",
"receivedAt": "Ontvangstdatum",
"sentAt": "Verzenddatum",
"from": "Afzender",
"subject": "Onderwerp",
"size": "Grootte"
},
"direction": {
"unread_first": "Ongelezen eerst",
"read_first": "Gelezen eerst",
"starred_first": "Met ster eerst",
"unstarred_first": "Zonder ster eerst",
"tagged_first": "Met label eerst",
"untagged_first": "Zonder label eerst",
"newest_first": "Nieuwste eerst",
"oldest_first": "Oudste eerst",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Grootste eerst",
"smallest_first": "Kleinste eerst"
},
"add_level": "Niveau toevoegen",
"remove_level": "Niveau verwijderen",
"move_up": "Omhoog",
"move_down": "Omlaag",
"tiebreak_note": "Maximaal drie niveaus; latere niveaus beslissen bij gelijke stand. 'Nieuwste eerst' wordt altijd als laatste toegepast.",
"scope": {
"label": "Op alle mappen toepassen",
"description": "Uitgeschakeld geldt de volgorde alleen voor het Postvak IN; andere mappen blijven chronologisch."
},
"unsupported_note": "Je mailserver ondersteunt geen sortering op leesstatus, ster of label. Die niveaus worden genegeerd en de lijst blijft chronologisch."
},
"title": "Uiterlijk",
"description": "Pas het uiterlijk van je webmail aan",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Zaawansowane"
},
"appearance": {
"message_list_order": {
"label": "Kolejność listy wiadomości",
"description": "Jak wiadomości są uporządkowane na liście. Sortowanie wykonuje serwer, więc obejmuje cały folder, a nie tylko załadowane wiadomości.",
"preset": {
"chronological": "Chronologicznie (najnowsze najpierw)",
"unread_first": "Nieprzeczytane najpierw",
"starred_first": "Oznaczone gwiazdką najpierw",
"tagged_first": "Z etykietą najpierw",
"custom": "Własna"
},
"tag_label": "Etykieta",
"advanced_show": "Zaawansowane: edytuj poziomy sortowania",
"advanced_hide": "Ukryj zaawansowane",
"criterion_label": "Kryterium poziomu {n}",
"direction_label": "Kierunek poziomu {n}",
"criteria": {
"unread": "Nieprzeczytane",
"starred": "Z gwiazdką",
"tag": "Etykieta",
"receivedAt": "Data odebrania",
"sentAt": "Data wysłania",
"from": "Nadawca",
"subject": "Temat",
"size": "Rozmiar"
},
"direction": {
"unread_first": "Nieprzeczytane najpierw",
"read_first": "Przeczytane najpierw",
"starred_first": "Z gwiazdką najpierw",
"unstarred_first": "Bez gwiazdki najpierw",
"tagged_first": "Z etykietą najpierw",
"untagged_first": "Bez etykiety najpierw",
"newest_first": "Najnowsze najpierw",
"oldest_first": "Najstarsze najpierw",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Największe najpierw",
"smallest_first": "Najmniejsze najpierw"
},
"add_level": "Dodaj poziom",
"remove_level": "Usuń poziom",
"move_up": "Przenieś wyżej",
"move_down": "Przenieś niżej",
"tiebreak_note": "Do trzech poziomów; kolejne poziomy rozstrzygają remisy. „Najnowsze najpierw” jest zawsze stosowane na końcu.",
"scope": {
"label": "Zastosuj do wszystkich folderów",
"description": "Gdy wyłączone, kolejność dotyczy tylko Odebranych; pozostałe foldery pozostają chronologiczne."
},
"unsupported_note": "Twój serwer poczty nie obsługuje sortowania według stanu przeczytania, gwiazdek ani etykiet. Te poziomy są pomijane, a lista pozostaje chronologiczna."
},
"title": "Wygląd",
"description": "Dostosuj wygląd i działanie swojej poczty webowej",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Avançado"
},
"appearance": {
"message_list_order": {
"label": "Ordem da lista de mensagens",
"description": "Como as mensagens são ordenadas na lista. A ordenação é feita pelo servidor, por isso abrange toda a pasta e não apenas as mensagens carregadas.",
"preset": {
"chronological": "Cronológica (mais recentes primeiro)",
"unread_first": "Não lidas primeiro",
"starred_first": "Com estrela primeiro",
"tagged_first": "Com etiqueta primeiro",
"custom": "Personalizada"
},
"tag_label": "Etiqueta",
"advanced_show": "Avançado: editar níveis de ordenação",
"advanced_hide": "Ocultar avançado",
"criterion_label": "Critério do nível {n}",
"direction_label": "Direção do nível {n}",
"criteria": {
"unread": "Não lida",
"starred": "Com estrela",
"tag": "Etiqueta",
"receivedAt": "Data de receção",
"sentAt": "Data de envio",
"from": "Remetente",
"subject": "Assunto",
"size": "Tamanho"
},
"direction": {
"unread_first": "Não lidas primeiro",
"read_first": "Lidas primeiro",
"starred_first": "Com estrela primeiro",
"unstarred_first": "Sem estrela primeiro",
"tagged_first": "Com etiqueta primeiro",
"untagged_first": "Sem etiqueta primeiro",
"newest_first": "Mais recentes primeiro",
"oldest_first": "Mais antigas primeiro",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Maiores primeiro",
"smallest_first": "Menores primeiro"
},
"add_level": "Adicionar nível",
"remove_level": "Remover nível",
"move_up": "Mover para cima",
"move_down": "Mover para baixo",
"tiebreak_note": "Até três níveis; os níveis seguintes desempatam. «Mais recentes primeiro» é sempre aplicado por último.",
"scope": {
"label": "Aplicar a todas as pastas",
"description": "Desativado, a ordem aplica-se apenas à Caixa de entrada; as outras pastas continuam cronológicas."
},
"unsupported_note": "O seu servidor de correio não suporta ordenação por estado de leitura, estrelas ou etiquetas. Esses níveis são ignorados e a lista mantém-se cronológica."
},
"title": "Aparência",
"description": "Personalize a aparência do seu webmail",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Nivel avansat"
},
"appearance": {
"message_list_order": {
"label": "Ordinea listei de mesaje",
"description": "Cum sunt ordonate mesajele în listă. Sortarea este făcută de server, deci acoperă întregul dosar, nu doar mesajele încărcate.",
"preset": {
"chronological": "Cronologic (cele mai noi primele)",
"unread_first": "Necitite primele",
"starred_first": "Cu stea primele",
"tagged_first": "Etichetate primele",
"custom": "Personalizat"
},
"tag_label": "Etichetă",
"advanced_show": "Avansat: editează nivelurile de sortare",
"advanced_hide": "Ascunde avansat",
"criterion_label": "Criteriul nivelului {n}",
"direction_label": "Direcția nivelului {n}",
"criteria": {
"unread": "Necitit",
"starred": "Cu stea",
"tag": "Etichetă",
"receivedAt": "Data primirii",
"sentAt": "Data trimiterii",
"from": "Expeditor",
"subject": "Subiect",
"size": "Dimensiune"
},
"direction": {
"unread_first": "Necitite primele",
"read_first": "Citite primele",
"starred_first": "Cu stea primele",
"unstarred_first": "Fără stea primele",
"tagged_first": "Etichetate primele",
"untagged_first": "Neetichetate primele",
"newest_first": "Cele mai noi primele",
"oldest_first": "Cele mai vechi primele",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Cele mai mari primele",
"smallest_first": "Cele mai mici primele"
},
"add_level": "Adaugă nivel",
"remove_level": "Elimină nivelul",
"move_up": "Mută în sus",
"move_down": "Mută în jos",
"tiebreak_note": "Până la trei niveluri; nivelurile următoare departajează egalitățile. „Cele mai noi primele” se aplică întotdeauna la final.",
"scope": {
"label": "Aplică la toate dosarele",
"description": "Dezactivat, ordinea se aplică doar la Inbox; celelalte dosare rămân cronologice."
},
"unsupported_note": "Serverul tău de e-mail nu acceptă sortarea după starea de citire, stele sau etichete. Aceste niveluri sunt ignorate, iar lista rămâne cronologică."
},
"title": "Aspect",
"description": "Personalizați aspectul și funcționalitatea serviciului dvs. de webmail",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Дополнительно"
},
"appearance": {
"message_list_order": {
"label": "Порядок списка сообщений",
"description": "Как упорядочены сообщения в списке. Сортировку выполняет сервер, поэтому она охватывает всю папку, а не только загруженные сообщения.",
"preset": {
"chronological": "Хронологически (сначала новые)",
"unread_first": "Сначала непрочитанные",
"starred_first": "Сначала помеченные",
"tagged_first": "Сначала с меткой",
"custom": "Свой порядок"
},
"tag_label": "Метка",
"advanced_show": "Дополнительно: настроить уровни сортировки",
"advanced_hide": "Скрыть дополнительно",
"criterion_label": "Критерий уровня {n}",
"direction_label": "Направление уровня {n}",
"criteria": {
"unread": "Непрочитанные",
"starred": "Помеченные",
"tag": "Метка",
"receivedAt": "Дата получения",
"sentAt": "Дата отправки",
"from": "Отправитель",
"subject": "Тема",
"size": "Размер"
},
"direction": {
"unread_first": "Сначала непрочитанные",
"read_first": "Сначала прочитанные",
"starred_first": "Сначала помеченные",
"unstarred_first": "Сначала непомеченные",
"tagged_first": "Сначала с меткой",
"untagged_first": "Сначала без метки",
"newest_first": "Сначала новые",
"oldest_first": "Сначала старые",
"a_to_z": "А → Я",
"z_to_a": "Я → А",
"largest_first": "Сначала большие",
"smallest_first": "Сначала маленькие"
},
"add_level": "Добавить уровень",
"remove_level": "Удалить уровень",
"move_up": "Выше",
"move_down": "Ниже",
"tiebreak_note": "До трёх уровней; следующие уровни решают при равенстве. «Сначала новые» всегда применяется последним.",
"scope": {
"label": "Применять ко всем папкам",
"description": "Если выключено, порядок действует только для папки «Входящие»; остальные папки остаются хронологическими."
},
"unsupported_note": "Ваш почтовый сервер не поддерживает сортировку по статусу прочтения, пометкам или меткам. Эти уровни игнорируются, и список остаётся хронологическим."
},
"title": "Внешний вид",
"description": "Настройте внешний вид веб-почты",
"theme": {
+50
View File
@@ -924,6 +924,56 @@
"advanced": "Pokročilé"
},
"appearance": {
"message_list_order": {
"label": "Poradie zoznamu správ",
"description": "Ako sú správy v zozname zoradené. Zoradenie vykonáva server, takže platí pre celý priečinok, nie len pre načítané správy.",
"preset": {
"chronological": "Chronologicky (najnovšie prvé)",
"unread_first": "Neprečítané prvé",
"starred_first": "S hviezdičkou prvé",
"tagged_first": "So štítkom prvé",
"custom": "Vlastné"
},
"tag_label": "Štítok",
"advanced_show": "Rozšírené: upraviť úrovne zoradenia",
"advanced_hide": "Skryť rozšírené",
"criterion_label": "Kritérium úrovne {n}",
"direction_label": "Smer úrovne {n}",
"criteria": {
"unread": "Neprečítané",
"starred": "S hviezdičkou",
"tag": "Štítok",
"receivedAt": "Dátum prijatia",
"sentAt": "Dátum odoslania",
"from": "Odosielateľ",
"subject": "Predmet",
"size": "Veľkosť"
},
"direction": {
"unread_first": "Neprečítané prvé",
"read_first": "Prečítané prvé",
"starred_first": "S hviezdičkou prvé",
"unstarred_first": "Bez hviezdičky prvé",
"tagged_first": "So štítkom prvé",
"untagged_first": "Bez štítku prvé",
"newest_first": "Najnovšie prvé",
"oldest_first": "Najstaršie prvé",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "Najväčšie prvé",
"smallest_first": "Najmenšie prvé"
},
"add_level": "Pridať úroveň",
"remove_level": "Odstrániť úroveň",
"move_up": "Posunúť nahor",
"move_down": "Posunúť nadol",
"tiebreak_note": "Až tri úrovne; ďalšie úrovne rozhodujú pri zhode. „Najnovšie prvé“ sa vždy uplatní ako posledné.",
"scope": {
"label": "Použiť na všetky priečinky",
"description": "Ak je vypnuté, poradie platí len pre Doručenú poštu; ostatné priečinky zostávajú chronologické."
},
"unsupported_note": "Váš poštový server nepodporuje zoradenie podľa stavu prečítania, hviezdičiek ani štítkov. Tieto úrovne sa ignorujú a zoznam zostáva chronologický."
},
"title": "Vzhľad",
"description": "Prispôsobte si vzhľad a správanie webmailu",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Gelişmiş"
},
"appearance": {
"message_list_order": {
"label": "İleti listesi sıralaması",
"description": "İletilerin listede nasıl sıralandığı. Sıralamayı sunucu yapar; bu yüzden yalnızca yüklenen iletiler değil, klasörün tamamı sıralanır.",
"preset": {
"chronological": "Kronolojik (en yeni önce)",
"unread_first": "Okunmamışlar önce",
"starred_first": "Yıldızlılar önce",
"tagged_first": "Etiketliler önce",
"custom": "Özel"
},
"tag_label": "Etiket",
"advanced_show": "Gelişmiş: sıralama düzeylerini düzenle",
"advanced_hide": "Gelişmişi gizle",
"criterion_label": "{n}. düzey ölçütü",
"direction_label": "{n}. düzey yönü",
"criteria": {
"unread": "Okunmamış",
"starred": "Yıldızlı",
"tag": "Etiket",
"receivedAt": "Alınma tarihi",
"sentAt": "Gönderilme tarihi",
"from": "Gönderen",
"subject": "Konu",
"size": "Boyut"
},
"direction": {
"unread_first": "Okunmamışlar önce",
"read_first": "Okunmuşlar önce",
"starred_first": "Yıldızlılar önce",
"unstarred_first": "Yıldızsızlar önce",
"tagged_first": "Etiketliler önce",
"untagged_first": "Etiketsizler önce",
"newest_first": "En yeni önce",
"oldest_first": "En eski önce",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "En büyük önce",
"smallest_first": "En küçük önce"
},
"add_level": "Düzey ekle",
"remove_level": "Düzeyi kaldır",
"move_up": "Yukarı taşı",
"move_down": "Aşağı taşı",
"tiebreak_note": "En fazla üç düzey; sonraki düzeyler eşitlikleri çözer. \"En yeni önce\" her zaman en son uygulanır.",
"scope": {
"label": "Tüm klasörlere uygula",
"description": "Kapalıyken sıralama yalnızca Gelen Kutusu için geçerlidir; diğer klasörler kronolojik kalır."
},
"unsupported_note": "Posta sunucunuz okunma durumu, yıldız veya etikete göre sıralamayı desteklemiyor. Bu düzeyler yok sayılır ve liste kronolojik kalır."
},
"title": "Görünüm",
"description": "Web postanızın görünümünü özelleştirin",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "Додатково"
},
"appearance": {
"message_list_order": {
"label": "Порядок списку повідомлень",
"description": "Як упорядковано повідомлення в списку. Сортування виконує сервер, тому воно охоплює всю теку, а не лише завантажені повідомлення.",
"preset": {
"chronological": "Хронологічно (спочатку нові)",
"unread_first": "Спочатку непрочитані",
"starred_first": "Спочатку із зірочкою",
"tagged_first": "Спочатку з міткою",
"custom": "Власний"
},
"tag_label": "Мітка",
"advanced_show": "Додатково: змінити рівні сортування",
"advanced_hide": "Сховати додаткові",
"criterion_label": "Критерій рівня {n}",
"direction_label": "Напрямок рівня {n}",
"criteria": {
"unread": "Непрочитані",
"starred": "Із зірочкою",
"tag": "Мітка",
"receivedAt": "Дата отримання",
"sentAt": "Дата надсилання",
"from": "Відправник",
"subject": "Тема",
"size": "Розмір"
},
"direction": {
"unread_first": "Спочатку непрочитані",
"read_first": "Спочатку прочитані",
"starred_first": "Спочатку із зірочкою",
"unstarred_first": "Спочатку без зірочки",
"tagged_first": "Спочатку з міткою",
"untagged_first": "Спочатку без мітки",
"newest_first": "Спочатку нові",
"oldest_first": "Спочатку старі",
"a_to_z": "А → Я",
"z_to_a": "Я → А",
"largest_first": "Спочатку великі",
"smallest_first": "Спочатку малі"
},
"add_level": "Додати рівень",
"remove_level": "Вилучити рівень",
"move_up": "Вище",
"move_down": "Нижче",
"tiebreak_note": "До трьох рівнів; наступні рівні вирішують за рівності. «Спочатку нові» завжди застосовується останнім.",
"scope": {
"label": "Застосувати до всіх тек",
"description": "Якщо вимкнено, порядок діє лише для теки «Вхідні»; інші теки лишаються хронологічними."
},
"unsupported_note": "Ваш поштовий сервер не підтримує сортування за станом прочитання, зірочками чи мітками. Ці рівні ігноруються, і список лишається хронологічним."
},
"title": "Зовнішній вигляд",
"description": "Налаштуйте зовнішній вигляд вашої веб-пошти",
"theme": {
+50
View File
@@ -921,6 +921,56 @@
"advanced": "高级"
},
"appearance": {
"message_list_order": {
"label": "邮件列表排序",
"description": "列表中邮件的排序方式。排序由服务器完成,因此作用于整个文件夹,而不仅是已加载的邮件。",
"preset": {
"chronological": "按时间(最新在前)",
"unread_first": "未读优先",
"starred_first": "星标优先",
"tagged_first": "标签优先",
"custom": "自定义"
},
"tag_label": "标签",
"advanced_show": "高级:编辑排序层级",
"advanced_hide": "隐藏高级选项",
"criterion_label": "第 {n} 级条件",
"direction_label": "第 {n} 级方向",
"criteria": {
"unread": "未读",
"starred": "星标",
"tag": "标签",
"receivedAt": "接收日期",
"sentAt": "发送日期",
"from": "发件人",
"subject": "主题",
"size": "大小"
},
"direction": {
"unread_first": "未读在前",
"read_first": "已读在前",
"starred_first": "星标在前",
"unstarred_first": "无星标在前",
"tagged_first": "有标签在前",
"untagged_first": "无标签在前",
"newest_first": "最新在前",
"oldest_first": "最早在前",
"a_to_z": "A → Z",
"z_to_a": "Z → A",
"largest_first": "最大在前",
"smallest_first": "最小在前"
},
"add_level": "添加层级",
"remove_level": "移除层级",
"move_up": "上移",
"move_down": "下移",
"tiebreak_note": "最多三个层级;后续层级用于打破平局。“最新在前”始终最后应用。",
"scope": {
"label": "应用于所有文件夹",
"description": "关闭时,排序仅应用于收件箱,其他文件夹保持按时间排序。"
},
"unsupported_note": "您的邮件服务器不支持按已读状态、星标或标签排序。这些层级将被忽略,列表保持按时间排序。"
},
"title": "外观",
"description": "自定义邮箱的外观与风格",
"theme": {
+33 -10
View File
@@ -2,8 +2,9 @@ import { create } from "zustand";
import { Email, Mailbox, StateChange, ScheduledEmail, SendEmailResult, isUnifiedMailboxId, isCrossViewId } from "@/lib/jmap/types";
import type { UnifiedMailboxRole, CrossView } from "@/lib/jmap/types";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useSettingsStore } from "@/stores/settings-store";
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
import { useCalendarStore } from "@/stores/calendar-store";
import type { SortLevel } from "@/lib/message-list-order";
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
import { emailHooks } from "@/lib/plugin-hooks";
import { resolveThreadRoute } from "@/lib/thread-routing";
@@ -67,6 +68,9 @@ interface EmailStore {
retainedInViewIds: Set<string>;
hasMoreEmails: boolean; // Track if more emails are available to load
totalEmails: number; // Total number of emails in the current mailbox/query
// The configured message-list order the current folder view was fetched
// with (#718); empty for chronological. Thread grouping mirrors it.
listOrder: SortLevel[];
isPushConnected: boolean; // Track if push notifications are connected
lastPushUpdate: number | null; // Timestamp of last push update
newEmailNotification: Email | null; // New email notification for toast
@@ -1064,6 +1068,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
lastSelectedEmailId: null,
hasMoreEmails: false,
totalEmails: 0,
listOrder: [],
isPushConnected: false,
lastPushUpdate: null,
newEmailNotification: null,
@@ -1362,12 +1367,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? await advancedSearchUnifiedEmails(built, unifiedRole!, (mb) => buildJMAPFilter(searchQuery, searchFilters, mb), emailsPerPage, 0)
: searchQuery
? await searchUnifiedEmails(built, unifiedRole!, searchQuery, emailsPerPage, 0)
: await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0));
: await fetchUnifiedEmails(built, unifiedRole!, emailsPerPage, 0, getMessageListOrderFor(unifiedRole)));
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
listOrder: crossView || hasFilters || searchQuery ? [] : getMessageListOrderFor(unifiedRole),
threadEmailsCache: new Map(),
expandedThreadIds: new Set(),
isLoadingThread: null,
@@ -1400,6 +1406,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? null
: useMessageListTabsStore.getState().getCategoryFilter(mailbox?.role);
// The configured list order (#718). A tag view spans folders and has no
// role, so it only follows the order under the "all folders" scope.
const order = getMessageListOrderFor(selectedKeyword ? null : mailbox?.role);
// When filtering by tag, omit the mailbox constraint so emails across
// all folders that carry the tag are returned.
const result = await effectiveClient.getEmails(
@@ -1410,12 +1420,14 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
keywordFilter,
true,
categoryFilter ?? undefined,
order,
);
const enrichedEmails = await emailHooks.onEmailsFetched.transform(result.emails);
set({
emails: annotateScheduledEmails(enrichedEmails, get().scheduledSubmissionByEmailId),
hasMoreEmails: result.hasMore,
totalEmails: result.total,
listOrder: order,
// Clear thread caches since the email list was fully replaced
threadEmailsCache: new Map(),
expandedThreadIds: new Set(),
@@ -1506,7 +1518,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
)
: searchQuery
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, position)
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position);
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, position, getMessageListOrderFor(unifiedRole));
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
const newEmails = result.emails.filter(e => !existingIds.has(e.id));
@@ -1587,6 +1599,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
selectedKeyword ? `$label:${selectedKeyword}` : undefined,
true,
categoryFilter ?? undefined,
getMessageListOrderFor(selectedKeyword ? null : mailbox?.role),
);
}
@@ -3330,7 +3343,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
? await advancedSearchUnifiedEmails(built, unifiedRole, (mailboxId) => buildJMAPFilter(searchQuery, searchFilters, mailboxId), emailsPerPage, 0)
: searchQuery
? await searchUnifiedEmails(built, unifiedRole, searchQuery, emailsPerPage, 0)
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, 0);
: await fetchUnifiedEmails(built, unifiedRole, emailsPerPage, 0, getMessageListOrderFor(unifiedRole));
unifiedErrors = result.errors;
} else {
// Single real mailbox (own or shared): query by its JMAP id. Refresh the
@@ -3349,7 +3362,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const scopeAccountId = scopeMailbox?.isShared ? scopeMailbox.accountId : undefined;
result = await effectiveClient.advancedSearchEmails(filter, scopeAccountId, emailsPerPage, 0);
} else {
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true);
result = await effectiveClient.getEmails(jmapMailboxId, accountId, emailsPerPage, 0, undefined, true, undefined, getMessageListOrderFor(mailbox?.role));
}
}
@@ -3360,9 +3373,17 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// Without these guards the toast/sound also fires when sending,
// saving drafts, or moving/deleting the top message in any mailbox,
// because all of those change the first-email id of the current view.
// Pinned mails sit above the date order, so the newest mail is the
// first NON-pinned entry (a just-arrived mail cannot be pinned yet).
const newFirst = result.emails.find(e => !e.keywords?.['$pinned']) ?? result.emails[0];
// Pinned mails sit above the date order, and a configured list order
// (#718, e.g. starred first) can put older mail on top too, so the
// candidate is the newest non-pinned mail on the page rather than its
// first entry (a just-arrived mail cannot be pinned yet). Deleting the
// top message shifts in an OLDER one, which never becomes the newest.
const newFirst = result.emails
.filter(e => !e.keywords?.['$pinned'])
.reduce<Email | undefined>(
(newest, e) => (!newest || new Date(e.receivedAt).getTime() > new Date(newest.receivedAt).getTime() ? e : newest),
undefined,
) ?? result.emails[0];
if (
newFirst &&
mailbox?.role === 'inbox' &&
@@ -4016,11 +4037,13 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
});
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0);
const order = getMessageListOrderFor(role);
const result = await fetchUnifiedEmails(accounts, role, emailsPerPage, 0, order);
set({
emails: result.emails,
hasMoreEmails: result.hasMore,
totalEmails: result.total,
listOrder: order,
isLoading: false,
unifiedErrors: result.errors,
});
@@ -4044,7 +4067,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
try {
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
const position = emails.length;
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position);
const result = await fetchUnifiedEmails(accounts, unifiedRole, emailsPerPage, position, getMessageListOrderFor(unifiedRole));
const currentEmails = get().emails;
const existingIds = new Set(currentEmails.map(e => e.id));
+34
View File
@@ -6,6 +6,7 @@ import type { EmailTemplate } from '@/lib/template-types';
import type { NotificationSoundChoice } from '@/lib/notification-sound';
import { apiFetch } from '@/lib/browser-navigation';
import { generateAccountId } from '@/lib/account-utils';
import { orderForMailbox, sanitizeSortLevels, type MessageListOrderScope, type SortLevel } from '@/lib/message-list-order';
import {
DEFAULT_SUB_ADDRESS_DELIMITER,
isValidSubAddressDelimiter,
@@ -303,6 +304,10 @@ interface SettingsState {
fontSize: FontSize;
density: Density;
animationsEnabled: boolean;
// Message-list ordering (#718): prioritised sort levels mapped onto the JMAP
// Email/query sort; empty = chronological. Scope: Inbox only or every folder.
messageListOrder: SortLevel[];
messageListOrderScope: MessageListOrderScope;
// Language & Region
dateFormat: DateFormat;
@@ -537,6 +542,8 @@ const DEFAULT_SETTINGS = {
fontSize: 'medium' as FontSize,
density: 'regular' as Density,
animationsEnabled: true,
messageListOrder: [] as SortLevel[],
messageListOrderScope: 'inbox' as MessageListOrderScope,
// Language & Region
dateFormat: 'smart' as DateFormat,
@@ -765,6 +772,8 @@ export const useSettingsStore = create<SettingsState>()(
fontSize: state.fontSize,
density: state.density,
animationsEnabled: state.animationsEnabled,
messageListOrder: state.messageListOrder,
messageListOrderScope: state.messageListOrderScope,
dateFormat: state.dateFormat,
dateLocale: state.dateLocale,
timeFormat: state.timeFormat,
@@ -909,6 +918,16 @@ export const useSettingsStore = create<SettingsState>()(
if (key === 'preferredIdentityIds' && !isPlainRecord(settings[key])) {
return;
}
// The list order is sent to the server as a sort array, so an
// unknown criterion from a newer/older client must never get
// through (an unsupportedSort refusal empties the folder).
if (key === 'messageListOrder') {
set({ messageListOrder: sanitizeSortLevels(settings[key]) });
return;
}
if (key === 'messageListOrderScope' && settings[key] !== 'inbox' && settings[key] !== 'all') {
return;
}
if (DEVICE_LOCAL_SETTING_KEYS.has(key)) {
return;
}
@@ -1161,6 +1180,10 @@ export const useSettingsStore = create<SettingsState>()(
if (!isPlainRecord(state.preferredIdentityIds)) {
state.preferredIdentityIds = {};
}
state.messageListOrder = sanitizeSortLevels(state.messageListOrder);
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
state.messageListOrderScope = 'inbox';
}
applyFontSize(state.fontSize);
applyDensity(state.density);
applyAnimations(state.animationsEnabled);
@@ -1363,3 +1386,14 @@ if (typeof window !== 'undefined') {
// templates.
void import('./template-store');
}
/**
* The message-list order to request for a folder with the given role (#718):
* the configured levels when they apply to every folder or this is the Inbox,
* chronological otherwise. Pass null for views without a folder role (tag
* views, search) - those only pick the order up under the "all folders" scope.
*/
export function getMessageListOrderFor(role: string | null | undefined): SortLevel[] {
const { messageListOrder, messageListOrderScope } = useSettingsStore.getState();
return orderForMailbox(messageListOrder, messageListOrderScope, role);
}