- {showPreview && density !== 'extra-compact' && density !== 'compact' && (
+ {showPreview && !previewOnSubjectLine && density !== 'extra-compact' && density !== 'compact' && (
onMarkAsSpam(latestEmail) : undefined}
onUndoSpam={onUndoSpam ? () => onUndoSpam(latestEmail) : undefined}
isInJunk={currentMailboxRole === 'junk'}
- spamApplicable={!['sent', 'drafts', 'scheduled'].includes(currentMailboxRole || '')}
+ spamApplicable={spamApplicableIn(currentMailboxRole)}
mailboxRole={currentMailboxRole ?? null}
pinToFirstLine={mvRow}
/>
diff --git a/components/favicon-badge.tsx b/components/favicon-badge.tsx
index 1bc199925..a961d0f29 100644
--- a/components/favicon-badge.tsx
+++ b/components/favicon-badge.tsx
@@ -1,13 +1,9 @@
"use client";
-import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
import { useAppBadge } from "@/hooks/use-app-badge";
-import { useShallow } from "zustand/react/shallow";
-import { useAccountStore } from "@/stores/account-store";
-import { useAuthStore } from "@/stores/auth-store";
-import { totalInboxUnread } from "@/lib/account-inbox-unread";
+import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread";
/**
* Badges the browser-tab favicon, and — where supported — the installed PWA's
@@ -31,33 +27,14 @@ import { totalInboxUnread } from "@/lib/account-inbox-unread";
* Renders nothing.
*/
export function FaviconBadge() {
- // The store's canonical inbox selector. `role === 'inbox'` alone is not
- // enough: shared and group inboxes ship in the same `mailboxes` array, so on
- // a delegated setup the first match can be somebody else's inbox.
- const inboxUnread = useEmailStore(
- (s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
- );
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
- const allAccounts = useSettingsStore((s) => s.unreadBadgeAllAccounts);
// With several accounts the badge counts the inbox of each (on by default):
// new mail in an account that is not on screen still shows on the icon and
- // in the desktop tray. The other accounts' folder lists are what the
- // account switcher reads (useProMultiAccountMailboxes); one not loaded yet
- // adds nothing.
- const othersUnread = useOtherAccountsUnread(allAccounts);
- const count = inboxUnread + othersUnread;
+ // in the desktop tray.
+ const count = useAllInboxesUnread();
useFaviconBadge(count, enabled);
useAppBadge(count, enabled);
return null;
}
-
-function useOtherAccountsUnread(enabled: boolean): number {
- const activeAccountId = useAuthStore((s) => s.activeAccountId);
- const otherIds = useAccountStore(
- useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])),
- );
- const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
- return totalInboxUnread(otherIds, accountMailboxes);
-}
diff --git a/components/layout/__tests__/mailbox-context-menu-order.test.tsx b/components/layout/__tests__/mailbox-context-menu-order.test.tsx
index 947fb0062..54daf6a4b 100644
--- a/components/layout/__tests__/mailbox-context-menu-order.test.tsx
+++ b/components/layout/__tests__/mailbox-context-menu-order.test.tsx
@@ -12,7 +12,7 @@ const { MailboxContextMenu } = await import('../mailbox-context-menu');
const folder = { id: 'p', name: 'Projects', sortOrder: 1, totalEmails: 2, unreadEmails: 0, myRights: {} } as unknown as Mailbox;
describe('MailboxContextMenu', () => {
- it('ends with Empty folder and Delete folder', () => {
+ it('ends with Delete folder', () => {
render(
{
/>,
);
const labels = screen.getAllByRole('menuitem').map((item) => item.textContent);
- expect(labels.slice(-2)).toEqual(['empty_folder_generic', 'delete_folder']);
+ // A folder of your own has no Empty: it is offered on Trash and Spam only.
+ expect(labels.slice(-1)).toEqual(['delete_folder']);
expect(labels).toContain('refresh');
});
});
diff --git a/components/layout/__tests__/mailbox-context-menu-states.test.tsx b/components/layout/__tests__/mailbox-context-menu-states.test.tsx
index 5f6447986..63c89f3b9 100644
--- a/components/layout/__tests__/mailbox-context-menu-states.test.tsx
+++ b/components/layout/__tests__/mailbox-context-menu-states.test.tsx
@@ -63,7 +63,7 @@ describe('folder menu states', () => {
const folder = box('f', 2);
renderMenu(folder, [folder]);
expect(screen.getByText('offline_header')).toBeInTheDocument();
- for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'empty_folder_generic', 'delete_folder']) {
+ for (const name of ['new_subfolder', 'rename', 'menu_import', 'refresh', 'delete_folder']) {
expect(item(name).disabled).toBe(true);
}
// No mail kept on this device: marking read would fail too.
diff --git a/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx b/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx
new file mode 100644
index 000000000..617e43223
--- /dev/null
+++ b/components/layout/__tests__/mailbox-context-menu-system-folders.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// Usability test 2026-11-04, mail-20 and pass 2 p2-14: Trash and Spam
+// showed Rename and Delete folder greyed out, "Empty folder" instead of
+// "Empty Trash", and Rules and Mute notifications; every folder, the Inbox
+// included, offered "Empty folder".
+
+vi.mock('@/components/email/rules-menu', () => ({ FolderRulesSubMenu: () => rules
}));
+
+const { MailboxContextMenu } = await import('../mailbox-context-menu');
+
+const box = (id: string, role: string | null) =>
+ ({ id, name: id, role, parentId: null, sortOrder: 1, totalEmails: 5, unreadEmails: 0, myRights: {} }) as unknown as Mailbox;
+
+function labels(mailbox: Mailbox) {
+ render(
+ false,
+ isAccountMuted: () => false,
+ hasFolderSettings: () => false,
+ setMailboxMuted: vi.fn(),
+ setAccountMuted: vi.fn(),
+ resetFolderSettings: vi.fn(),
+ }}
+ />,
+ );
+ return screen.getAllByRole('menuitem').map((item) => item.textContent);
+}
+
+describe('folder menu of Trash and Spam', () => {
+ it('Trash: Empty Trash last, no rename, delete, rules or mute', () => {
+ const items = labels(box('trash', 'trash'));
+ expect(items[items.length - 1]).toBe('empty_trash');
+ for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications', 'empty_folder', 'empty_folder_generic']) {
+ expect(items).not.toContain(gone);
+ }
+ });
+
+ it('Spam: Empty Spam last, no rename, delete, rules or mute', () => {
+ const items = labels(box('junk', 'junk'));
+ expect(items[items.length - 1]).toBe('empty_spam');
+ for (const gone of ['rename', 'delete_folder', 'rules', 'mute_notifications']) expect(items).not.toContain(gone);
+ });
+
+ it('Inbox: no Empty, no rename or delete, but rules and mute', () => {
+ const items = labels(box('inbox', 'inbox'));
+ for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam', 'rename', 'delete_folder']) {
+ expect(items).not.toContain(gone);
+ }
+ expect(items).toContain('rules');
+ expect(items).toContain('mute_notifications');
+ });
+
+ it('a folder of your own: rename and delete, no Empty', () => {
+ const items = labels(box('family', null));
+ expect(items).toContain('rename');
+ expect(items[items.length - 1]).toBe('delete_folder');
+ for (const gone of ['empty_folder', 'empty_folder_generic', 'empty_trash', 'empty_spam']) expect(items).not.toContain(gone);
+ });
+});
diff --git a/components/layout/__tests__/navigation-rail-badge-name.test.tsx b/components/layout/__tests__/navigation-rail-badge-name.test.tsx
index c3baba3de..bf3d0aa5b 100644
--- a/components/layout/__tests__/navigation-rail-badge-name.test.tsx
+++ b/components/layout/__tests__/navigation-rail-badge-name.test.tsx
@@ -63,3 +63,29 @@ describe.each([
expect(link.textContent).not.toContain('2');
});
});
+
+// Usability test 2026-11-04, mail-6: with two accounts the rail said 10 while
+// 30 were unread in all; the favicon already counted every account.
+describe('Mail entry with several accounts', () => {
+ const twoAccounts = async () => {
+ const { useAccountStore } = await import('@/stores/account-store');
+ const { useAuthStore } = await import('@/stores/auth-store');
+ useAccountStore.setState({ accounts: [{ id: 'acc-a', isConnected: true }, { id: 'acc-b', isConnected: true }] } as never);
+ useAuthStore.setState({ activeAccountId: 'acc-a' } as never);
+ useEmailStore.setState({ accountMailboxes: { 'acc-b': [{ id: 'b-in', role: 'inbox', unreadEmails: 5 }] } } as never);
+ };
+
+ it('counts the other accounts\' inboxes too', async () => {
+ await twoAccounts();
+ useSettingsStore.setState({ unreadBadgeAllAccounts: true });
+ render( );
+ expect(screen.getAllByRole('link', { name: 'mail, 7 unread' }).length).toBeGreaterThan(0);
+ });
+
+ it('counts the open account only when Notifications says so', async () => {
+ await twoAccounts();
+ useSettingsStore.setState({ unreadBadgeAllAccounts: false });
+ render( );
+ expect(screen.getAllByRole('link', { name: 'mail, 2 unread' }).length).toBeGreaterThan(0);
+ });
+});
diff --git a/components/layout/__tests__/sidebar-folded-column.test.tsx b/components/layout/__tests__/sidebar-folded-column.test.tsx
new file mode 100644
index 000000000..02c0f30b2
--- /dev/null
+++ b/components/layout/__tests__/sidebar-folded-column.test.tsx
@@ -0,0 +1,124 @@
+import { act, render, screen, fireEvent, cleanup } from '@testing-library/react';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// Usability test 2026-11-04, pass 2 p2-9: folded to icons (600 to 1024 px),
+// the folder column showed eight identical folder icons, no unread marks,
+// and no way to reach subfolders without unfolding it.
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({
+ useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
+}));
+vi.mock('@/contexts/drag-drop-context', () => ({
+ useDragDropContext: () => ({ isDragging: false }),
+}));
+vi.mock('@/hooks/use-mailbox-drop', () => ({
+ useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
+}));
+vi.mock('@/hooks/use-tag-drop', () => ({
+ useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
+}));
+vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
+
+const { Sidebar } = await import('../sidebar');
+const { useAuthStore } = await import('@/stores/auth-store');
+const { useSettingsStore } = await import('@/stores/settings-store');
+const { useUIStore } = await import('@/stores/ui-store');
+
+const box = (id: string, name: string, role: string | null, extra: Partial = {}) => ({
+ id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
+}) as unknown as Mailbox;
+
+const mailboxes: Mailbox[] = [
+ box('inbox', 'Inbox', 'inbox', { totalEmails: 120, unreadEmails: 4 }),
+ box('trash', 'Trash', 'trash'),
+ box('news', 'Newsletters', null, { totalEmails: 30, unreadEmails: 2 }),
+ box('receipts', 'Receipts', null, { totalEmails: 5 }),
+ box('y2026', '2026', null, { parentId: 'receipts', totalEmails: 3 }),
+];
+
+const peek = () => document.querySelector('[data-folder-peek]');
+
+describe('folded folder column (default layout)', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ useAuthStore.setState({ activeAccountId: 'login-a', client: { getAccountId: () => 'me', getCapabilities: () => ({}) } } as never);
+ useSettingsStore.setState({ hideAccountSwitcher: true, interfaceLayout: 'default', openOnHover: true, unreadCountStyle: 'count' } as never);
+ useUIStore.setState({ sidebarCollapsed: true } as never);
+ });
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ useUIStore.setState({ sidebarCollapsed: false } as never);
+ });
+
+ it('tells folders of one\'s own apart by their first letter', () => {
+ render( );
+ const initials = screen.getAllByTestId('folder-initial').map((el) => el.textContent);
+ expect(initials).toEqual(expect.arrayContaining(['N', 'R']));
+ // System folders keep their own icons.
+ expect(initials).toHaveLength(2);
+ });
+
+ it('shows the unread count on the icon', () => {
+ render( );
+ const inbox = document.querySelector('[data-mailbox-id="inbox"]')!;
+ expect(inbox.querySelector('[data-testid="folded-unread"]')?.textContent).toBe('4');
+ const trash = document.querySelector('[data-mailbox-id="trash"]')!;
+ expect(trash.querySelector('[data-testid="folded-unread"]')).toBeNull();
+ });
+
+ it('shows a dot when counts are dots', () => {
+ useSettingsStore.setState({ unreadCountStyle: 'dot' } as never);
+ render( );
+ const mark = document.querySelector('[data-mailbox-id="news"] [data-testid="folded-unread"]');
+ expect(mark).not.toBeNull();
+ expect(mark!.textContent).toBe('');
+ });
+
+ it('opens the whole tree from All folders, keyboard included, and Escape folds it back', () => {
+ render( );
+ expect(screen.queryByText('Receipts')).toBeNull();
+ fireEvent.click(screen.getByTestId('folded-all-folders'));
+ expect(peek()).not.toBeNull();
+ expect(screen.getByText('Receipts')).toBeTruthy();
+ // The focus is in the tree, on the open folder.
+ expect(document.activeElement?.closest('[data-mailbox-id]')?.getAttribute('data-mailbox-id')).toBe('inbox');
+ // Subfolders are reachable: Receipts > 2026.
+ expect(screen.getByText('2026')).toBeTruthy();
+ fireEvent.keyDown(document.activeElement!, { key: 'Escape' });
+ expect(peek()).toBeNull();
+ });
+
+ it('folds the tree away after a pick', () => {
+ const { rerender } = render( );
+ fireEvent.click(screen.getByTestId('folded-all-folders'));
+ expect(peek()).not.toBeNull();
+ rerender( );
+ expect(peek()).toBeNull();
+ });
+
+ it('folds it away on a press outside', () => {
+ render(<>elsewhere >);
+ fireEvent.click(screen.getByTestId('folded-all-folders'));
+ fireEvent.pointerDown(screen.getByText('elsewhere'));
+ expect(peek()).toBeNull();
+ });
+
+ it('floats out after the pointer rests on it, not at once, and not with hover opening off', () => {
+ vi.useFakeTimers();
+ const { container } = render( );
+ fireEvent.mouseEnter(container.firstElementChild!);
+ expect(peek()).toBeNull();
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(peek()).not.toBeNull();
+ fireEvent.mouseLeave(container.firstElementChild!);
+ expect(peek()).toBeNull();
+
+ useSettingsStore.setState({ openOnHover: false } as never);
+ fireEvent.mouseEnter(container.firstElementChild!);
+ act(() => { vi.advanceTimersByTime(400); });
+ expect(peek()).toBeNull();
+ });
+});
diff --git a/components/layout/mailbox-context-menu.tsx b/components/layout/mailbox-context-menu.tsx
index 8aee8b210..da1ff59da 100644
--- a/components/layout/mailbox-context-menu.tsx
+++ b/components/layout/mailbox-context-menu.tsx
@@ -159,6 +159,7 @@ export function MailboxContextMenu({
notifications,
}: MailboxContextMenuProps) {
const t = useTranslations("mailbox_context_menu");
+ const tP2 = useTranslations("mail_p2_folders");
const tSidebar = useTranslations("sidebar");
const tImport = useTranslations("mail_import");
const tExport = useTranslations("mail_export");
@@ -251,6 +252,8 @@ export function MailboxContextMenu({
}
const mailbox = target.mailbox;
+ // Emptying is offered where people expect it, as "Empty Trash" and "Empty
+ // Spam"; rules and notifications mean nothing there (mail-20, pass 2 p2-14).
const isTrashOrJunk = mailbox.role === "trash" || mailbox.role === "junk";
const isSystem =
!!mailbox.role &&
@@ -305,13 +308,16 @@ export function MailboxContextMenu({
disabled={!onCreateSubfolder || !canCreateChild || offline}
testId="mailbox-new-subfolder"
/>
- handleAction(() => onRenameFolder?.(mailbox.id))}
- disabled={!onRenameFolder || !canRename || offline}
- testId="mailbox-rename"
- />
+ {/* The server's own folders can't be renamed or deleted: not shown greyed out. */}
+ {!isSystem && (
+ handleAction(() => onRenameFolder?.(mailbox.id))}
+ disabled={!onRenameFolder || !canRename || offline}
+ testId="mailbox-rename"
+ />
+ )}
{onShareFolder && (
)}
-
- {notifications && (() => {
+ {!isTrashOrJunk && }
+ {notifications && !isTrashOrJunk && (() => {
const muted = notifications.isMailboxMuted(mailbox);
return (
{/* Destructive entries last (R6). */}
-
+ {(isTrashOrJunk || !isSystem) && }
- handleAction(() => onEmptyFolder?.(mailbox.id))}
- disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
- destructive
- />
- handleAction(() => onDeleteFolder?.(mailbox.id))}
- disabled={!onDeleteFolder || !canDelete || offline}
- destructive
- testId="mailbox-delete"
- />
+ {isTrashOrJunk && (
+ handleAction(() => onEmptyFolder?.(mailbox.id))}
+ disabled={!onEmptyFolder || mailbox.totalEmails === 0 || !canRemoveItems || offline}
+ destructive
+ testId="mailbox-empty"
+ />
+ )}
+ {!isSystem && (
+ handleAction(() => onDeleteFolder?.(mailbox.id))}
+ disabled={!onDeleteFolder || !canDelete || offline}
+ destructive
+ testId="mailbox-delete"
+ />
+ )}
);
}
diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx
index c123da7f6..e36d84345 100644
--- a/components/layout/navigation-rail.tsx
+++ b/components/layout/navigation-rail.tsx
@@ -12,7 +12,6 @@ import { useShallow } from "zustand/react/shallow";
import { usePathname, Link, useRouter } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { useCalendarStore } from "@/stores/calendar-store";
-import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { usePolicyStore } from "@/stores/policy-store";
import { useResolvedSidebarApps } from "@/hooks/use-resolved-sidebar-apps";
@@ -34,6 +33,7 @@ import { toUnicodeEmail } from "@/lib/idn";
import { usePluginPages, pluginPageHref, isPluginPageActive } from "@/hooks/use-plugin-pages";
import { NotificationBell } from "@/components/notifications/notification-center";
import { useBottomControls } from "@/hooks/use-bottom-controls";
+import { useAllInboxesUnread } from "@/hooks/use-all-inboxes-unread";
import { writeRailHints, type RailHint } from "@/lib/boot-skeleton/hints";
interface NavItem {
@@ -260,7 +260,6 @@ export function NavigationRail({
const { appLogoLightUrl, appLogoDarkUrl } = useConfig();
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const { supportsCalendar } = useCalendarStore(useShallow((state) => ({ supportsCalendar: state.supportsCalendar })));
- const { mailboxes } = useEmailStore(useShallow((state) => ({ mailboxes: state.mailboxes })));
const client = useAuthStore((s) => s.client);
const supportsFiles = client?.supportsFiles() ?? false;
const supportsContacts = client?.supportsContacts() ?? false;
@@ -277,7 +276,9 @@ export function NavigationRail({
const visibleSidebarApps = useResolvedSidebarApps();
// Pages plugins added (api.ui.setPages); listed after the built-in apps.
const pluginPages = usePluginPages();
- const inboxUnread = mailboxes.find(m => m.role === "inbox")?.unreadEmails || 0;
+ // Every signed-in account's inbox, as the favicon and the tab title count
+ // it (mail-6), unless Notifications says to count the open one only.
+ const inboxUnread = useAllInboxesUnread();
const isStalwartAdmin = useStalwartAdmin();
const hasUpdate = useUpdateStore(selectHasUpdate);
const updateSeverity = useUpdateStore((s) => s.status?.severity);
diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx
index 3b41225c1..b07adb2d4 100644
--- a/components/layout/sidebar.tsx
+++ b/components/layout/sidebar.tsx
@@ -22,6 +22,7 @@ import {
ChevronUp,
Folder,
FolderOpen,
+ FolderSync,
User,
Users,
Palmtree,
@@ -172,6 +173,8 @@ interface SidebarProps {
const ROW_PX_BASE = FOLDER_ROW_PX_BASE;
const CHEVRON_SLOT = FOLDER_CHEVRON_SLOT;
const INDENT_STEP = 12;
+/** How long the pointer rests on the folded column before the tree floats out. */
+const RAIL_HOVER_DELAY_MS = 350;
// Icons and colours follow the JMAP role only.
const getIconForMailbox = (role?: string, hasChildren?: boolean, isExpanded?: boolean, id?: string) => {
@@ -497,8 +500,22 @@ function SidebarRow({
)}
title={isCollapsed ? label : undefined}
>
-
+
{icon}
+ {/* Folded to icons, the count rides on the icon (p2-9); the name says it already. */}
+ {isCollapsed && unreadForName > 0 && (
+ unreadCountStyle === 'dot' ? (
+
+ ) : (
+
+ {unreadForName > 99 ? '99+' : unreadForName}
+
+ )
+ )}
{!isCollapsed && (
<>
@@ -548,6 +565,20 @@ function SidebarRow({
);
}
+/** A folder of one's own on the folded strip: its first letter in a small box. */
+function FolderInitial({ name, className }: { name: string; className?: string }) {
+ const initial = Array.from(name.trim())[0]?.toLocaleUpperCase() ?? '?';
+ return (
+
+ {initial}
+
+ );
+}
+
/** A folder the server gave a role (inbox, sent, trash...), as opposed to one the user made. */
const isSystemFolder = (node: MailboxNode) => !!node.role;
/** Key of the single-account "Your folders" section in the collapsed set. */
@@ -709,6 +740,9 @@ function MailboxTreeItem({
const isVirtualNode = node.id.startsWith('shared-');
const isSelected = selectedMailbox === node.id;
const label = sharedAccountName ?? localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
+ // Folded to icons, eight folders of one's own were eight identical folder
+ // icons (p2-9): each shows its first letter instead.
+ const letterIcon = isCollapsed && !node.role && !sharedAccountName && !isVirtualNode;
// Only for the active login's folders: its client tells a missing
// accountId (own mailbox) apart.
const muted = useMutedMailbox(isVirtualNode || dragAccountId || pickOnly ? null : node);
@@ -752,7 +786,9 @@ function MailboxTreeItem({
return (
<>
}
+ icon={letterIcon
+ ?
+ : }
label={label}
testRole={node.role}
testName={node.name}
@@ -1177,8 +1213,23 @@ function SidebarImpl({
// floats rather than widening the column, or every hover would reflow the
// list behind it.
const [railHovered, setRailHovered] = useState(false);
- const hoverExpanded = mvShell && sidebarCollapsed && railHovered;
+ // The default layout's folded column floats the whole tree out too, so
+ // subfolders and folder names are a hover or a click away (p2-9). A hover
+ // waits a moment there, so passing over the column on the way to the list
+ // doesn't open it; "All folders" opens it by click, tap or keyboard.
+ const [railOpen, setRailOpen] = useState(false);
+ const railHoverTimer = useRef | null>(null);
+ const rootRef = useRef(null);
+ const allFoldersRef = useRef(null);
+ const hoverExpanded = sidebarCollapsed && !isDrawer && (railHovered || railOpen);
const isCollapsed = sidebarCollapsed && !hoverExpanded && !isDrawer;
+ const closeRailPeek = useCallback(() => {
+ if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
+ railHoverTimer.current = null;
+ setRailHovered(false);
+ setRailOpen(false);
+ }, []);
+ useEffect(() => () => { if (railHoverTimer.current) clearTimeout(railHoverTimer.current); }, []);
const { primaryIdentity: _primaryIdentity, activeAccountId } = useAuthStore(useShallow((state) => ({ primaryIdentity: state.primaryIdentity, activeAccountId: state.activeAccountId })));
const [expandedFolders, setExpandedFolders] = useState>(new Set());
const [expandedTags, setExpandedTags] = useState>(new Set());
@@ -1276,6 +1327,31 @@ function SidebarImpl({
crossAccountActive, restoring: restoringAccounts, scope: unifiedScope, accounts,
}), [crossAccountActive, restoringAccounts, accounts, unifiedScope]);
const t = useTranslations('sidebar');
+ const tP2 = useTranslations('mail_p2_folders');
+
+ // A pick in the floated tree folds it away again (the hover peek of the
+ // default layout, and the one "All folders" opened).
+ const pickedRef = useRef({ mailbox: selectedMailbox, keyword: selectedKeyword });
+ useEffect(() => {
+ const before = pickedRef.current;
+ pickedRef.current = { mailbox: selectedMailbox, keyword: selectedKeyword };
+ if (before.mailbox === selectedMailbox && before.keyword === selectedKeyword) return;
+ if (railOpen || !mvShell) closeRailPeek();
+ }, [selectedMailbox, selectedKeyword, railOpen, mvShell, closeRailPeek]);
+ // Opened from "All folders": the focus goes to the open folder in the tree,
+ // and a press anywhere outside folds it away.
+ useEffect(() => {
+ if (!railOpen) return;
+ const root = rootRef.current;
+ const target = root?.querySelector('nav [aria-current="page"]')
+ ?? root?.querySelector('nav button:not([disabled])');
+ target?.focus();
+ const onPointerDown = (e: PointerEvent) => {
+ if (root && !root.contains(e.target as Node)) closeRailPeek();
+ };
+ document.addEventListener('pointerdown', onPointerDown, true);
+ return () => document.removeEventListener('pointerdown', onPointerDown, true);
+ }, [railOpen, closeRailPeek]);
useEffect(() => {
const stored = localStorage.getItem('expandedMailboxes');
@@ -1902,10 +1978,32 @@ function SidebarImpl({
return (
{ if (hoverOpens()) setRailHovered(true); } : undefined}
- onMouseLeave={mvShell && sidebarCollapsed ? () => setRailHovered(false) : undefined}
- data-mv-rail-peek={hoverExpanded ? "" : undefined}
+ onMouseEnter={sidebarCollapsed && !isDrawer ? () => {
+ if (!hoverOpens()) return;
+ if (mvShell) { setRailHovered(true); return; }
+ if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
+ railHoverTimer.current = setTimeout(() => setRailHovered(true), RAIL_HOVER_DELAY_MS);
+ } : undefined}
+ onMouseLeave={sidebarCollapsed && !isDrawer ? () => {
+ if (railHoverTimer.current) clearTimeout(railHoverTimer.current);
+ railHoverTimer.current = null;
+ setRailHovered(false);
+ } : undefined}
+ onKeyDown={railOpen || railHovered ? (e) => {
+ if (e.key !== 'Escape' || e.defaultPrevented) return;
+ e.preventDefault();
+ const hadFocus = rootRef.current?.contains(document.activeElement);
+ closeRailPeek();
+ if (hadFocus) requestAnimationFrame(() => allFoldersRef.current?.focus());
+ } : undefined}
+ onBlur={railOpen ? (e) => {
+ const next = e.relatedTarget as Node | null;
+ if (next && !e.currentTarget.contains(next)) closeRailPeek();
+ } : undefined}
+ data-mv-rail-peek={hoverExpanded && mvShell ? "" : undefined}
+ data-folder-peek={hoverExpanded && !mvShell ? "" : undefined}
className={cn(
"relative flex flex-col h-full border-e transition-all duration-300 overflow-hidden",
// Mountain View's navigation sits on the canvas, with no rule between
@@ -1914,7 +2012,7 @@ function SidebarImpl({
"max-desk:w-full",
// Mountain View's collapsed column is 72px (--mv-rail-w), wide enough
// for its round compose button; the default layout's rail is 48px.
- isCollapsed && !mvShell ? "desk:w-12" : "desk:w-full",
+ isCollapsed && !mvShell ? "desk:w-12" : hoverExpanded && !mvShell ? "desk:w-64" : "desk:w-full",
// Floats out of the rail's own column rather than widening it, so the
// message list underneath never moves while the pointer passes over.
// Width comes from --mv-rail-peek-w, keyed on data-mv-rail-peek.
@@ -2288,6 +2386,20 @@ function SidebarImpl({
{!isCollapsed &&
}
+ {/* Folded: the whole tree, subfolders included, one click away (p2-9). */}
+ {isCollapsed && !mvShell && (
+
+ setRailOpen(true)}
+ >
+
+
+
+ )}
+
({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
+// What the stub composer's close does: 'saves' closes and lets the next one
+// open, 'fails' keeps the composer (a save the server refused).
+const stub = vi.hoisted(() => ({
+ close: 'none' as 'none' | 'saves' | 'fails',
+ closes: [] as string[],
+ mounts: [] as string[],
+ restore: null as null | ((data: Record) => void),
+}));
+
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({
+ Sidebar: ({ onCompose }: { onCompose: () => void }) => stub compose ,
+}));
+// As the real composer: it hands its state (its own mode, untouched) to the
+// host as it unmounts without an explicit close, and offers its close.
+vi.mock('@/components/email/email-composer', async () => {
+ const { useEffect, useRef } = await import('react');
+ return {
+ EmailComposer: ({ mode, onSaveState, onClose, requestCloseRef, onRestoreDiscarded }: {
+ mode: string;
+ onRestoreDiscarded?: (data: Record) => void;
+ onSaveState: (data: Record) => void;
+ onClose: () => void;
+ requestCloseRef?: { current: unknown };
+ }) => {
+ const closed = useRef(false);
+ useEffect(() => {
+ stub.mounts.push(mode);
+ stub.restore = onRestoreDiscarded ?? null;
+ // The old host moved the composer between dock and pane for ever
+ // (React error 185 in the browser); stop it here instead of hanging.
+ if (stub.mounts.length > 40) throw new Error('composer remount loop');
+ if (requestCloseRef && stub.close !== 'none') {
+ requestCloseRef.current = (afterClose?: () => void) => {
+ stub.closes.push(mode);
+ if (stub.close === 'fails') return;
+ closed.current = true;
+ onClose();
+ afterClose?.();
+ };
+ }
+ return () => {
+ if (requestCloseRef) requestCloseRef.current = null;
+ if (closed.current) return;
+ onSaveState({ to: '', cc: '', bcc: '', subject: '', body: '', mode, draftId: null, unedited: true });
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+ return stub composer
;
+ },
+ };
+});
+vi.mock('@/components/search/mv-search-form', () => ({
+ MvSearchForm: ({ onSearch }: { onSearch: () => void }) => stub filter search ,
+}));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ onReply, onReplyAll, onForward }: { onReply: () => void; onReplyAll: () => void; onForward: () => void }) => (
+
+ message viewer
+ onReply()}>stub reply
+ onReplyAll()}>stub reply all
+ onForward()}>stub forward
+
+ ),
+}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => device.current,
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({
+ EmailList: () => null,
+}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
+const MESSAGE = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
+ subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
+ hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {},
+} as never;
+
+beforeEach(() => {
+ device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ stub.close = 'none';
+ stub.closes = [];
+ stub.mounts = [];
+ stub.restore = null;
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ const client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getEmail: vi.fn().mockResolvedValue(null),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
+ useEmailStore.setState({
+ emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
+ selectedEmailKeys: new Set(), unreadSection: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+ useToastStore.getState().clearToasts();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ useToastStore.getState().clearToasts();
+});
+
+const composerMode = () => screen.getByTestId('stub-composer').getAttribute('data-mode');
+
+describe('Compose while a reply is docked (compose-p2-1)', () => {
+ for (const action of ['stub reply', 'stub forward']) {
+ it(`opens a new message in the pane after ${action}`, async () => {
+ render( );
+ fireEvent.click(await screen.findByText(action));
+ const reply = await screen.findByTestId('stub-composer');
+ expect(reply.closest('[data-reply-dock]')).not.toBeNull();
+
+ fireEvent.click(screen.getByText('stub compose'));
+ await waitFor(() => expect(composerMode()).toBe('compose'));
+ // The new message takes the pane, not the dock, and stays put.
+ expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).toBeNull();
+ const mounted = stub.mounts.length;
+ await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
+ expect(stub.mounts.length).toBe(mounted);
+ expect(composerMode()).toBe('compose');
+ });
+ }
+});
+
+describe('another composer while one is open (compose-p2-2)', () => {
+ it('closes the reply through its own close (save, "Draft saved") before Compose opens', async () => {
+ stub.close = 'saves';
+ render( );
+ fireEvent.click(await screen.findByText('stub reply'));
+ await screen.findByTestId('stub-composer');
+
+ fireEvent.click(screen.getByText('stub compose'));
+ await waitFor(() => expect(composerMode()).toBe('compose'));
+ expect(stub.closes).toEqual(['reply']);
+ });
+
+ for (const [action, mode] of [['stub reply all', 'replyAll'], ['stub forward', 'forward']] as const) {
+ it(`closes the reply through its own close before ${action} opens`, async () => {
+ stub.close = 'saves';
+ render( );
+ fireEvent.click(await screen.findByText('stub reply'));
+ await screen.findByTestId('stub-composer');
+
+ fireEvent.click(screen.getByText(action));
+ await waitFor(() => expect(composerMode()).toBe(mode));
+ expect(stub.closes).toEqual(['reply']);
+ });
+ }
+
+ it('closes a new message before another new message opens', async () => {
+ stub.close = 'saves';
+ render( );
+ fireEvent.click(screen.getByText('stub compose'));
+ await screen.findByTestId('stub-composer');
+ const mounted = stub.mounts.length;
+
+ fireEvent.click(screen.getByText('stub compose'));
+ await waitFor(() => expect(stub.mounts.length).toBe(mounted + 1));
+ expect(stub.closes).toEqual(['compose']);
+ });
+
+ it('keeps the open composer when its save fails', async () => {
+ stub.close = 'fails';
+ render( );
+ fireEvent.click(await screen.findByText('stub reply'));
+ await screen.findByTestId('stub-composer');
+ const mounted = stub.mounts.length;
+
+ fireEvent.click(screen.getByText('stub compose'));
+ await act(async () => { await new Promise((r) => setTimeout(r, 20)); });
+ expect(stub.closes).toEqual(['reply']);
+ expect(stub.mounts.length).toBe(mounted);
+ expect(composerMode()).toBe('reply');
+ });
+});
+
+// compose-p2-10: Undo after the bin brings the composer back.
+describe('Undo after the bin', () => {
+ it('opens the discarded text again, closing whatever is open first', async () => {
+ stub.close = 'saves';
+ render( );
+ fireEvent.click(screen.getByText('stub compose'));
+ await screen.findByTestId('stub-composer');
+ const restore = stub.restore!;
+
+ act(() => restore({ to: 'bob@example.org', cc: '', bcc: '', subject: 'Back', body: 'kept', mode: 'forward', draftId: 'd1' }));
+ await waitFor(() => expect(composerMode()).toBe('forward'));
+ expect(stub.closes).toEqual(['compose']);
+ });
+});
diff --git a/components/mail/__tests__/mail-app-drafts.test.tsx b/components/mail/__tests__/mail-app-drafts.test.tsx
new file mode 100644
index 000000000..980410488
--- /dev/null
+++ b/components/mail/__tests__/mail-app-drafts.test.tsx
@@ -0,0 +1,160 @@
+import React from 'react';
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+
+// Drafts reopen where people look for them (compose-p2-6, compose-p2-8):
+// a click on a draft opens it in the composer, and Reply on a message with a
+// reply draft opens that draft instead of starting a second one.
+
+const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
+const DRAFT_ROW = vi.hoisted(() => ({
+ id: 'd1', threadId: 't1', mailboxIds: { drafts: true }, keywords: { $seen: true, $draft: true },
+ from: [{ email: 'a@example.org' }], to: [{ email: 'ann@example.org' }],
+ subject: 'Re: Plans', receivedAt: '2026-11-02T10:00:00Z', preview: 'my answer', size: 10, hasAttachment: false,
+}));
+
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/email/email-composer', () => ({
+ EmailComposer: ({ mode, initialData }: { mode: string; initialData?: { draftId?: string } | null }) => (
+ stub composer
+ ),
+}));
+vi.mock('@/components/search/mv-search-form', () => ({
+ MvSearchForm: ({ onSearch }: { onSearch: () => void }) => stub filter search ,
+}));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ onReply, replyDraft }: { onReply: () => void; replyDraft?: { preview?: string; onEdit: () => void } | null }) => (
+
+ message viewer
+ onReply()}>stub reply
+ {replyDraft && stub reply draft: {replyDraft.preview} }
+
+ ),
+}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => device.current,
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({
+ EmailList: ({ onEmailSelect }: { onEmailSelect: (email: unknown) => void }) => (
+ onEmailSelect(DRAFT_ROW)}>stub draft row
+ ),
+}));
+
+const { MailApp } = await import('../mail-app');
+
+const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
+const drafts = { id: 'drafts', name: 'Drafts', role: 'drafts', totalEmails: 1, unreadEmails: 0 } as Mailbox;
+const MESSAGE = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ name: 'Ann', email: 'ann@example.org' }], to: [{ email: 'a@example.org' }],
+ subject: 'Plans', receivedAt: '2026-11-01T10:00:00Z', preview: 'Hi', size: 10,
+ hasAttachment: false, textBody: [], htmlBody: [], bodyValues: {}, messageId: ['plans@example.org'],
+} as never;
+const FULL_DRAFT = {
+ ...DRAFT_ROW, textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [], bodyValues: { 1: { value: 'my answer' } },
+ inReplyTo: ['plans@example.org'],
+};
+
+type Client = Record & { getEmail: ReturnType };
+let client: Client;
+
+beforeEach(() => {
+ device.current = { isMobile: false, isTablet: false, isDesktop: true };
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getEmail: vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT } : null)),
+ getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m1', 'd1'] })),
+ getSomeEmails: vi.fn(async () => [{ ...(MESSAGE as object) }, { ...DRAFT_ROW }]),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: () => client as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split' } as never);
+ useEmailStore.setState({
+ emails: [MESSAGE], selectedEmail: MESSAGE, selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: [inbox, drafts],
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
+ selectedEmailKeys: new Set(), unreadSection: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+ useToastStore.getState().clearToasts();
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ useToastStore.getState().clearToasts();
+});
+
+describe('a draft in the list (compose-p2-8)', () => {
+ it('opens in the composer on a click', async () => {
+ render( );
+ fireEvent.click(await screen.findByText('stub draft row'));
+ const composer = await screen.findByTestId('stub-composer');
+ expect(composer).toHaveAttribute('data-draft', 'd1');
+ expect(composer).toHaveAttribute('data-mode', 'compose');
+ });
+});
+
+describe('a message with a reply draft (compose-p2-6)', () => {
+ it('shows the draft under the message', async () => {
+ render( );
+ expect(await screen.findByText('stub reply draft: my answer')).toBeInTheDocument();
+ });
+
+ it('opens that draft on Reply instead of a new reply', async () => {
+ render( );
+ await screen.findByText('stub reply draft: my answer');
+ fireEvent.click(screen.getByText('stub reply'));
+ await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', 'd1'));
+ // Under the message it answers, as a new reply is, not over it.
+ expect(screen.getByTestId('stub-composer').closest('[data-reply-dock]')).not.toBeNull();
+ });
+
+ it('starts a new reply when the conversation has no reply to this message', async () => {
+ client.getEmail = vi.fn(async (id: string) => (id === 'd1' ? { ...FULL_DRAFT, inReplyTo: ['other@example.org'] } : null));
+ render( );
+ await waitFor(() => expect(client.getEmail).toHaveBeenCalled());
+ expect(screen.queryByText(/stub reply draft/)).toBeNull();
+ fireEvent.click(screen.getByText('stub reply'));
+ await waitFor(() => expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-mode', 'reply'));
+ expect(screen.getByTestId('stub-composer')).toHaveAttribute('data-draft', '');
+ });
+});
diff --git a/components/mail/__tests__/mail-app-held-reply.test.tsx b/components/mail/__tests__/mail-app-held-reply.test.tsx
new file mode 100644
index 000000000..fa1ec6fba
--- /dev/null
+++ b/components/mail/__tests__/mail-app-held-reply.test.tsx
@@ -0,0 +1,217 @@
+import React, { Suspense, lazy } from 'react';
+import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { useQuickReplyRestoreStore } from '@/stores/quick-reply-restore-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { clearAllHooks } from '@/lib/plugin-hooks';
+import { forgetEmailPrefetches } from '@/lib/email-prefetch';
+
+// mail-p2-1: a quick reply that went out shows in the open conversation at
+// once. mail-p2-8: during its Undo window it isn't counted in Sent or
+// Scheduled and the message isn't marked answered; Undo takes it out of the
+// conversation and puts the text back into the quick reply box, leaving no
+// draft behind.
+
+vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => {
+ const Component = lazy(async () => ({ default: await load() }));
+ return function Dynamic(props: Record) {
+ return ;
+ };
+} }));
+type ViewerProps = { mvConversation?: { id: string }[]; onQuickReply?: (body: string) => Promise; onReply?: () => void };
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: ({ mvConversation, onQuickReply, onReply }: ViewerProps) => (
+
+
{mvConversation ? mvConversation.map((e) => e.id).join(',') : 'no conversation'}
+
void onQuickReply?.('Thanks, see you there')}>quick send
+
onReply?.()}>reply
+
+ ),
+}));
+type ComposerProps = {
+ mode: string;
+ initialData?: { draftId?: string | null; body?: string } | null;
+ onSend?: (data: Record, state?: Record) => Promise;
+};
+vi.mock('@/components/email/email-composer', () => ({
+ EmailComposer: ({ mode, initialData, onSend }: ComposerProps) => (
+
+
void onSend?.(
+ { to: ['sam@example.org'], cc: [], bcc: [], subject: 'Re: Lunch tomorrow?', body: 'Works for me' },
+ { mode: 'reply', body: 'Works for me
', to: 'sam@example.org', cc: '', bcc: '', subject: 'Re: Lunch tomorrow?', draftId: 'd-1', attachments: [] },
+ )}>composer send
+
+ ),
+}));
+vi.mock('@/components/layout/sidebar', () => ({
+ Sidebar: ({ scheduledTotal, mailboxes }: { scheduledTotal?: number; mailboxes: Mailbox[] }) => (
+ {`scheduled ${scheduledTotal} sent ${mailboxes.find((m) => m.role === 'sent')?.totalEmails}`}
+ ),
+}));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
+vi.mock('@/components/layout/mv-side-panel', () => ({ MvSidePanel: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }), getCachedConfig: () => null }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+function mailbox(id: string, role: string, totalEmails = 3): Mailbox {
+ return {
+ id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: 3, unreadThreads: 0, isSubscribed: true,
+ } as Mailbox;
+}
+const row = (id: string, minute: number, extra: Partial = {}) => ({
+ id, threadId: 't-m1', subject: 'Lunch tomorrow?', receivedAt: new Date(Date.UTC(2026, 9, 31, 10, minute)).toISOString(),
+ keywords: { $seen: true }, mailboxIds: { inbox: true }, from: [{ name: 'Sam', email: 'sam@example.org' }],
+ to: [{ name: 'Me', email: 'a@example.org' }], ...extra,
+}) as unknown as Email;
+
+let client: Record | (() => unknown)>;
+let sendEmail: ReturnType;
+let markEmailKeyword: ReturnType;
+let cancelUndoSend: ReturnType;
+let folders: Mailbox[];
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ useToastStore.setState({ toasts: [] });
+ useQuickReplyRestoreStore.setState({ pending: null });
+ clearAllHooks();
+ forgetEmailPrefetches();
+ folders = [mailbox('inbox', 'inbox'), mailbox('sent', 'sent', 51), mailbox('drafts', 'drafts')];
+ const thread = [row('m0', 0, { mailboxIds: { inbox: true } }), row('m1', 30)];
+ client = {
+ getAccountId: () => 'acc', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => true, getSession: () => null,
+ getMailboxes: vi.fn().mockResolvedValue(folders),
+ getThread: vi.fn(async (threadId: string) => ({ id: threadId, emailIds: ['m0', 'm1'] })),
+ getSomeEmails: vi.fn(async (ids: string[]) => thread.filter((e) => ids.includes(e.id))),
+ getEmail: vi.fn(async (id: string) => ({ ...thread.find((e) => e.id === id)!, bodyValues: {} })),
+ deleteEmail: vi.fn().mockResolvedValue(undefined),
+ markAsRead: vi.fn().mockResolvedValue(undefined),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({
+ enableUnifiedMailbox: false, interfaceLayout: 'default', mailLayout: 'split', disableThreading: false,
+ sendDelaySeconds: 5, sendConfirmation: false, placeholderWarningEnabled: false, attachmentReminderEnabled: false,
+ } as never);
+ sendEmail = vi.fn(async () => {
+ const sendAt = new Date(Date.now() + 5000).toISOString();
+ useEmailStore.setState({
+ pendingUndoSend: {
+ submissionId: 'sub-1', emailId: 'sent-1', sendAt, isSmime: false,
+ countsBefore: { sent: 51, scheduled: 0 },
+ },
+ } as never);
+ // The server files it in Sent and lists its submission at once.
+ useEmailStore.setState({ scheduledTotal: 1, mailboxes: folders.map((m) => m.role === 'sent' ? { ...m, totalEmails: 52 } : m) } as never);
+ return { scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt };
+ });
+ markEmailKeyword = vi.fn().mockResolvedValue(undefined);
+ cancelUndoSend = vi.fn(async () => {
+ useEmailStore.setState({ pendingUndoSend: null } as never);
+ return { ...row('sent-1', 40), id: 'sent-1', mailboxIds: { drafts: true }, keywords: { $draft: true } };
+ });
+ const emails = [row('m1', 30)];
+ useEmailStore.setState({
+ emails, selectedEmail: emails[0], selectedMailbox: 'inbox', selectedKeyword: null, mailboxes: folders,
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', scheduledTotal: 0, pendingUndoSend: null,
+ selectedEmailKeys: new Set(), unreadSection: null, listRows: null,
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ fetchEmailContent: vi.fn().mockResolvedValue(undefined),
+ sendEmail, markEmailKeyword, cancelUndoSend,
+ } as never);
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+describe('a quick reply in its Undo window', () => {
+ it('shows in the conversation at once, counts nowhere and answers nothing yet; Undo puts it back in the box', async () => {
+ render( );
+ const reader = await screen.findByTestId('reader', {}, { timeout: 5000 });
+ await waitFor(() => expect(reader).toHaveTextContent('m0,m1'));
+
+ fireEvent.click(screen.getByRole('button', { name: 'quick send' }));
+ await waitFor(() => expect(sendEmail).toHaveBeenCalled());
+ await waitFor(() => expect(reader).toHaveTextContent('m0,m1,sent-1'));
+
+ expect(markEmailKeyword).not.toHaveBeenCalled();
+ expect(screen.getByTestId('sidebar-counts')).toHaveTextContent('scheduled 0 sent 51');
+
+ const undo = await waitFor(() => {
+ const toast = useToastStore.getState().toasts.find((t) => t.action);
+ expect(toast).toBeTruthy();
+ return toast!;
+ });
+ await act(async () => { undo.action!.onClick(); });
+ await waitFor(() => expect(cancelUndoSend).toHaveBeenCalled());
+ await waitFor(() => expect(reader).toHaveTextContent(/^m0,m1$/));
+ await waitFor(() => expect(useQuickReplyRestoreStore.getState().pending).toEqual({ emailId: 'm1', text: 'Thanks, see you there' }));
+ expect(client.deleteEmail).toHaveBeenCalledWith('sent-1', undefined);
+ expect(markEmailKeyword).not.toHaveBeenCalled();
+ });
+
+ it('brings an undone reply back into the dock, as it was', async () => {
+ render( );
+ await screen.findByTestId('reader', {}, { timeout: 5000 });
+ fireEvent.click(screen.getByRole('button', { name: 'reply' }));
+ const composer = await screen.findByTestId('composer');
+ expect(composer.closest('[data-reply-dock]')).not.toBeNull();
+ fireEvent.click(screen.getByRole('button', { name: 'composer send' }));
+ await waitFor(() => expect(sendEmail).toHaveBeenCalled());
+ await waitFor(() => expect(screen.queryByTestId('composer')).toBeNull());
+ expect(markEmailKeyword).not.toHaveBeenCalled();
+
+ const undo = await waitFor(() => {
+ const toast = useToastStore.getState().toasts.find((t) => t.action);
+ expect(toast).toBeTruthy();
+ return toast!;
+ });
+ await act(async () => { undo.action!.onClick(); });
+ const back = await screen.findByTestId('composer');
+ expect(back).toHaveAttribute('data-mode', 'reply');
+ expect(back).toHaveAttribute('data-draft', 'sent-1');
+ expect(back).toHaveAttribute('data-body', 'Works for me
');
+ expect(back.closest('[data-reply-dock]')).not.toBeNull();
+ });
+});
diff --git a/components/mail/__tests__/mail-app-list-ux.test.tsx b/components/mail/__tests__/mail-app-list-ux.test.tsx
index 7f11c1bf9..54354744c 100644
--- a/components/mail/__tests__/mail-app-list-ux.test.tsx
+++ b/components/mail/__tests__/mail-app-list-ux.test.tsx
@@ -37,8 +37,8 @@ vi.mock('@/components/ui/welcome-banner', () => ({
useWelcomeBannerDue: () => false,
}));
vi.mock('@/components/email/email-viewer', () => ({
- EmailViewer: ({ emptyPaneContent, emptyPaneFooter }: { emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
- {emptyPaneContent ??
no conversation selected
}{emptyPaneFooter}
+ EmailViewer: ({ email, emptyPaneContent, emptyPaneFooter }: { email?: { id: string } | null; emptyPaneContent?: React.ReactNode; emptyPaneFooter?: React.ReactNode }) => (
+ {email ?
reading {email.id}
: <>{emptyPaneContent ??
no conversation selected
}{emptyPaneFooter}>}
),
}));
vi.mock('@/components/email/email-list', () => ({
@@ -94,7 +94,7 @@ beforeEach(() => {
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', isAdvancedSearchOpen: false,
- selectedEmailKeys: new Set(), unreadSection: null,
+ selectedEmailKeys: new Set(), unreadSection: null, listRows: null,
fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
@@ -138,6 +138,35 @@ describe('the reading pane for a selection', () => {
expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
});
+ it('replaces the open message once other rows are ticked too (mail-p2-3)', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm2']) } as never);
+ render( );
+ expect(screen.queryByText('reading m1')).toBeNull();
+ expect(listProps.host).toBeInstanceOf(HTMLElement);
+ expect(screen.getByTestId('viewer').contains(listProps.host!)).toBe(true);
+ });
+
+ it('replaces the open message when another single row is ticked', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m2']) } as never);
+ render( );
+ expect(screen.queryByText('reading m1')).toBeNull();
+ });
+
+ it('keeps the open message when only its own row is ticked', () => {
+ useEmailStore.setState({ selectedEmail: message, selectedEmailKeys: new Set(['m1']) } as never);
+ render( );
+ expect(screen.getByText('reading m1')).toBeTruthy();
+ });
+
+ it('keeps the open message when its conversation row is ticked', () => {
+ useEmailStore.setState({
+ selectedEmail: message, selectedEmailKeys: new Set(['m1', 'm1-reply']),
+ listRows: [{ key: 'm1', members: ['m1', 'm1-reply'] }, { key: 'm2', members: ['m2'] }],
+ } as never);
+ render( );
+ expect(screen.getByText('reading m1')).toBeTruthy();
+ });
+
it('says nothing is selected without a selection', () => {
render( );
expect(screen.getByText('no conversation selected')).toBeTruthy();
@@ -145,6 +174,18 @@ describe('the reading pane for a selection', () => {
});
});
+describe('the list header checkbox (mail-p2-11)', () => {
+ it('ticks every loaded row on the first click and clears them on the second', () => {
+ const second = { ...message, id: 'm2', threadId: 't-m2', subject: 'm2' } as Email;
+ useEmailStore.setState({ emails: [message, second] } as never);
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /^email_list\.batch_actions\.select/ }));
+ expect([...useEmailStore.getState().selectedEmailKeys].sort()).toEqual(['m1', 'm2']);
+ fireEvent.click(screen.getByRole('button', { name: 'email_list.batch_actions.clear_selection' }));
+ expect(useEmailStore.getState().selectedEmailKeys.size).toBe(0);
+ });
+});
+
describe('the new folder dialogs', () => {
it.each([
['stub new folder', 'mail_list_extra.new_folder_title'],
diff --git a/components/mail/__tests__/mail-app-open-offline.test.tsx b/components/mail/__tests__/mail-app-open-offline.test.tsx
new file mode 100644
index 000000000..34699ce66
--- /dev/null
+++ b/components/mail/__tests__/mail-app-open-offline.test.tsx
@@ -0,0 +1,179 @@
+import React, { Suspense, lazy } from 'react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { useAuthStore } from '@/stores/auth-store';
+import { useEmailStore } from '@/stores/email-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { usePluginStore } from '@/stores/plugin-store';
+import { useThemeStore } from '@/stores/theme-store';
+import { useToastStore } from '@/stores/toast-store';
+import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
+import { clearAllHooks } from '@/lib/plugin-hooks';
+import { dispatchApiCall } from '@/lib/plugin-sandbox/host-api';
+import { resetOpenEmail } from '@/lib/plugin-sandbox/open-email';
+import type { InstalledPlugin } from '@/lib/plugin-types';
+
+// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, a click
+// on a message while offline left "No conversation selected" and the keys did
+// nothing. The reader now says the message needs a connection, with Retry, and
+// a key that changes mail says you're offline. The message is opened the way a
+// plugin opens one, which goes through the same open as a click.
+
+vi.mock('next/dynamic', () => ({ default: (load: () => Promise>>) => {
+ const Component = lazy(async () => ({ default: await load() }));
+ return function Dynamic(props: Record) {
+ return ;
+ };
+} }));
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: (props: { email: unknown; emptyPaneContent?: React.ReactNode }) => (props.email ? reader
: {props.emptyPaneContent ?? 'empty pane'}
),
+}));
+vi.mock('@/components/email/email-composer', () => ({ EmailComposer: () => null }));
+vi.mock('@/components/layout/sidebar', () => ({ Sidebar: () => null }));
+vi.mock('@/components/layout/navigation-rail', () => ({ NavigationRail: () => null }));
+vi.mock('@/components/layout/calendar-event-notification-toaster', () => ({ CalendarEventNotificationToaster: () => null }));
+vi.mock('@/components/layout/share-notification-toaster', () => ({ ShareNotificationToaster: () => null }));
+vi.mock('@/components/layout/mobile-header', () => ({ MobileHeader: () => null }));
+vi.mock('@/components/email/message-list-tabs', () => ({ useMessageListPanelProps: (id: string) => ({ id }), MessageListTabs: () => null }));
+vi.mock('@/components/search/search-box', () => ({ SearchBox: () => null }));
+vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ activeTour: null, registerStep: vi.fn() }) }));
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
+vi.mock('@/hooks/use-config', () => ({ useConfig: () => ({ appName: 'Mail' }) }));
+vi.mock('@/hooks/use-identity-sync', () => ({ useIdentitySync: () => {} }));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({ useProMultiAccountIdentities: () => [] }));
+vi.mock('@/hooks/use-pro-multi-account-mailboxes', () => ({ useProMultiAccountMailboxes: () => {} }));
+vi.mock('@/hooks/use-wopi-status', () => ({ useWopiStatus: () => null, canWopiOpen: () => false }));
+vi.mock('@/hooks/use-browser-navigation', () => ({ useBrowserNavigation: () => {} }));
+vi.mock('@/hooks/use-media-query', () => ({
+ useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
+ useMediaQuery: () => false,
+ useIsDesktop: () => true,
+}));
+vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
+vi.mock('@/components/email/email-list', () => ({ EmailList: () => message list
}));
+
+const { MailApp } = await import('../mail-app');
+
+function mailbox(id: string, role: string | undefined, accountId: string, shared = false): Mailbox {
+ return {
+ id: shared ? `${accountId}:${id}` : id, originalId: id, name: role ?? id, role, accountId, isShared: shared,
+ sortOrder: 0, totalEmails: 0, unreadEmails: 0, totalThreads: 0, unreadThreads: 0, isSubscribed: true,
+ } as Mailbox;
+}
+
+// The active login's folders: its own account `cf` and the group `ce`.
+const folders = [mailbox('a', 'inbox', 'cf'), mailbox('a', 'inbox', 'ce', true)];
+
+// The group's Inbox message, as Email/get gives it.
+const groupMessage = {
+ id: 'eaaaaab', threadId: 'c', subject: 'GRP one', receivedAt: '2026-10-26T10:00:00Z',
+ keywords: { $seen: true }, mailboxIds: { a: true }, bodyValues: {},
+} as unknown as Email;
+
+let client: Record;
+const plugin = {
+ id: 'p1', name: 'Plugin One', version: '1.0.0', author: '', description: '', type: 'hook',
+ permissions: ['email:read'], entrypoint: 'index.js', enabled: true, status: 'running', settings: {}, managed: true,
+} as InstalledPlugin;
+
+/** The group account has `eaaaaab`, the own account nothing. */
+let failing = true;
+const getEmail = vi.fn(async (id: string, accountId?: string) => {
+ // Offline the real client logs the failure and answers with no message.
+ if (failing && navigator.onLine === false) return null;
+ if (failing) throw new TypeError('Failed to fetch');
+ return id === 'eaaaaab' && accountId === 'ce' ? { ...groupMessage } : null;
+});
+
+beforeEach(() => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Unexpected network request')));
+ vi.stubGlobal('ResizeObserver', class { observe() {} unobserve() {} disconnect() {} });
+ usePluginStore.setState({ initializePlugins: vi.fn().mockResolvedValue(undefined) });
+ useThemeStore.setState({ syncServerThemes: vi.fn().mockResolvedValue(undefined) });
+ useToastStore.setState({ toasts: [] });
+ clearAllHooks();
+ resetOpenEmail();
+ getEmail.mockClear();
+ client = {
+ getAccountId: () => 'cf', getUsername: () => 'a@example.org',
+ getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
+ getMailboxes: vi.fn().mockResolvedValue(folders),
+ getEmail,
+ getSharedAccounts: () => ['cf', 'ce'].map((id) => ({ id, name: id, isPrimary: id === 'cf', capabilities: { mail: true } })),
+ };
+ useAuthStore.setState({
+ isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login-a', identities: [],
+ getClientForAccount: (id: string) => (id === 'login-a' ? client : undefined) as never,
+ getAllConnectedClients: () => new Map([['login-a', client]]) as never,
+ });
+ useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', archiveMode: 'single', deleteAction: 'trash' } as never);
+ useEmailStore.setState({
+ emails: [], selectedEmail: null, selectedMailbox: 'a', selectedKeyword: null, mailboxes: folders,
+ viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
+ isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
+ searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '',
+ selectedEmailKeys: new Set(),
+ fetchMailboxes: vi.fn().mockResolvedValue(undefined), fetchEmails: vi.fn().mockResolvedValue(undefined),
+ fetchQuota: vi.fn().mockResolvedValue(undefined), fetchTagCounts: vi.fn().mockResolvedValue(undefined),
+ refreshScheduledMetadata: vi.fn().mockResolvedValue(undefined),
+ } as never);
+});
+afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+});
+
+const setOnline = (online: boolean) => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
+ window.dispatchEvent(new Event(online ? 'online' : 'offline'));
+};
+
+describe('a message that cannot be fetched', () => {
+ afterEach(() => setOnline(true));
+
+ it('says offline that it needs a connection, and Retry opens it once back online', async () => {
+ failing = true;
+ render( );
+ await screen.findByText('message list', {}, { timeout: 5000 });
+ act(() => setOnline(false));
+ await act(async () => {
+ await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
+ });
+ expect(await screen.findByText('reader_offline')).toBeTruthy();
+
+ failing = false;
+ act(() => setOnline(true));
+ await act(async () => {
+ fireEvent.click(screen.getByRole('button', { name: 'retry' }));
+ await new Promise((r) => setTimeout(r, 20));
+ });
+ expect(await screen.findByText('reader')).toBeTruthy();
+ expect(screen.queryByText('reader_offline')).toBeNull();
+ });
+
+ it('says it could not be loaded when online', async () => {
+ failing = true;
+ render( );
+ await screen.findByText('message list', {}, { timeout: 5000 });
+ await act(async () => {
+ await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
+ });
+ expect(await screen.findByText('reader_failed')).toBeTruthy();
+ });
+
+ it('answers a key that changes mail with a note that it is offline', async () => {
+ render( );
+ await screen.findByText('message list', {}, { timeout: 5000 });
+ act(() => setOnline(false));
+ failing = true;
+ await act(async () => {
+ await dispatchApiCall(plugin, 'ui.openEmail', [{ id: 'eaaaaab', accountId: 'ce' }]).catch(() => {});
+ });
+ await screen.findByText('reader_offline');
+ // No Archive folder here: Delete stands in for every key that changes mail.
+ fireEvent.keyDown(document.body, { key: '#', code: 'Digit3', shiftKey: true });
+ expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('action_offline');
+ });
+});
diff --git a/components/mail/__tests__/reply-dock.test.tsx b/components/mail/__tests__/reply-dock.test.tsx
new file mode 100644
index 000000000..eebdd83a7
--- /dev/null
+++ b/components/mail/__tests__/reply-dock.test.tsx
@@ -0,0 +1,58 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+// mail-p2-2 / compose-p2-4: the docked reply took 62 % of the reading pane
+// (at least 18 rem), so the message it answered was out of sight, and on a
+// short window the reply itself had two lines to type in. It now grows with
+// its text up to half of a tall pane; on a short pane it takes the pane under
+// the reader's toolbar, with "Show the message" to read and come back.
+
+const { ReplyDock, dockLayout } = await import('../reply-dock');
+
+function renderInPane(height: number, readerTop = 110) {
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
+ if (this.hasAttribute('data-pane')) return { top: 0, height, width: 800, left: 0, right: 800, bottom: height, x: 0, y: 0, toJSON() {} } as DOMRect;
+ if (this.hasAttribute('data-reader-scroll')) return { top: readerTop, height: 0, width: 800, left: 0, right: 800, bottom: readerTop, x: 0, y: readerTop, toJSON() {} } as DOMRect;
+ return { top: 0, height: 0, width: 0, left: 0, right: 0, bottom: 0, x: 0, y: 0, toJSON() {} } as DOMRect;
+ });
+ return render(
+ ,
+ );
+}
+
+afterEach(() => vi.restoreAllMocks());
+
+describe('the reply dock', () => {
+ it('takes at most half of a tall pane, sized by its content', () => {
+ const { container } = renderInPane(768);
+ const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
+ expect(dock.getAttribute('data-dock-mode')).toBe('share');
+ expect(dock.style.maxHeight).toBe('384px');
+ expect(dock.style.height).toBe('');
+ expect(dock.className).not.toMatch(/basis-/);
+ expect(screen.queryByText('show_message')).toBeNull();
+ });
+
+ it('takes a short pane under the reader toolbar, and folds to show the message', () => {
+ const { container } = renderInPane(600, 110);
+ const dock = container.querySelector('[data-reply-dock]') as HTMLElement;
+ expect(dock.getAttribute('data-dock-mode')).toBe('takeover');
+ expect(dock.style.height).toBe('490px');
+ const editor = screen.getByLabelText('reply text');
+ act(() => { fireEvent.click(screen.getByText('show_message')); });
+ expect(dock.style.height).toBe('');
+ // Folded, the reply stays mounted with its text.
+ expect(editor.isConnected).toBe(true);
+ expect(editor.parentElement!.className).toMatch(/\bhidden\b/);
+ act(() => { fireEvent.click(screen.getByText('back_to_reply')); });
+ expect(dock.style.height).toBe('490px');
+ });
+
+ it('chooses by the pane height', () => {
+ expect(dockLayout(699).mode).toBe('takeover');
+ expect(dockLayout(700)).toEqual({ mode: 'share', maxHeight: 350 });
+ });
+});
diff --git a/components/mail/__tests__/undo-send-toast.test.tsx b/components/mail/__tests__/undo-send-toast.test.tsx
index 92d7e83e3..d3c87a094 100644
--- a/components/mail/__tests__/undo-send-toast.test.tsx
+++ b/components/mail/__tests__/undo-send-toast.test.tsx
@@ -114,11 +114,26 @@ describe('undo send toast', () => {
await waitFor(() => expect(undoToast()).toBeDefined());
const toast = undoToast()!;
expect(toast.title).toBe('email_viewer.sending_in');
- // 30 s is the setting, but only 10 s are left of the window.
- expect(toast.duration).toBeLessThan(10_000);
+ // 30 s is the setting, but only 10 s are left of the window. The toast
+ // itself stays a moment longer, to say "Sent" once Undo is over.
+ expect(toast.duration).toBeLessThanOrEqual(10_000);
expect(toast.pauseOnHover).toBe(false);
});
+ // compose-p2-12: once Undo is over the toast says the message went.
+ it('turns into "Sent" with View before the server releases the message', async () => {
+ render( );
+ sendDelayedBy(2_500);
+
+ await waitFor(() => expect(undoToast()).toBeDefined());
+ const id = undoToast()!.id;
+ await waitFor(() => {
+ const toast = useToastStore.getState().toasts.find((t) => t.id === id);
+ expect(toast?.title).toBe('compose_send.sent');
+ expect(toast?.action?.label).toBe('compose_send.view');
+ }, { timeout: 3000 });
+ });
+
// compose-23: "Email scheduled for sending" with "Undo send" said neither
// when it goes nor that the button cancels it.
it('says when a scheduled message goes, with Cancel and Send now', async () => {
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 76f9013b1..7833a66e2 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -11,6 +11,19 @@ import { productName } from "@/lib/product-name";
import { MessageListTabs, useMessageListPanelProps } from "@/components/email/message-list-tabs";
import dynamic from "next/dynamic";
import type { ComposerDraftData } from "@/components/email/email-composer";
+import { ReplyDock } from "@/components/mail/reply-dock";
+import { addEcho, isUndoWindow, mergeEchoes, sentEcho, settleSent, withoutHeldSend } from "@/lib/held-send";
+import { useQuickReplyRestoreStore } from "@/stores/quick-reply-restore-store";
+
+/** What waits with a send in its Undo window (mail-p2-8). */
+type HeldSendExtras = {
+ emailId?: string;
+ /** Marks the answered message; runs once the message really goes. */
+ answer?: () => Promise;
+ origin?:
+ | { kind: 'quick-reply'; emailId: string; text: string }
+ | { kind: 'composer'; state: ComposerDraftData; sourceEmailId?: string };
+};
// Neither the viewer (5k+ lines, postal-mime/dompurify) nor the composer (the
// entire TipTap/ProseMirror stack) is needed to paint the mail list, and both
@@ -43,13 +56,15 @@ import { useRemoveFromFolder } from "@/hooks/use-remove-from-folder";
import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-scope-folders";
import { toast } from "@/stores/toast-store";
import { discardWithUndo } from "@/lib/compose/draft-close-toast";
+import { findReplyDraft, threadDrafts } from "@/lib/reply-drafts";
import { cancelFailureToast, partlyRefusedWarning, rescheduleFailureToast, rescheduledToast } from "@/lib/send-failure-message";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
-import { startSendProgress } from "@/lib/outgoing/send-progress-toast";
+import { runUndoSendCountdown, startSendProgress, UNDO_TOAST_SLACK_MS } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
import { keywordRestorePoints, markSelectionReadWithUndo, recordQuietUndo, runBatchEmailAction, toastWithUndo, undoKeywordAction, undoLabels, undoMoveAction } from "@/lib/email-action-toast";
+import { batchToastTitle, selectionRowCount } from "@/lib/selection-toast";
import { MailboxShareDialog } from "@/components/layout/mailbox-share-dialog";
import { ShareNotificationToaster } from "@/components/layout/share-notification-toaster";
import { CalendarEventNotificationToaster } from "@/components/layout/calendar-event-notification-toaster";
@@ -57,7 +72,9 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin, canEnterLoginInP
import { useRouter } from '@/i18n/navigation';
import { useSettingsStore } from "@/stores/settings-store";
import { useDrawnMailLayout } from "@/hooks/use-effective-mail-layout";
-import { useOfflineStore } from "@/lib/offline/state";
+import { isOfflineNow, useOfflineStore } from "@/lib/offline/state";
+import { useConnectionState } from "@/hooks/use-connection-state";
+import { ReaderOpenFailure } from "@/components/email/reader-open-failure";
import { useContactStore } from "@/stores/contact-store";
import { useIdentityStore } from "@/stores/identity-store";
import { useUIStore, sidebarFolded, listPaneCssSize, SIDEBAR_MIN, SIDEBAR_MAX, EMAIL_LIST_MIN, EMAIL_LIST_MAX, EMAIL_LIST_HEIGHT_MIN, EMAIL_LIST_HEIGHT_MAX, READING_PANE_MIN_WIDTH, READING_PANE_MIN_HEIGHT, COMPOSER_PANE_MIN_HEIGHT } from "@/stores/ui-store";
@@ -65,6 +82,7 @@ import { atLeast, PHONE_BELOW, useDeviceDetection } from "@/hooks/use-media-quer
import { usePaneSize } from "@/hooks/use-pane-size";
import { useIsFocusedProTab, usePaneId } from "@/hooks/use-pane-context";
import { useKeyboardShortcuts, type GoToMailboxTarget } from "@/hooks/use-keyboard-shortcuts";
+import { guardOfflineMailChanges } from "@/lib/offline-key-guard";
import { useDesktopCommands } from "@/lib/desktop/commands";
import { useDesktopPrefs } from "@/lib/desktop/preferences";
import { hasDesktopWindows, popoutsAvailable } from "@/lib/popout/support";
@@ -96,6 +114,7 @@ import {
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { ScheduleSendDialog } from "@/components/email/schedule-send-dialog";
import { MailFolderMenu, MailFolderMenuItems } from "@/components/email/mail-list-menus";
+import { emptyFolderPrompt } from "@/lib/empty-folder-text";
import { ContextMenuItem } from "@/components/ui/context-menu";
import { PromptDialog } from "@/components/ui/prompt-dialog";
import { DragDropProvider } from "@/contexts/drag-drop-context";
@@ -152,7 +171,8 @@ import { ResizeHandle } from "@/components/layout/resize-handle";
import { Button } from "@/components/ui/button";
import { ToolbarButton, ToolbarSearchAction } from "@/components/ui/toolbar";
import { useDocumentTitle } from "@/hooks/use-document-title";
-import { mailTitleContext } from "@/lib/tab-title";
+import { mailTitleContext, mailboxTitleLine } from "@/lib/tab-title";
+import { useOtherAccountsUnread } from "@/hooks/use-all-inboxes-unread";
import { usePluginStore } from "@/stores/plugin-store";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { canDownload } from "@/lib/device-policy";
@@ -258,6 +278,11 @@ const UNIFIED_LIST_PUSH = {
/** A draft from outside: a mailto:, or a share with files. ParsedMailto stays JSON, for sessionStorage. */
type DraftRequest = ParsedMailto & { initialFiles?: File[]; unedited?: boolean };
+/** No network, or this login's mail server can't be reached. */
+function offlineNow(client: { getServerUrl?: () => string }): boolean {
+ return (typeof navigator !== 'undefined' && navigator.onLine === false) || isOfflineNow(client.getServerUrl?.());
+}
+
export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Static Lite build: the route params are empty, read the link from the URL.
const linkSegments = useLiteLinkSegments('mail', routeSegments);
@@ -304,7 +329,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
composerSessionRef.current = composerSessionId;
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
- const dockedSessionRef = useRef(null);
+ const [dockedSession, setDockedSession] = useState(null);
+ // A reply draft opened again from its message (compose-p2-6): it docks too.
+ const [resumedReplySession, setResumedReplySession] = useState(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -319,11 +346,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// already uses the same seam), so entry points that replace the session can
// ask before discarding unsaved text.
const composerRequestCloseRef = useRef<((afterClose?: () => void) => void) | null>(null);
+ // One composer at a time (compose-p2-2). Whatever opens a composer closes
+ // the one on screen first, the way its X does: it saves and says "Draft
+ // saved" with Discard, then the new one opens. When that save fails the old
+ // composer stays with its question, and this never settles: nothing opens.
+ const closeOpenComposer = useCallback(() => new Promise((resolve) => {
+ const requestClose = composerRequestCloseRef.current;
+ if (showComposerRef.current && requestClose) requestClose(resolve);
+ else resolve();
+ }), []);
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps();
const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client);
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
+ // A message that couldn't be fetched: the reader says so, with Retry (mail-p2-7).
+ const [openFailure, setOpenFailure] = useState<{ target: { id: string } & Partial>; offline: boolean } | null>(null);
+ const tP2Desk = useTranslations('mail_p2_desk');
// The message the palette's "Move to…" was picked for.
const [moveDialogEmail, setMoveDialogEmail] = useState(null);
// The empty reading pane's slot for a selection; the list draws into it (mail-10).
@@ -357,6 +396,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
threadId: string; emailId: string; startedAt: number; promise: Promise;
} | null>(null);
const mvConversationOpenSeqRef = useRef(0);
+ // compose-p2-6: a reply to the open message saved as a draft. The reader
+ // shows it under the message, and Reply opens it again. The conversation
+ // fetch notes the thread's drafts; a scan reads them fresh after a composer
+ // closed or a draft went.
+ const [replyDraft, setReplyDraft] = useState<{ emailId: string; draft: Email; source: DraftSource } | null>(null);
+ const [replyDraftScan, setReplyDraftScan] = useState<{ emailId: string; n: number } | null>(null);
+ const conversationDraftsRef = useRef<{ threadId: string; emailId: string; drafts: Email[] } | null>(null);
const [conversationEmails, setConversationEmails] = useState([]);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null);
@@ -1065,9 +1111,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (shortcutsTargetSelection && client) {
const count = selectedEmailKeys.size;
if (!(await confirmMailRemoval('archive', count, confirmDialog, tRemove))) return;
+ // Counted as the selection bar counts: conversations (mail-18).
+ const rows = selectionRowCount(resolveSelectedEmails(useEmailStore.getState()), count, disableThreading);
const restore = restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchArchive(client), {
- success: t('notifications.emails_archived', { count }),
+ success: batchToastTitle(t, 'archive', rows, disableThreading),
undo: undoMoveAction(client, restore, undo),
error: t('notifications.error_archiving'),
describeError: (error) => error instanceof ArchiveMailboxNotFoundError
@@ -1104,10 +1152,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
} else if (!(await confirmMailRemoval('delete', selectedEmailKeys.size, confirmDialog, tRemove))) {
return;
}
- const count = selectedEmailKeys.size;
+ const rows = selectionRowCount(resolveSelectedEmails(useEmailStore.getState()), selectedEmailKeys.size, disableThreading);
const restore = permanent ? null : restoreBatchesForSelection(client);
await runBatchEmailAction(() => batchDelete(client, permanent), {
- success: t('notifications.emails_deleted', { count }),
+ success: batchToastTitle(t, permanent ? 'delete' : 'trash', rows, disableThreading),
error: t('notifications.error_deleting'),
undo: undoMoveAction(client, restore, undo),
});
@@ -1176,12 +1224,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
onCompose: () => {
- void withComposeGate('compose', {}, () => {
- startFreshComposerSession();
- setComposerMode('compose');
- setShowComposer(true);
- if (isMobile) setActiveView('viewer');
- });
+ void handleCompose();
},
onFocusSearch: () => {
if (isScheduledView) return;
@@ -1286,7 +1329,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
- }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane]);
+ }), [activeEmails, selectedEmail, client, selectedMailbox, isMobile, selectedEmailKeys, mailboxes, isScheduledView, mvShell, cursorOnly, showCrossStarred, isUnifiedView, unifiedRole, archiveActionEnabled, deepLinkPane, disableThreading]);
// Initialize keyboard shortcuts
// Mountain View keeps the ticked rows while a message is read (#1231). The
@@ -1306,14 +1349,27 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return state.tabs.find((tab) => tab.id === id)?.kind === 'calendar';
});
const mailShortcutsApply = !isEmbedded || isFocusedProTab;
+ // Offline with no mail kept on this device, a key that changes mail can't
+ // run: say so rather than do nothing (mail-p2-7).
+ const connection = useConnectionState();
+ const canChangeMailRef = useRef(connection.canChangeMail);
+ canChangeMailRef.current = connection.canChangeMail;
+ const shortcutHandlers = useMemo(
+ () => guardOfflineMailChanges(keyboardHandlers, () => canChangeMailRef.current, () => toast.info(tP2Desk('action_offline'))),
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- tP2Desk is stable per locale
+ [keyboardHandlers],
+ );
+ // Another folder: the line about the message that didn't open goes.
+ useEffect(() => setOpenFailure(null), [selectedMailbox]);
useKeyboardShortcuts({
enabled: isAuthenticated && !showComposer && mailShortcutsApply,
singleKeyShortcuts,
emails: activeEmails,
- selectedEmailId: selectedEmail?.id,
+ // The message that didn't open is still what the keys are about.
+ selectedEmailId: selectedEmail?.id ?? openFailure?.target.id,
selectionCount: selectedEmailKeys.size,
mountainViewKeys: mvShell,
- handlers: keyboardHandlers,
+ handlers: shortcutHandlers,
});
const anyTabHandlers = useMemo(() => ({
onShowHelp: keyboardHandlers.onShowHelp,
@@ -1498,6 +1554,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// The subject is kept out until the policy has loaded: a gate that keeps
// subjects out of browser history must not let one through while unknown.
const subjectInTabTitle = usePolicyStore((s) => s.loaded && s.isFeatureEnabled('tabTitleSubjectEnabled'));
+ const otherAccountsUnread = useOtherAccountsUnread();
const titleContext = useMemo(() => {
// Composing email
const composer = showComposer
@@ -1517,12 +1574,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const mailbox = selectedMailbox ? mailboxes.find(mb => mb.id === selectedMailbox) : undefined;
if (mailbox) {
const mailboxName = localizeMailboxName(mailbox.role, mailbox.name, (k) => t(`sidebar.mailboxes.${k}`));
- const unreadCount = mailbox.unreadEmails || 0;
- mailboxLine = unreadCount > 0 ? `${mailboxName} (${unreadCount})` : mailboxName;
+ // The open account's own Inbox also counts the other accounts' (mail-6).
+ mailboxLine = mailboxTitleLine({
+ name: mailboxName,
+ unread: mailbox.unreadEmails || 0,
+ otherUnread: otherAccountsUnread,
+ ownInbox: mailbox.role === 'inbox' && !mailbox.isShared && !isUnifiedView,
+ }, t);
}
return mailTitleContext({ composer, subject, mailbox: mailboxLine }, { showSubject: subjectInTabTitle });
- }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle]);
+ }, [showComposer, composerMode, selectedEmail, selectedMailbox, mailboxes, t, subjectInTabTitle, otherAccountsUnread, isUnifiedView]);
useDocumentTitle(titleContext);
// The Window menu names this window by its folder, never by the open message (desktop-p2-5).
const windowView = useMemo(() => {
@@ -2626,7 +2688,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
headers?: Record;
labelHeaders?: Record;
composerId?: string;
- }) => {
+ }, composerState?: ComposerDraftData) => {
// Nothing is sent: the composer keeps what only goes once the mail is out.
if (!client) return false;
@@ -2706,13 +2768,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// This runs before the scheduled early-return: the undo-send delay is a
// HOLDFOR submission, so `scheduled` is true for every delayed send and
// the flag was never set. (#985)
- if (originalEmailId && (effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) {
+ // In the Undo window the flag waits until the message really goes, and
+ // Undo brings the composer back as it was, where it was (mail-p2-8).
+ const answeredSource = answeredEmailSource;
+ const markAnswered = async () => {
+ if (!originalEmailId || !(effectiveMode === 'reply' || effectiveMode === 'replyAll' || effectiveMode === 'forward')) return;
const keyword = effectiveMode === 'forward' ? '$forwarded' : '$answered';
try {
- await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredEmailSource);
+ await useEmailStore.getState().markEmailKeyword(client, originalEmailId, keyword, answeredSource);
} catch (e) {
debug.error(`Failed to set ${keyword} keyword:`, e);
}
+ };
+ const held = holdSend(result, {
+ answer: markAnswered,
+ origin: composerState ? { kind: 'composer', state: composerState, sourceEmailId: originalEmailId } : undefined,
+ });
+ if (!held) await markAnswered();
+ if (effectiveMode === 'reply' || effectiveMode === 'replyAll') {
+ const replied = composerSourceEmail?.id === originalEmailId ? composerSourceEmail : null;
+ echoSentReply(replied, {
+ emailId: result.emailId, to: data.to, cc: data.cc, subject: data.subject, text: data.body,
+ fromEmail: data.fromEmail, fromName: data.fromName,
+ });
}
const refreshRepliedThreadAfterSend = async (emailId: string | undefined) => {
@@ -2858,11 +2936,51 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPendingDraft(null);
}, []);
+ // A new message, from every Compose and New message there is.
+ const handleCompose = async (onOpen?: () => void) => {
+ await closeOpenComposer();
+ await withComposeGate('compose', {}, () => {
+ startFreshComposerSession();
+ setComposerMode('compose');
+ setShowComposer(true);
+ if (isMobile) setActiveView('viewer');
+ onOpen?.();
+ });
+ };
+
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
+ // Reply on a message with a reply draft opens that draft (compose-p2-6).
+ // After a composer closed (it may have saved that very draft under a new
+ // id) the drafts are read fresh.
+ const resumeReplyDraft = async (open: Email | null, composerWasOpen: boolean): Promise => {
+ if (!client || !open || open.keywords?.$draft || mvShell || isScheduledView) return false;
+ let found = replyDraft?.emailId === open.id ? replyDraft : null;
+ // A phone's conversation holds its drafts in full.
+ const phoneConversation = isMobile && conversationEmails.some((e) => e.id === open.id);
+ if (composerWasOpen || phoneConversation) {
+ const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
+ if (!threadClient) return false;
+ const drafts = phoneConversation && !composerWasOpen
+ ? conversationEmails
+ : await threadDrafts(threadClient, open.threadId, accountId).catch(() => []);
+ const draft = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
+ found = draft ? {
+ emailId: open.id, draft,
+ source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
+ } : null;
+ }
+ if (!found) return false;
+ await handleEditDraft(found.draft, found.source, { underMessage: true });
+ return true;
+ };
+
const handleReply = async (draftText?: string) => {
+ const composerWasOpen = showComposer;
+ await closeOpenComposer();
+ if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReply.transform(selectedEmail);
@@ -2894,13 +3012,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
* undone or cancelled - the account it was sent from (a group or shared one
* for mail sent from its identity) and the login that sent it.
*/
- const handleEditDraft = async (email?: Email, source?: DraftSource) => {
+ // `underMessage`: a reply draft resumed from the message it answers docks
+ // under that message, as a new reply does (compose-p2-6).
+ const handleEditDraft = async (email?: Email, source?: DraftSource, { underMessage = false }: { underMessage?: boolean } = {}) => {
if (!client) return;
const draftCandidate = email && typeof email === 'object' && typeof email.id === 'string'
? email
: selectedEmail;
let draft = draftCandidate;
if (!draft) return;
+ await closeOpenComposer();
// The login and account the draft lives in (resolveDraftSource). The
// composer removes the old version there when it saves, sends or
@@ -2942,7 +3063,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Increment session ID to force the composer to remount with fresh state,
// even if it was already open (e.g. right-clicking a draft while composing).
- setComposerSessionId(id => id + 1);
+ const nextSession = composerSessionRef.current + 1;
+ setComposerSessionId(nextSession);
+ setResumedReplySession(underMessage ? nextSession : null);
setPendingDraft(composerDataFromDraft(draft, {
accountId,
localAccountId,
@@ -3032,6 +3155,69 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
);
};
+ // A send in its Undo window (mail-p2-8): what waits until it really goes
+ // (the answered flag), and where Undo puts it back. Keyed by submission.
+ const heldSendsRef = useRef(new Map());
+ const releaseHeldSend = (submissionId: string) => {
+ const held = heldSendsRef.current.get(submissionId);
+ if (!held) return;
+ heldSendsRef.current.delete(submissionId);
+ void held.answer?.();
+ };
+ /** True when the send waits in its Undo window; `extras` then wait with it. */
+ const holdSend = (result: SendEmailResult, extras: Omit): boolean => {
+ if (!result.scheduled || !result.emailSubmissionId || !isUndoWindow(result.sendAt)) return false;
+ const submissionId = result.emailSubmissionId;
+ heldSendsRef.current.set(submissionId, { ...extras, emailId: result.emailId });
+ const remainingMs = Math.max(Date.parse(result.sendAt!) - Date.now(), 0);
+ setTimeout(() => releaseHeldSend(submissionId), remainingMs);
+ return true;
+ };
+ // Undo: the message goes back where it was written, as it was.
+ const restoreUndoneSend = async (
+ held: HeldSendExtras | undefined,
+ restored: Email | null | undefined,
+ pending: { submissionAccountId?: string },
+ undoLogin: string | undefined,
+ undoClient: NonNullable,
+ ): Promise => {
+ const origin = held?.origin;
+ if (!origin || !restored) return false;
+ const openNow = useEmailStore.getState().selectedEmail;
+ const reopen = async (emailId: string): Promise => {
+ if (openNow?.id === emailId) return true;
+ const listed = useEmailStore.getState().emails.find((e) => e.id === emailId);
+ if (!listed) return false;
+ return (await handleEmailSelect(listed)) === 'opened';
+ };
+ if (origin.kind === 'quick-reply') {
+ if (!(await reopen(origin.emailId))) return false;
+ // Back in the quick reply box, so the draft the server made of it goes.
+ try {
+ await undoClient.deleteEmail(restored.id, pending.submissionAccountId);
+ } catch (error) {
+ debug.error('Undid a quick reply, but removing its draft failed:', error);
+ }
+ useQuickReplyRestoreStore.getState().restore(origin.emailId, origin.text);
+ return true;
+ }
+ const state: ComposerDraftData = {
+ ...origin.state,
+ draftId: restored.id,
+ draftAccountId: pending.submissionAccountId,
+ draftLocalAccountId: undoLogin,
+ };
+ // A reply docks under its message again; with that message gone from
+ // view it opens in the pane, still as a reply.
+ if (origin.sourceEmailId && state.mode !== 'compose' && !(await reopen(origin.sourceEmailId))) selectEmail(null);
+ startFreshComposerSession();
+ setPendingDraft(state);
+ setComposerMode(state.mode);
+ setShowComposer(true);
+ if (isMobile) setActiveView('viewer');
+ return true;
+ };
+
useEffect(() => {
if (!pendingUndoSend || !client) return;
if (lastUndoToastSubmissionRef.current === pendingUndoSend.submissionId) return;
@@ -3062,10 +3248,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A message scheduled for later says when it goes, and offers Cancel
// where a send-delay window offers Undo send.
- toast.success(isUndoWindow
+ const undoToastId = toast.success(isUndoWindow
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
: t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
- duration: undoDurationMs,
+ // An undo window turns into "Sent" as Undo ends (runUndoSendCountdown).
+ duration: isUndoWindow ? undoDurationMs + UNDO_TOAST_SLACK_MS : undoDurationMs,
pauseOnHover: false,
secondaryAction: (pending.emailId && pending.identityId)
? {
@@ -3083,6 +3270,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
pending.submissionAccountId,
);
clearPendingUndoSend();
+ releaseHeldSend(pending.submissionId);
// The recipients are checked again for the release.
const refused = partlyRefusedWarning(result, tComposer);
if (refused) toast.warning(refused);
@@ -3120,10 +3308,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
// The send is undone; opening the draft or refreshing the list
// failing is no reason to say otherwise.
+ const held = heldSendsRef.current.get(pending.submissionId);
+ heldSendsRef.current.delete(pending.submissionId);
+ dropEcho(pending.emailId);
try {
// Never left the server: plugins don't hear about it.
cancelAnnouncedSend(pending.submissionId);
- if (restored && !pending.isSmime) {
+ if (!pending.isSmime && await restoreUndoneSend(held, restored, pending, undoLogin, undoClient)) {
+ // Back where it was written.
+ } else if (restored && !pending.isSmime) {
// Back in the Drafts of the account it was sent from, through
// the login that sent it.
await handleEditDraft(restored, { accountId: pending.submissionAccountId, localAccountId: undoLogin });
@@ -3137,6 +3330,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
},
});
+ // Counts down, then "Sent" with View (compose-p2-12).
+ if (isUndoWindow) {
+ const sentId = pending.emailId;
+ const viewable = sentId && undoClient === client;
+ const ownerAccountId = pending.submissionAccountId && pending.submissionAccountId !== client.getAccountId() ? pending.submissionAccountId : undefined;
+ runUndoSendCountdown(undoToastId, {
+ sendAt: Date.parse(pending.sendAt),
+ undoEndsAt: Date.now() + undoDurationMs,
+ maxSeconds: sendDelaySeconds,
+ sendingIn: (seconds) => t('email_viewer.sending_in', { seconds }),
+ sent: t('compose_send.sent'),
+ view: viewable
+ ? { label: t('compose_send.view'), onClick: () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId }) }
+ : undefined,
+ });
+ }
+
const timer = setTimeout(() => {
const current = useEmailStore.getState().pendingUndoSend;
if (current?.submissionId === pending.submissionId) {
@@ -3155,6 +3365,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [cancelUndoSend, clearPendingUndoSend, client, fetchScheduledEmails, isScheduledView, pendingUndoSend?.submissionId, sendDelaySeconds, t, tComposer, tViewer]);
const handleReplyAll = async (draftText?: string) => {
+ const composerWasOpen = showComposer;
+ await closeOpenComposer();
+ if (!draftText && await resumeReplyDraft(selectedEmail, composerWasOpen)) return;
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToReplyAll.transform(selectedEmail);
@@ -3244,6 +3457,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}, [isEmbedded]);
const handleForward = async () => {
+ await closeOpenComposer();
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
const enrichedEmail = await emailHooks.onBeforeComposeOpenToForward.transform(selectedEmail);
@@ -3290,6 +3504,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// unselected row. See the list context-menu wiring below.
const handleForwardAsAttachment = async (email: Email | null = selectedEmail) => {
if (!email) return;
+ await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForwardAsAttachment.transform(email);
// Same filename options "Export as .eml" uses (see emailFilenameOptions
// in email-viewer.tsx), so the two actions produce consistent filenames
@@ -3358,6 +3573,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const handleBatchForwardAsAttachment = async () => {
const selected = resolveSelectedEmails(useEmailStore.getState());
if (selected.length < 2) return; // menu only offers this for >1; guards future wiring
+ await closeOpenComposer();
const {
emailDownloadTemplate,
@@ -4106,12 +4322,12 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const mailbox = mailboxes.find(mb => mb.id === mailboxId);
if (!mailbox) return;
+ // "Empty Trash", with how many messages go (p2-14).
+ const prompt = emptyFolderPrompt(mailbox, emptyFolderMovesToTrash(mailbox));
const confirmed = await confirmDialog({
- title: tCtxMenu('email_list.empty_folder.confirm_title'),
- message: emptyFolderMovesToTrash(mailbox)
- ? tCtxMenu('email_list.empty_folder.confirm_message_trash')
- : tCtxMenu('email_list.empty_folder.confirm_message'),
- confirmText: tCtxMenu('email_list.empty_folder.confirm_button'),
+ title: tCtxMenu(prompt.title),
+ message: tCtxMenu(prompt.message, prompt.values),
+ confirmText: tCtxMenu(prompt.confirm),
variant: "destructive",
});
if (!confirmed) return;
@@ -4643,13 +4859,23 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// account so the flag lands on shared/group-mailbox messages instead of
// being dropped against the reaching account. (#281) Runs before the
// scheduled early-return so a delayed (undo-send) reply is flagged too. (#985)
- {
+ // In the Undo window it waits until the reply really goes (mail-p2-8).
+ const markAnswered = async () => {
try {
await useEmailStore.getState().markEmailKeyword(client, originalEmailId, '$answered', originalSource);
} catch (e) {
debug.error('Failed to set $answered keyword:', e);
}
- }
+ };
+ const held = holdSend(result, {
+ answer: markAnswered,
+ origin: { kind: 'quick-reply', emailId: originalEmailId, text: body },
+ });
+ if (!held) await markAnswered();
+ echoSentReply(selectedEmail, {
+ emailId: result.emailId, to: recipients, subject: labelled.subject, text: finalBody,
+ fromEmail: headerFromEmail, fromName: headerFromName,
+ });
const refreshInlineRepliedThread = async () => {
const emailState = useEmailStore.getState();
@@ -4749,6 +4975,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
const scheduledSubmissionByEmailId = useEmailStore.getState().scheduledSubmissionByEmailId;
const annotated = annotateScheduledEmails(emails, scheduledSubmissionByEmailId);
+ conversationDraftsRef.current = {
+ threadId: ref.threadId, emailId: ref.id,
+ drafts: annotated.filter((e) => e.id !== ref.id && e.keywords?.$draft),
+ };
return annotated
.filter((e) => e.id === ref.id || !e.keywords?.$draft)
.sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
@@ -4778,6 +5008,102 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message, not on every list refresh
}, [mvConversationThreadId, mvConversationRef?.id, mvBlobSource.client, mvBlobSource.accountId, client]);
+ // mail-p2-1: a reply that went out shows at the end of the open
+ // conversation at once, then the server's reading of the thread replaces
+ // it (a moment later, and again after a few seconds for a server that
+ // threads new mail late).
+ const confirmConversation = async (ref: Email) => {
+ if (!client || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
+ mvConversationLoadRef.current = null;
+ const fresh = await loadMvConversation(ref, client);
+ if (!fresh || useEmailStore.getState().selectedEmail?.threadId !== ref.threadId) return;
+ // A reply read back in its Undo window carries its send time; once that
+ // has passed it went out, whatever the scheduled list said then.
+ const settled = settleSent(fresh);
+ setMvConversationResult((prev) => prev && prev.threadId === ref.threadId
+ ? { ...prev, emails: mergeEchoes(settled, prev.emails) }
+ : prev);
+ };
+ const echoSentReply = (replied: Email | null | undefined, sent: {
+ emailId?: string; to: string[]; cc?: string[]; subject: string; text: string; fromEmail?: string; fromName?: string;
+ }) => {
+ if (!replied || !client || !readerConversationView) return;
+ const open = useEmailStore.getState().selectedEmail;
+ if (!open || open.threadId !== replied.threadId) return;
+ const echo = sentEcho({ ...sent, threadId: replied.threadId });
+ setMvConversationResult((prev) => {
+ if (prev && prev.threadId === replied.threadId) return { ...prev, emails: addEcho(prev.emails, echo) };
+ if (prev) return prev;
+ // A message alone so far: it becomes a conversation of two.
+ return { emails: [open, echo], source: resolveEmailBlobContext(open, client), threadId: replied.threadId };
+ });
+ for (const delay of [2000, 9000]) setTimeout(() => { void confirmConversation(open); }, delay);
+ };
+ const dropEcho = (emailId: string | undefined) => {
+ if (!emailId) return;
+ setMvConversationResult((prev) => {
+ if (!prev || !prev.emails.some((e) => e.id === emailId)) return prev;
+ const emails = prev.emails.filter((e) => e.id !== emailId);
+ return emails.length > 1 ? { ...prev, emails } : null;
+ });
+ };
+
+ // The open message's reply draft (compose-p2-6). Not in Mountain View, which
+ // has its own conversation, nor on a phone, whose conversation lists drafts.
+ const replyDraftMessageIds = String(selectedEmail?.messageId ?? '');
+ useEffect(() => {
+ const open = selectedEmail;
+ if (!client || !open || open.keywords?.$draft || mvShell || isMobile || isScheduledView || !replyDraftMessageIds) {
+ setReplyDraft(null);
+ return;
+ }
+ const fresh = replyDraftScan?.emailId === open.id;
+ const noted = conversationDraftsRef.current;
+ const known = noted && noted.threadId === open.threadId && noted.emailId === open.id ? noted.drafts : null;
+ if (!fresh && !known) {
+ // The conversation isn't here yet: keep what this message had.
+ setReplyDraft((prev) => (prev?.emailId === open.id ? prev : null));
+ return;
+ }
+ if (!fresh && known?.length === 0) {
+ setReplyDraft(null);
+ return;
+ }
+ const { client: threadClient, accountId, clientAccountId } = resolveEmailBlobContext(open, client);
+ if (!threadClient) return;
+ let cancelled = false;
+ void (async () => {
+ const drafts = fresh ? await threadDrafts(threadClient, open.threadId, accountId).catch(() => []) : known ?? [];
+ const found = await findReplyDraft(drafts, open, (id) => threadClient.getEmail(id, accountId));
+ if (cancelled) return;
+ setReplyDraft(found ? {
+ emailId: open.id,
+ draft: { ...found, sourceClientAccountId: open.sourceClientAccountId, sourceAccountId: open.sourceAccountId },
+ source: { accountId, localAccountId: clientAccountId ?? activeAccountId ?? undefined },
+ } : null);
+ })();
+ return () => { cancelled = true; };
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the open message and what can change its drafts
+ }, [selectedEmail?.id, replyDraftMessageIds, mvConversationResult, replyDraftScan, client, mvShell, isMobile, isScheduledView]);
+ // A composer closing may have saved, sent or discarded a reply draft.
+ const scanReplyDrafts = () => {
+ const open = useEmailStore.getState().selectedEmail;
+ if (open) setReplyDraftScan((prev) => ({ emailId: open.id, n: (prev?.n ?? 0) + 1 }));
+ };
+ const composerWasOpenRef = useRef(showComposer);
+ useEffect(() => {
+ if (composerWasOpenRef.current && !showComposer) scanReplyDrafts();
+ composerWasOpenRef.current = showComposer;
+ }, [showComposer]);
+ const discardReplyDraft = () => {
+ const kept = replyDraft;
+ if (!kept) return;
+ setReplyDraft(null);
+ discardWithUndo(() => {
+ void handleDiscardDraft(kept.draft.id, kept.source).then(scanReplyDrafts);
+ }, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setReplyDraft(kept));
+ };
+
const patchMvConversation = (id: string, patch: (e: Email) => Email) =>
setMvConversationResult(prev => prev ? {
...prev, emails: prev.emails.map(e => e.id === id ? patch(e) : e),
@@ -4851,14 +5177,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onImportEmail: handleImportEmailFromContextMenu,
onRefreshMailboxes: handleRefreshMailboxes,
onCompose: () => {
- void withComposeGate('compose', {}, () => {
- startFreshComposerSession();
- setComposerMode('compose');
- setShowComposer(true);
- if (isMobile) {
- setSidebarOpen(false);
- setActiveView('viewer');
- }
+ void handleCompose(() => {
+ if (isMobile) setSidebarOpen(false);
});
},
onSidebarClose: () => setSidebarOpen(false),
@@ -4943,6 +5263,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? localizeMailboxName(mb.role, mb.name, (k) => t(`sidebar.mailboxes.${k}`))
: "Inbox";
})();
+ // A send in its Undo window isn't counted in Sent or Scheduled yet (mail-p2-8).
+ const heldSendView = withoutHeldSend(mailboxes, scheduledTotal, pendingUndoSend);
const isFocusedMailLayout = mailLayout === 'focus';
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile;
// Under Mountain View on desktop the composer floats over the list in a
@@ -4956,13 +5278,29 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Decided as it opens and kept for the session: moving it would mount it
// anew when the message goes away (another folder opened meanwhile).
const dockCandidate = !mvShell && !isMobile && showComposer && !isEmbedded && !floatingComposer
- && (pendingDraft?.mode ?? composerMode) !== 'compose';
- if (dockCandidate && selectedEmail && !selectedEmail.keywords?.$draft) dockedSessionRef.current = composerSessionId;
- const dockedReply = dockCandidate && dockedSessionRef.current === composerSessionId;
+ && ((pendingDraft?.mode ?? composerMode) !== 'compose' || resumedReplySession === composerSessionId);
+ const dockOnOpen = dockCandidate && Boolean(selectedEmail) && !selectedEmail?.keywords?.$draft;
+ if (dockOnOpen && dockedSession !== composerSessionId) setDockedSession(composerSessionId);
+ const dockedReply = dockCandidate && (dockOnOpen || dockedSession === composerSessionId);
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
+ // Rows ticked beyond the open message take the reading pane (mail-p2-3):
+ // the selection bar and the keys act on the ticked rows, so a reader
+ // toolbar acting on the one open message would be a second, different set.
+ // Unticking brings the message back. Mountain View keeps the message while
+ // rows are ticked (#1231); the focus layout can't tick rows while reading.
+ const openRowKeys = (() => {
+ if (!selectedEmail) return null;
+ const key = emailKeyFor(selectedEmail);
+ // A conversation row ticks all its messages: the open one's whole row.
+ const row = useEmailStore.getState().listRows?.find((r) => r.key === key || r.members.includes(key));
+ return new Set(row ? [key, ...row.members] : [key]);
+ })();
+ const selectionTakesReader = !isMobile && !isFocusedMailLayout && !mvShell && !isScheduledView
+ && selectedEmailKeys.size > 0
+ && !(openRowKeys && [...selectedEmailKeys].every((key) => openRowKeys.has(key)));
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
- || Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
+ || Boolean(selectedEmail && !(mvShell && cursorOnly) && !selectionTakesReader) || deepLinkPane !== null;
// A linked message on a phone opens over the list, as one picked there does.
const mobileView = deepLinkPane !== null && isMobile ? 'viewer' : activeView;
const shouldCollapseListPane = !isMobile && isFocusedMailLayout && hasViewerContent;
@@ -4987,6 +5325,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
): Promise => {
if (!client || !email) return new Error('the mail page is not ready');
setCursorOnly(false);
+ setOpenFailure(null);
// If composing, close the composer the way its X does: it keeps the
// draft and says so. Mountain View's compose window stays open while
@@ -5111,11 +5450,24 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Mark-as-read logic is now handled by useEffect
return 'opened';
}
+ // The client answers a failed fetch with no message. Offline that
+ // says nothing about the message: the reader says it needs a connection.
+ if (offlineNow(client)) {
+ abandonOpenEmail(null);
+ setOpenFailure({ target: email, offline: true });
+ return new Error('offline');
+ }
abandonOpenEmail(previousEmail);
return 'missing';
} catch (error) {
console.error('Failed to fetch email content:', error);
- if (openSeq === emailOpenSeqRef.current) abandonOpenEmail(previousEmail);
+ if (openSeq === emailOpenSeqRef.current) {
+ // Offline the reader says why instead of going blank or back to the
+ // last message; another failure says so too when there's nothing to go back to.
+ const offline = offlineNow(client);
+ abandonOpenEmail(offline ? null : previousEmail);
+ if (offline || !previousEmail) setOpenFailure({ target: email, offline });
+ }
return error instanceof Error ? error : new Error(String(error));
}
};
@@ -5188,6 +5540,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// Handle reply from conversation view
const handleConversationReply = async (email: Email) => {
+ const composerWasOpen = showComposer;
+ await closeOpenComposer();
+ if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReply.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'reply');
@@ -5200,6 +5555,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationReplyAll = async (email: Email) => {
+ const composerWasOpen = showComposer;
+ await closeOpenComposer();
+ if (await resumeReplyDraft(email, composerWasOpen)) return;
email = await emailHooks.onBeforeComposeOpenToReplyAll.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'replyAll');
@@ -5212,6 +5570,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
const handleConversationForward = async (email: Email) => {
+ await closeOpenComposer();
email = await emailHooks.onBeforeComposeOpenToForward.transform(email);
selectEmail(email);
await prepareComposerQuoteHeader(email, 'forward');
@@ -5307,8 +5666,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
prefill={composerPrefill}
initialFiles={composerInitialFiles}
sessionKey={`inline-${composerSessionId}`}
+ docked={composerHost === 'dock'}
sourceEmailId={answeredEmailId}
onSaveState={(data) => {
+ // A composer whose session another one has replaced: its state
+ // belongs to no composer on screen. Taken as the new session's,
+ // it turned a new message into the old reply and moved it between
+ // the dock and the pane for ever (React error 185, compose-p2-1).
+ // Whatever opens a composer closes the old one first, which saves it.
+ if (composerSessionRef.current !== composerSessionId) return;
setComposerPrefill(null);
if (suppressComposerStateSaveSessionRef.current === composerSessionId) {
suppressComposerStateSaveSessionRef.current = null;
@@ -5336,10 +5702,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
setPendingDraft(data);
}}
- onSend={async (data) => {
- const outcome = await handleEmailSend(data);
+ onSend={async (data, state) => {
+ const outcome = await handleEmailSend(data, state);
setPendingDraft(null);
setComposerPrefill(null);
+ scanReplyDrafts();
return outcome;
}}
onSendFailed={(state) => {
@@ -5370,7 +5737,18 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
requestCloseRef={composerRequestCloseRef}
onDiscardDraft={(draftId, draft) => {
handleDiscardDraft(draftId, draft);
- setPendingDraft(null);
+ // The bin and the toasts' Discard delete once their Undo is over,
+ // when another composer may be open: its draft stays.
+ if (composerSessionRef.current === composerSessionId) setPendingDraft(null);
+ }}
+ onRestoreDiscarded={(data) => {
+ void closeOpenComposer().then(() => {
+ startFreshComposerSession();
+ setPendingDraft(data);
+ setComposerMode(data.mode);
+ setShowComposer(true);
+ if (isMobile) setActiveView('viewer');
+ });
}}
popOut={popoutsOn && !isEmbedded ? composerPopOut : undefined}
/>
@@ -5520,10 +5898,10 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
>
handleMarkFolderRead(selectedMailbox) : undefined}
onMarkAllFoldersRead={handleMarkAllFoldersRead}
onEmptyFolder={folderIsEmptyable && selectedMailbox ? () => void handleEmptyFolderFromContextMenu(selectedMailbox) : undefined}
+ folderRole={conversationRole}
onPicked={close}
/>
>
@@ -5688,24 +6067,15 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Select / Select All toggle */}
{
- if (selectedEmailKeys.size > 0) {
- if (selectedEmailKeys.size === activeEmails.length) {
- clearSelection();
- } else {
- selectAllEmails();
- }
- } else if (activeEmails.length > 0) {
- // The open message when it is in this list, else the
- // first row - as an email, so the selection is keyed by
- // its owning account and not by a colliding id.
- const openKey = selectedEmail ? emailKeyFor(selectedEmail) : null;
- const target = activeEmails.find((e) => emailKeyFor(e) === openKey) ?? activeEmails[0];
- toggleEmailSelection(target);
- }
+ // One click ticks every loaded row, a second clears
+ // them, as Gmail's box does (mail-p2-11). The bar then
+ // offers the rest of the folder.
+ if (selectedEmailKeys.size > 0) clearSelection();
+ else if (activeEmails.length > 0) selectAllEmails();
}}
disabled={isScheduledView}
active={selectedEmailKeys.size > 0}
- label={selectedEmailKeys.size > 0 ? (selectedEmailKeys.size === activeEmails.length ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')) : t('email_list.batch_actions.select')}
+ label={selectedEmailKeys.size > 0 ? t('email_list.batch_actions.clear_selection') : t('email_list.batch_actions.select_all')}
title={isScheduledView ? t('email_viewer.scheduled_actions_only') : undefined}
>
{selectedEmailKeys.size > 0 ? : }
@@ -5852,7 +6222,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onRescheduleScheduled={(email) => setRescheduleTarget(email)}
onCancelScheduledSend={handleCancelScheduledSend}
- onEmailSelect={handleEmailSelect}
+ // A click (or Enter) on a draft opens it in the composer, as
+ // Gmail and Outlook do (compose-p2-8). j/k still read it.
+ // Mountain View and Scheduled keep their own ways.
+ onEmailSelect={!mvShell && !isScheduledView && !isEmbedded ? (email) => {
+ if (email.keywords?.$draft) void handleEditDraft(email).catch(reportDraftOpenFailure);
+ else void handleEmailSelect(email);
+ } : handleEmailSelect}
onOpenSearchResult={handleEmailSelect}
onEmailPrefetch={handleEmailPrefetch}
onEmailDoubleClick={!isEmbedded ? (desktopDoubleClick ? (email) => {
@@ -5915,12 +6291,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onClearSearch={() => void handleClearSearch()}
onCompose={() => {
- void withComposeGate('compose', {}, () => {
- startFreshComposerSession();
- setComposerMode('compose');
- setShowComposer(true);
- if (isMobile) setActiveView('viewer');
- });
+ void handleCompose();
}}
onMarkAsRead={async (email, read) => {
await markReadWithUndo(email, read).catch(toastUpdateFailure);
@@ -5963,12 +6334,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
button no longer sits on the list's rows. */}
{!mvShell && (isMobile || isEmbedded) && {
- void withComposeGate('compose', {}, () => {
- startFreshComposerSession();
- setComposerMode('compose');
- setShowComposer(true);
- if (isMobile) setActiveView('viewer');
- });
+ void handleCompose();
}}
className={cn(
// Under the drawer's backdrop (z-40), which comes earlier in the page.
@@ -6096,6 +6462,22 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
onReply={handleConversationReply}
onReplyAll={handleConversationReplyAll}
onForward={handleConversationForward}
+ onEditDraft={(draft) => {
+ void handleEditDraft(draft, {
+ accountId: conversationBlobSource?.accountId,
+ localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
+ }).catch(reportDraftOpenFailure);
+ }}
+ onDiscardDraft={(draft) => {
+ const before = conversationEmails;
+ setConversationEmails(before.filter((e) => e.id !== draft.id));
+ discardWithUndo(() => {
+ void handleDiscardDraft(draft.id, {
+ accountId: conversationBlobSource?.accountId,
+ localAccountId: conversationBlobSource?.clientAccountId ?? activeAccountId ?? undefined,
+ });
+ }, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setConversationEmails(before));
+ }}
blobSource={conversationBlobSource}
onDownloadAttachment={handleDownloadConversationAttachment}
onMarkAsRead={async (emailId, read) => {
@@ -6140,9 +6522,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
<>
{
+ const full = e.bodyValues ? e : await handleLoadConversationEmail(e);
+ await handleConversationForward(full ?? e);
+ } : undefined}
mvInlineComposer={mvInlineReply ? {composerNode} : undefined}
replyComposerOpen={dockedReply}
+ replyDraft={replyDraft && selectedEmail && replyDraft.emailId === selectedEmail.id ? {
+ preview: replyDraft.draft.preview,
+ onEdit: () => void handleEditDraft(replyDraft.draft, replyDraft.source, { underMessage: true }).catch(reportDraftOpenFailure),
+ onDiscard: discardReplyDraft,
+ } : null}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
@@ -6243,15 +6634,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
}}
onCompose={() => {
- void withComposeGate('compose', {}, () => {
- startFreshComposerSession();
- setComposerMode('compose');
- setShowComposer(true);
- });
+ void handleCompose();
}}
emptyPaneContent={emptyPaneBesideList && selectedEmailKeys.size > 0 && !isScheduledView && !mvShell
?
- : undefined}
+ : openFailure && !isLoadingEmail
+ ? void handleEmailSelect(openFailure.target)} />
+ : undefined}
emptyPaneFooter={emptyPaneBesideList ? : undefined}
currentUserEmail={client?.getUsername()}
currentUserName={client?.getUsername()?.split("@")[0]}
@@ -6271,11 +6660,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
>
)}
- {dockedReply && (
-
- {composerNode}
-
- )}
+ {dockedReply && {composerNode} }
>
)}
diff --git a/components/mail/reply-dock.tsx b/components/mail/reply-dock.tsx
new file mode 100644
index 000000000..d0f79c2eb
--- /dev/null
+++ b/components/mail/reply-dock.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
+import { useTranslations } from "next-intl";
+import { ChevronDown, ChevronUp } from "@/components/icons";
+import { cn } from "@/lib/utils";
+
+/**
+ * Below this reading-pane height a docked reply can't share the pane with
+ * the message: an 800×600 window or a 1366×768 laptop at 150 % left two
+ * lines to type in and none of the message (compose-p2-4).
+ */
+export const DOCK_SHARE_MIN_PANE = 700;
+
+/**
+ * How the reply docked under its message takes the reading pane
+ * (mail-p2-2, compose-p2-4).
+ *
+ * - A tall pane: the reply starts as high as its content (the compact
+ * composer: To, a few lines, the bottom bar) and grows with the text up to
+ * half the pane, so the message stays readable above it.
+ * - A short pane: the reply takes the pane, with "Show the message" to fold it
+ * to a bar and read the message, and back.
+ */
+export function dockLayout(paneHeight: number): { mode: 'share' | 'takeover'; maxHeight?: number } {
+ if (paneHeight > 0 && paneHeight < DOCK_SHARE_MIN_PANE) return { mode: 'takeover' };
+ return { mode: 'share', maxHeight: paneHeight > 0 ? Math.round(paneHeight / 2) : undefined };
+}
+
+export function ReplyDock({ children }: { children: ReactNode }) {
+ const t = useTranslations("mail_p2_conv");
+ const ref = useRef(null);
+ const [paneHeight, setPaneHeight] = useState(0);
+ // The reader's toolbar and subject above its scrolling part: they stay.
+ const [readerChrome, setReaderChrome] = useState(0);
+ const [showMessage, setShowMessage] = useState(false);
+
+ useLayoutEffect(() => {
+ const pane = ref.current?.parentElement;
+ if (!pane) return;
+ const measure = () => {
+ const paneBox = pane.getBoundingClientRect();
+ setPaneHeight(Math.round(paneBox.height));
+ const scroll = pane.querySelector("[data-reader-scroll]");
+ setReaderChrome(scroll ? Math.max(0, Math.round(scroll.getBoundingClientRect().top - paneBox.top)) : 0);
+ };
+ measure();
+ if (typeof ResizeObserver === "undefined") return;
+ const ro = new ResizeObserver(measure);
+ ro.observe(pane);
+ return () => ro.disconnect();
+ }, []);
+
+ const layout = dockLayout(paneHeight);
+ const takeover = layout.mode === "takeover";
+ const folded = takeover && showMessage;
+
+ return (
+
+ {takeover && (
+
setShowMessage((v) => !v)}
+ aria-expanded={showMessage}
+ data-dock-show-message=""
+ className="flex shrink-0 items-center justify-center gap-1.5 border-b border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
+ >
+ {showMessage ? : }
+ {showMessage ? t("back_to_reply") : t("show_message")}
+
+ )}
+ {/* Folded, the composer stays mounted, with its text and its undo history. */}
+
+ {children}
+
+
+ );
+}
diff --git a/components/offline/__tests__/offline-banner-no-store.test.tsx b/components/offline/__tests__/offline-banner-no-store.test.tsx
new file mode 100644
index 000000000..ea6f40aa3
--- /dev/null
+++ b/components/offline/__tests__/offline-banner-no-store.test.tsx
@@ -0,0 +1,48 @@
+import { act, cleanup, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { OfflineBanner } from '../offline-banner';
+import { useAuthStore } from '@/stores/auth-store';
+import { useOfflineStore } from '@/lib/offline/state';
+
+// pass-2 mail-p2-7: in a browser tab that keeps no mail on the device, going
+// offline showed no bar anywhere; a click on a message then did nothing.
+
+const setOnline = (online: boolean) => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
+ window.dispatchEvent(new Event(online ? 'online' : 'offline'));
+};
+
+beforeEach(() => {
+ useAuthStore.setState({ activeAccountId: 'acc', serverUrl: 'https://mail.example.org' });
+ useOfflineStore.setState({ enabled: [], outbox: [], conflicts: [], areaConflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
+});
+
+afterEach(() => {
+ cleanup();
+ setOnline(true);
+});
+
+describe('the offline bar for an account that keeps no mail here', () => {
+ it('says that reading and changing mail needs a connection, and goes once online', () => {
+ render( );
+ expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
+ act(() => setOnline(false));
+ expect(screen.getByTestId('offline-banner-no-store').textContent).toBe('offline_bar');
+ act(() => setOnline(true));
+ expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
+ });
+
+ it('leaves an account with offline mode its own bar', () => {
+ useOfflineStore.setState({ enabled: ['acc'], browserOffline: true });
+ render( );
+ expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
+ expect(screen.getByTestId('offline-banner')).toBeTruthy();
+ });
+
+ it('shows nothing when signed out', () => {
+ useAuthStore.setState({ activeAccountId: null });
+ act(() => setOnline(false));
+ render( );
+ expect(screen.queryByTestId('offline-banner-no-store')).toBeNull();
+ });
+});
diff --git a/components/offline/__tests__/offline-host.test.tsx b/components/offline/__tests__/offline-host.test.tsx
index 009be25e7..b022cfd89 100644
--- a/components/offline/__tests__/offline-host.test.tsx
+++ b/components/offline/__tests__/offline-host.test.tsx
@@ -63,7 +63,8 @@ describe('the notice that the desktop app keeps mail on this device', () => {
render( );
offlineNotify({ kind: 'default-on' });
expect(useToastStore.getState().toasts).toHaveLength(0);
- expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['default_on']);
+ // One line now (pass-2 mail-p2-19): the text that fits it, with Open settings.
+ expect(firstStartNoticesWaiting().map((n) => n.text)).toEqual(['offline_default_on']);
expect(firstStartNoticesWaiting()[0].action?.label).toBe('open_settings');
});
});
diff --git a/components/offline/offline-banner.tsx b/components/offline/offline-banner.tsx
index 4efe20b44..d3a678980 100644
--- a/components/offline/offline-banner.tsx
+++ b/components/offline/offline-banner.tsx
@@ -6,6 +6,7 @@ import { CloudOff, X } from '@/components/icons';
import { Dialog } from '@/components/ui/dialog';
import { IconButton } from '@/components/ui/icon-button';
import { useCloseBesideControls } from '@/hooks/use-close-beside-controls';
+import { useConnectionState } from '@/hooks/use-connection-state';
import { IS_LITE } from '@/lib/lite';
import { serverKey, useOfflineStore } from '@/lib/offline/state';
import { useAuthStore } from '@/stores/auth-store';
@@ -20,6 +21,9 @@ import { OutboxList } from './outbox-list';
*
* It can be hidden until what it says changes (going offline again, another
* message waiting), for this session of the window.
+ *
+ * An account that keeps no mail here gets a one-line bar while offline too,
+ * so a message that doesn't open isn't a mystery (mail-p2-7).
*/
const HIDDEN_KEY = 'bulwark.offlineBanner.hidden';
@@ -49,7 +53,23 @@ export function OfflineBanner() {
const [hidden, setHidden] = useState(hiddenFor);
const [banner, setBanner] = useState(null);
const closeFirst = useCloseBesideControls(banner);
+ const connection = useConnectionState();
+ const tP2 = useTranslations('mail_p2_desk');
const offline = browserOffline || serverDown;
+ if (!active) {
+ if (!account || !connection.offline) return null;
+ return (
+
+
+ {tP2('offline_bar')}
+
+ );
+ }
const says = `${browserOffline ? 'offline' : serverDown ? 'down' : 'online'}|${waiting}|${conflicts}|${waitingDrafts}|${refusedDrafts}`;
if (IS_LITE || !active || (!offline && waiting === 0 && conflicts === 0 && refusedDrafts === 0 && waitingDrafts === 0 && !open)) return null;
const hide = () => {
diff --git a/components/offline/offline-host.tsx b/components/offline/offline-host.tsx
index 2ce81bd3c..e33b8e706 100644
--- a/components/offline/offline-host.tsx
+++ b/components/offline/offline-host.tsx
@@ -28,6 +28,9 @@ export function OfflineHost() {
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
+ const tNotice = useTranslations('mail_p2_desk');
+ const tNoticeRef = useRef(tNotice);
+ tNoticeRef.current = tNotice;
const tKey = useTranslations('keyProtection');
const tKeyRef = useRef(tKey);
tKeyRef.current = tKey;
@@ -79,7 +82,8 @@ export function OfflineHost() {
break;
case 'default-on':
// A first-start notice, not a toast: it stays until closed, and covers nothing.
- showFirstStartNotice({ id: 'offline-default-on', text: tStorageRef.current('default_on'), action: openStorage });
+ // One line in the bar; its button says where to change it.
+ showFirstStartNotice({ id: 'offline-default-on', text: tNoticeRef.current('offline_default_on'), action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
diff --git a/components/popout/__tests__/popout-compose-app.test.tsx b/components/popout/__tests__/popout-compose-app.test.tsx
index d92e90439..f1bfe6f70 100644
--- a/components/popout/__tests__/popout-compose-app.test.tsx
+++ b/components/popout/__tests__/popout-compose-app.test.tsx
@@ -7,6 +7,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { useIdentityStore } from '@/stores/identity-store';
import { usePluginStore } from '@/stores/plugin-store';
+import { useSettingsStore } from '@/stores/settings-store';
import { emailHooks } from '@/lib/plugin-hooks';
// The composer's window: when it tells the opening window to let go, and what
@@ -610,11 +611,51 @@ describe('a send held for undo', () => {
expect(closeSelf).not.toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract | undefined;
expect(sent?.notice.pendingUndoSend ?? null).toBeNull();
+ // compose-p2-13: the main window asks for the Scheduled count again then.
+ expect(sent?.notice.releasesAt).toBe(sendAt);
+ });
+
+ // The server's time runs past the delay: "Sending in 6 seconds" for 5 s.
+ it('never counts from more than the delay the user picked', async () => {
+ const before = useSettingsStore.getState().sendDelaySeconds;
+ useSettingsStore.setState({ sendDelaySeconds: 5 } as never);
+ await sendFromWindow(5.8);
+ expect(screen.getByTestId('popout-sending')).toHaveAttribute('data-seconds', '5');
+ useSettingsStore.setState({ sendDelaySeconds: before } as never);
+ });
+
+ // compose-24: the window sat with a greyed Send until the server answered.
+ it('says "Sending…" as soon as Send goes through, before the server answers', async () => {
+ let answer!: () => void;
+ useEmailStore.setState({ sendEmail: vi.fn(() => new Promise((resolve) => { answer = () => resolve({}); })) } as never);
+ sendAt = new Date(Date.now() + 5000).toISOString();
+ window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
+ render( );
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
+ let done: Promise = Promise.resolve();
+ act(() => { done = (composer as unknown as Sending).onSend(DATA); });
+ expect(screen.getByTestId('popout-sending').textContent).toMatch(/sending$/);
+ await act(async () => { answer(); await done; });
+ });
+
+ it('shows the composer again with its text when the send fails', async () => {
+ useEmailStore.setState({ sendEmail: vi.fn(async () => { throw new Error('refused'); }) } as never);
+ sendAt = new Date(Date.now() + 5000).toISOString();
+ window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
+ render( );
+ await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
+ await act(async () => {
+ await expect((composer as unknown as Sending).onSend(DATA)).rejects.toThrow('refused');
+ });
+ expect(screen.queryByTestId('popout-sending')).toBeNull();
+ expect(screen.getByTestId('composer').closest('.hidden')).toBeNull();
});
it('closes once the undo time is over', async () => {
await sendFromWindow(1.2);
expect(closeSelf).not.toHaveBeenCalled();
+ // As the main window's toast: "Sent" once Undo is over (compose-24).
+ await waitFor(() => expect(screen.getByTestId('popout-sending').textContent).toMatch(/(^|\.)sent$/), { timeout: 3000 });
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
});
@@ -629,7 +670,8 @@ describe('a send held for undo', () => {
it('a message scheduled for later still goes to the main window, and the window closes', async () => {
await sendFromWindow(3600);
- expect(screen.queryByTestId('popout-sending')).toBeNull();
+ // "Sending…" may still show as the window goes, but no Undo here.
+ expect(screen.queryByRole('button', { name: 'undo_send' })).toBeNull();
expect(closeSelf).toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
diff --git a/components/popout/__tests__/popout-host.test.tsx b/components/popout/__tests__/popout-host.test.tsx
index afcae4fcf..6f9a6cdd2 100644
--- a/components/popout/__tests__/popout-host.test.tsx
+++ b/components/popout/__tests__/popout-host.test.tsx
@@ -24,7 +24,7 @@ const DRAFT: RescuedDraft = {
const original = { auth: useAuthStore.getState(), email: useEmailStore.getState(), accounts: useAccountStore.getState() };
let heard: PopoutMessage[];
let popout: NonNullable>;
-let client: { createDraft: ReturnType; getAuthHeader: () => string; isOfflineOnly: () => boolean };
+let client: { createDraft: ReturnType; deleteEmail: ReturnType; getAuthHeader: () => string; isOfflineOnly: () => boolean };
let lockRequests: string[];
// Rounds, not one long wait: a message and its answer are two hops, and a busy
@@ -63,7 +63,7 @@ beforeEach(() => {
sessionStorage.clear();
heard = [];
popout = openPopoutChannel((message) => heard.push(message))!;
- client = { createDraft: vi.fn(async () => 'new'), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
+ client = { createDraft: vi.fn(async () => 'new'), deleteEmail: vi.fn(async () => {}), getAuthHeader: () => 'Bearer main', isOfflineOnly: () => false };
useAccountStore.setState({
accounts: [{ id: 'alice', serverUrl: 'https://mail.example.test', username: 'alice', authMode: 'oauth', cookieSlot: 0 } as never],
activeAccountId: 'alice',
@@ -309,6 +309,23 @@ describe('PopoutHost', () => {
expect(useToastStore.getState().toasts).toHaveLength(1);
});
+ // compose-p2-13: the Scheduled count kept a pop-out's undo-held send
+ // until the page was reloaded, since nothing reports the release.
+ it('asks for the Scheduled count again once a send held in a pop-out has gone', async () => {
+ const refreshScheduledMetadata = vi.fn(async () => {});
+ useEmailStore.setState({ refreshScheduledMetadata: refreshScheduledMetadata as never });
+ render( );
+ await settle();
+ popout.post({ type: 'sent', requestId: 'r1', notice: { accountId: 'alice', scheduled: true, releasesAt: new Date(Date.now() + 100).toISOString() } });
+ popout.post({ type: 'sent', requestId: 'r2', notice: { accountId: 'alice', scheduled: true, releasesAt: '2030-01-01T00:00:00Z' } });
+ await settle();
+ expect(refreshScheduledMetadata).not.toHaveBeenCalled();
+ await settle(700);
+ // Once, for the held one; the one scheduled for 2030 stays in the count.
+ expect(refreshScheduledMetadata).toHaveBeenCalledTimes(1);
+ expect(refreshScheduledMetadata).toHaveBeenCalledWith(client);
+ });
+
it('says once that a closed pop-out kept its draft, with Discard', async () => {
render(<> >);
await settle();
@@ -320,6 +337,22 @@ describe('PopoutHost', () => {
expect(toasts[0].action?.label).toBe('email_composer.discard');
});
+ // compose-p2-10: the window's bin.
+ it('says a pop-out discarded its draft, with Undo', async () => {
+ render( );
+ await settle();
+ popout.post({ type: 'draft-kept', requestId: 'k2', notice: { draftId: 'd1', localAccountId: 'alice', discarded: true } });
+ await settle();
+ const toasts = useToastStore.getState().toasts;
+ expect(toasts).toHaveLength(1);
+ expect(toasts[0].title).toBe('compose_ux.draft_discarded');
+ expect(toasts[0].action?.label).toBe('compose_ux.undo');
+ // Leaving the page ends the Undo: the draft is deleted then, not left behind.
+ expect(client.deleteEmail).not.toHaveBeenCalled();
+ await act(async () => { window.dispatchEvent(new Event('pagehide')); });
+ expect(client.deleteEmail).toHaveBeenCalledWith('d1', undefined);
+ });
+
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
render( );
await settle();
@@ -514,7 +547,8 @@ describe('a move a message window made', () => {
});
expect(taken).toBe(true);
const toasts = useToastStore.getState().toasts;
- expect(toasts.map((toast) => toast.title)).toEqual(['notifications.email_archived']);
+ // The main window's own word for it (mail-18).
+ expect(toasts.map((toast) => toast.title)).toEqual(['mail_list_extra.archived']);
expect(toasts[0].action?.label).toBe('notifications.undo');
expect(undoHistory()).toHaveLength(1);
await act(async () => {
diff --git a/components/popout/__tests__/popout-message-app.test.tsx b/components/popout/__tests__/popout-message-app.test.tsx
index 82e6a7d69..9c1e8e48f 100644
--- a/components/popout/__tests__/popout-message-app.test.tsx
+++ b/components/popout/__tests__/popout-message-app.test.tsx
@@ -94,6 +94,13 @@ describe('a message opened from a bulwark:// link', () => {
});
describe('PopoutMessageApp', () => {
+ // pass-2 mail-p2-5: the window showed one message of a conversation.
+ it('reads the message in its conversation, as the reading pane does', async () => {
+ render( );
+ await settle();
+ expect((view as unknown as { conversation?: boolean }).conversation).toBe(true);
+ });
+
it('opens the message of the address, through its login and account', async () => {
render( );
await settle();
diff --git a/components/popout/popout-compose-app.tsx b/components/popout/popout-compose-app.tsx
index 3670f4c83..f1e36f8f9 100644
--- a/components/popout/popout-compose-app.tsx
+++ b/components/popout/popout-compose-app.tsx
@@ -11,6 +11,8 @@ import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { usePluginStore } from "@/stores/plugin-store";
import { toast } from "@/stores/toast-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { secondsUntilSend } from "@/lib/outgoing/send-progress-toast";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
@@ -68,6 +70,17 @@ interface ReopenTarget {
ownerAccountId?: string;
}
+/** "Sending…" or "Sent", where the composer was, with the send icon. */
+function SendStatus({ text, seconds, children }: { text: string; seconds?: number; children?: React.ReactNode }) {
+ return (
+
+
+
{text}
+ {children}
+
+ );
+}
+
/**
* A send held for undo, shown in the window it was sent from: that's where the
* user is looking, and quick compose is used with the main window out of sight.
@@ -80,6 +93,7 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onGone: () => void;
}) {
const tViewer = useTranslations("email_viewer");
+ const tSend = useTranslations("compose_send");
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false);
const sendAt = Date.parse(held.sendAt);
@@ -123,17 +137,18 @@ function SendingWithUndo({ held, onUndone, onGone }: {
onUndone(held.isSmime ? null : restored);
};
- const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
+ // As the main window's toast counts: never past the delay the user picked.
+ const seconds = secondsUntilSend(sendAt, now, useSettingsStore.getState().sendDelaySeconds);
+ // Once Undo is over, "Sent" for the moment before the window goes, as the
+ // main window's toast says it.
return (
-
-
-
{tViewer("sending_in", { seconds })}
+
{!over && (
void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
)}
-
+
);
}
@@ -324,7 +339,11 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
: t("email_composer.new_message"),
);
- const handleSend = useCallback(async (data: SendData) => {
+ // "Sending…" from the moment Send goes through, as the main window says it
+ // (compose-24); the composer waited greyed out for the server before. It
+ // stays mounted behind, and comes back with the text if the send fails.
+ const [sending, setSending] = useState(false);
+ const sendFromWindow = useCallback(async (data: SendData) => {
if (!client) return false;
const sendClient = data.localAccountId
? (useAuthStore.getState().getClientForAccount(data.localAccountId) ?? client)
@@ -381,6 +400,7 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
filingError: !!result.filingError,
rejectedRecipients,
pendingUndoSend: keepHere ? null : withLogin,
+ releasesAt: keepHere?.sendAt,
});
// No main window to say so, and this is the only notice these get (#1123).
if (!taken && (result.filingError || rejectedRecipients)) {
@@ -389,6 +409,16 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
await new Promise((resolve) => setTimeout(resolve, WARNING_READ_MS));
}
}, [client, mode, payload.sourceEmailId, payload.sourceEmailSource, t]);
+ const handleSend = useCallback(async (data: SendData) => {
+ if (!client) return false;
+ if (!data.delayedUntil) setSending(true);
+ try {
+ return await sendFromWindow(data);
+ } catch (error) {
+ setSending(false);
+ throw error;
+ }
+ }, [client, sendFromWindow]);
const handleDiscardDraft = useCallback(async (draftId: string, draft?: { accountId?: string; localAccountId?: string }) => {
if (!client) return;
@@ -491,6 +521,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined });
},
+ onDraftDiscarded: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
+ announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined, discarded: true });
+ },
onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
const active = useAuthStore.getState().activeAccountId;
announceMailChanged(active);
@@ -510,6 +543,9 @@ function PopoutComposer({ payload, handoffId, onReopen }: {
if (held) return ;
return (
+ <>
+ {sending && }
+
+
+ >
);
}
diff --git a/components/popout/popout-host.tsx b/components/popout/popout-host.tsx
index 1308ca0bf..cc12175d3 100644
--- a/components/popout/popout-host.tsx
+++ b/components/popout/popout-host.tsx
@@ -8,7 +8,7 @@ import { useAccountStore } from "@/stores/account-store";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
-import { showDraftSavedToast } from "@/lib/compose/draft-close-toast";
+import { discardWithUndo, showDraftSavedToast } from "@/lib/compose/draft-close-toast";
import { newPopoutId, openPopoutChannel, type PopoutChannel, type PopoutMessage, type PopoutSentNotice, type RescuedDraft, type SharedAccountSession } from "@/lib/popout/channel";
import { takeOverMoved } from "@/lib/popout/moved-undo";
import { keepRescuedDraft } from "@/lib/popout/rescued-drafts";
@@ -24,6 +24,10 @@ const LIST_CHANGE_DEBOUNCE_MS = 400;
const CLAIM_HOLD_MS = 15_000;
/** How long a window remembers which draft id a rescue became, for a reload of the pop-out. */
const RESCUE_MEMORY_MS = 60_000;
+/** The Scheduled count is asked again this long after the server let a held send go. */
+const RELEASE_REFRESH_MS = 3000;
+/** Longer holds are messages scheduled for later, which stay in the count. */
+const UNDO_HOLD_MS = 120_000;
/** The sessions this window can lend. Empty until it is signed in, and in the demo. */
function shareableSessions(): SharedAccountSession[] {
@@ -112,8 +116,26 @@ export function PopoutHost() {
}, REFRESH_THROTTLE_MS);
};
+ // The Scheduled count holds a send while the server holds it. Nothing
+ // reports the release (compose-p2-13): ask again once it is out.
+ const releaseTimers = new Set>();
+ const refreshScheduled = () => {
+ const client = useAuthStore.getState().client;
+ if (client) void useEmailStore.getState().refreshScheduledMetadata(client);
+ };
+ const refreshScheduledAfter = (releasesAt: string) => {
+ const wait = Date.parse(releasesAt) - Date.now();
+ if (!Number.isFinite(wait) || wait > UNDO_HOLD_MS) return;
+ const timer = setTimeout(() => {
+ releaseTimers.delete(timer);
+ refreshScheduled();
+ }, Math.max(0, wait) + RELEASE_REFRESH_MS);
+ releaseTimers.add(timer);
+ };
+
const takeOverSent = (notice: PopoutSentNotice) => {
const translate = tRef.current;
+ if (notice.releasesAt) refreshScheduledAfter(notice.releasesAt);
if (notice.filingError) toast.warning(translate("email_composer.send_filing_warning"));
if (notice.rejectedRecipients) {
toast.warning(translate("email_composer.send_some_recipients_rejected"), { message: notice.rejectedRecipients, duration: 15000 });
@@ -171,7 +193,7 @@ export function PopoutHost() {
// Undo for what a message window archived, deleted or moved: it has closed.
claimOnce(`bulwark-popout-moved:${message.requestId}`, () => {
channel?.post({ type: "moved-ack", requestId: message.requestId });
- takeOverMoved(message.notice, (key, values) => tRef.current(`notifications.${key}`, values));
+ takeOverMoved(message.notice, (key, values) => tRef.current(key, values));
refreshList();
});
break;
@@ -180,10 +202,10 @@ export function PopoutHost() {
break;
case "draft-kept": {
// Said here, as the pop-out closes with its Escape or X.
- const { draftId, accountId, localAccountId } = message.notice;
+ const { draftId, accountId, localAccountId, discarded } = message.notice;
claimOnce(`bulwark-popout-kept:${message.requestId}`, () => {
const tr = tRef.current;
- showDraftSavedToast(() => {
+ const discard = () => {
const auth = useAuthStore.getState();
const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client;
if (!login) {
@@ -191,12 +213,16 @@ export function PopoutHost() {
return;
}
void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error));
- }, {
+ };
+ const labels = {
saved: tr("email_composer.draft_saved"),
discard: tr("email_composer.discard"),
discarded: tr("compose_ux.draft_discarded"),
undo: tr("compose_ux.undo"),
- });
+ };
+ // The window's bin: Undo leaves the draft in Drafts.
+ if (discarded) discardWithUndo(discard, labels);
+ else showDraftSavedToast(discard, labels);
});
break;
}
@@ -272,6 +298,7 @@ export function PopoutHost() {
stopAccounts();
if (refreshTimer) clearTimeout(refreshTimer);
if (listTimer) clearTimeout(listTimer);
+ for (const timer of releaseTimers) clearTimeout(timer);
channel?.close();
};
}, [available]);
diff --git a/components/popout/popout-message-app.tsx b/components/popout/popout-message-app.tsx
index 80d81fb16..01cffd00d 100644
--- a/components/popout/popout-message-app.tsx
+++ b/components/popout/popout-message-app.tsx
@@ -198,6 +198,7 @@ function PopoutMessage({ emailId, accountId, ownerAccountId, fromLink }: { email
liveState={liveState}
onMailChanged={handleMailChanged}
noAutomaticActions={fromLink}
+ conversation
className="min-h-0 w-full flex-1"
/>
>
diff --git a/components/pro/__tests__/pro-email-window-conversation.test.tsx b/components/pro/__tests__/pro-email-window-conversation.test.tsx
new file mode 100644
index 000000000..0a0805061
--- /dev/null
+++ b/components/pro/__tests__/pro-email-window-conversation.test.tsx
@@ -0,0 +1,161 @@
+import { act, fireEvent, render, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import React from 'react';
+
+/**
+ * A message window (desktop app, installed app) showed only the message it was
+ * opened with, and r, e, # and ? did nothing there: its shortcuts waited for a
+ * Pro tab to be in front, and a window has no tabs (pass-2 mail-p2-5).
+ */
+
+const viewerProps = vi.hoisted(() => ({ current: null as Record | null }));
+
+vi.mock('@/components/email/email-viewer', () => ({
+ EmailViewer: (props: Record) => {
+ viewerProps.current = props;
+ return null;
+ },
+}));
+
+vi.mock('@/components/error', () => ({
+ ErrorBoundary: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
+ EmailViewerErrorFallback: () => null,
+}));
+
+vi.mock('next-intl', () => ({ useTranslations: () => (key: string) => key }));
+
+vi.mock('@/lib/popout/window-role', async (importOriginal) => ({
+ ...await importOriginal(),
+ isPopoutWindow: () => true,
+}));
+vi.mock('@/components/email/move-to-dialog', () => ({ MoveToDialog: () => null }));
+vi.mock('@/stores/toast-store', () => ({ toast: { error: () => {}, success: () => {} } }));
+const help = vi.hoisted(() => ({ open: false }));
+vi.mock('@/components/keyboard-shortcuts-modal', () => ({
+ KeyboardShortcutsModal: ({ isOpen }: { isOpen: boolean }) => {
+ help.open = isOpen;
+ return null;
+ },
+}));
+
+function selectorStore(state: T) {
+ const hook = (sel?: (s: T) => unknown) => (typeof sel === 'function' ? sel(state) : state);
+ hook.getState = () => state;
+ return hook;
+}
+
+vi.mock('@/stores/auth-store', () => ({
+ useAuthStore: selectorStore({ client: null, activeAccountId: 'dso', getClientForAccount: () => undefined }),
+}));
+vi.mock('@/stores/email-store', () => ({
+ resolveEmailBlobContext: (_email: unknown, client: unknown) => ({ client }),
+ useEmailStore: selectorStore({
+ fetchEmailContent: async () => null,
+ deleteEmail: async () => {},
+ markAsRead: async () => {},
+ toggleStar: async () => {},
+ moveToMailbox: async () => {},
+ setEmailKeywordsLocal: () => {},
+ mailboxes: [],
+ emails: [],
+ selectedEmail: null,
+ }),
+}));
+vi.mock('@/stores/identity-store', () => ({
+ useIdentityStore: selectorStore({ identities: [{ id: 'b', email: 'dso@example.org', name: 'Dso' }] }),
+}));
+const settings = vi.hoisted(() => ({ state: { singleKeyShortcuts: true, disableThreading: false } }));
+vi.mock('@/stores/settings-store', () => ({ useSettingsStore: selectorStore(settings.state) }));
+vi.mock('@/stores/pro-tab-store', () => ({
+ useProTabStore: selectorStore({ openComposeTab: () => {}, closeTab: () => {}, updateTabTitle: () => {} }),
+}));
+
+import { ProEmailView } from '../pro-email-tab-body';
+
+const OLDER = {
+ id: 'm1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
+ from: [{ email: 'aylin@example.org', name: 'Aylin' }], subject: 'Question about hiring plan',
+ receivedAt: '2026-10-20T08:00:00Z', preview: 'First',
+};
+const NEWER = {
+ ...OLDER, id: 'm2', from: [{ email: 'robin@example.org', name: 'Robin' }], subject: 'Re: Question about hiring plan',
+ receivedAt: '2026-10-21T08:00:00Z', preview: 'Second', keywords: {},
+};
+const BODY = {
+ bodyValues: { '1': { value: 'Hi' } },
+ textBody: [{ partId: '1', type: 'text/plain' }],
+ htmlBody: [{ partId: '1', type: 'text/plain' }],
+ attachments: [],
+};
+
+function makeClient() {
+ return {
+ getEmail: vi.fn(async (id: string) => (id === 'm1' ? { ...OLDER, ...BODY } : { ...NEWER, ...BODY })),
+ getThread: vi.fn(async () => ({ id: 't1', emailIds: ['m2', 'm1'] })),
+ // Not in date order: the window sorts.
+ getSomeEmails: vi.fn(async () => [NEWER, OLDER]),
+ markAsRead: vi.fn(async () => {}),
+ getUsername: () => 'dso@example.org',
+ getAccountId: () => 'a',
+ };
+}
+
+describe('a message window', () => {
+ beforeEach(() => {
+ viewerProps.current = null;
+ help.open = false;
+ settings.state.disableThreading = false;
+ });
+
+ async function open() {
+ const client = makeClient();
+ const onOpenCompose = vi.fn(async (_data: Record) => true);
+ render( );
+ await waitFor(() => expect(viewerProps.current?.email).toBeTruthy());
+ return { client, onOpenCompose };
+ }
+
+ it('shows the whole conversation, oldest first', async () => {
+ const { client } = await open();
+ await waitFor(() => expect((viewerProps.current?.mvConversation as Array<{ id: string }> | undefined)?.map((e) => e.id)).toEqual(['m1', 'm2']));
+ expect(viewerProps.current?.conversationPane).toBe(true);
+ expect(client.getThread).toHaveBeenCalledWith('t1', 'a');
+ });
+
+ it('keeps one message with "Show emails as individual messages"', async () => {
+ settings.state.disableThreading = true;
+ const { client } = await open();
+ await act(async () => { await new Promise((r) => setTimeout(r, 5)); });
+ expect(viewerProps.current?.mvConversation).toBeUndefined();
+ expect(client.getThread).not.toHaveBeenCalled();
+ });
+
+ it('replies to the message of the conversation whose button was pressed', async () => {
+ const { onOpenCompose } = await open();
+ await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
+ await act(async () => {
+ await (viewerProps.current!.onReplyConversationEmail as (e: unknown) => Promise)(NEWER);
+ });
+ await waitFor(() => expect(onOpenCompose).toHaveBeenCalled());
+ expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm2' });
+ });
+
+ it('marks an unfolded unread message read on the server', async () => {
+ const { client } = await open();
+ await waitFor(() => expect(viewerProps.current?.mvConversation).toBeTruthy());
+ await act(async () => {
+ (viewerProps.current!.onMarkConversationEmailRead as (e: unknown) => void)(NEWER);
+ await new Promise((r) => setTimeout(r, 5));
+ });
+ expect(client.markAsRead).toHaveBeenCalledWith('m2', true, 'a');
+ });
+
+ it('answers r and ? with no Pro tab around it', async () => {
+ const { onOpenCompose } = await open();
+ fireEvent.keyDown(document.body, { key: 'r', code: 'KeyR' });
+ await waitFor(() => expect(onOpenCompose).toHaveBeenCalledTimes(1));
+ expect(onOpenCompose.mock.calls[0][0]).toMatchObject({ mode: 'reply', sourceEmailId: 'm1' });
+ fireEvent.keyDown(document.body, { key: '?', code: 'Slash', shiftKey: true });
+ await waitFor(() => expect(help.open).toBe(true));
+ });
+});
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 5ddae4ffc..9ff6ada84 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -41,10 +41,13 @@ import { isFilePreviewable } from '@/lib/file-preview';
import { useDesktopCommands } from '@/lib/desktop/commands';
import { isPopoutWindow } from '@/lib/popout/window-role';
import { offerMoveUndo } from '@/lib/popout/moved-undo';
+import { useViewConversation } from './use-view-conversation';
import dynamic from 'next/dynamic';
// Only needed when the device policy turns downloads off.
const FilePreviewModal = dynamic(() => import('@/components/files/file-preview-modal').then((m) => m.FilePreviewModal), { ssr: false });
+// Only a message window opens the shortcuts help from here.
+const KeyboardShortcutsModal = dynamic(() => import('@/components/keyboard-shortcuts-modal').then((m) => m.KeyboardShortcutsModal), { ssr: false });
interface ProEmailTabBodyProps {
tabId: string;
@@ -86,6 +89,11 @@ interface ProEmailViewProps {
onMailChanged?: () => void;
/** See EmailViewer. */
noAutomaticActions?: boolean;
+ /**
+ * Show the message inside its conversation, as the mail page's reading
+ * pane does (a message window). "Show emails as individual messages" keeps one.
+ */
+ conversation?: boolean;
/**
* The message left this view: deleted, archived, moved, or reopened in the
* composer. The host decides what that means (close the tab / clear the
@@ -152,7 +160,7 @@ function buildReplyContext(email: Email, source: EmailWriteSource): ProReplyCont
* Hosted by `ProEmailTabBody` (a dedicated email tab) and by the folder tab's
* reading pane.
*/
-export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, onClose, className }: ProEmailViewProps) {
+export function ProEmailView({ emailId, client: clientOverride, clientAccountId, accountId, onLoaded, onOpenCompose, liveState, onMailChanged, noAutomaticActions, conversation, onClose, className }: ProEmailViewProps) {
const t = useTranslations();
const tNotifications = useTranslations('notifications');
const tRemove = useTranslations('email_list.confirm_remove');
@@ -295,17 +303,21 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
return mb?.role;
}, [email, mailboxes]);
- const handleReply = useCallback((draftText?: string) => {
- if (!email) return;
+ const replyToMessage = useCallback((target: Email, draftText?: string) => {
openComposeTab({
mode: 'reply',
- replyTo: buildReplyContext(email, writeSource),
- sourceEmailId: email.id,
+ replyTo: buildReplyContext(target, writeSource),
+ sourceEmailId: target.id,
sourceEmailSource: writeSource,
initialDraftText: draftText,
- title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
- }, { source: email, body: draftText ?? '' });
- }, [email, openComposeTab, t, writeSource]);
+ title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
+ }, { source: target, body: draftText ?? '' });
+ }, [openComposeTab, t, writeSource]);
+
+ const handleReply = useCallback((draftText?: string) => {
+ if (!email) return;
+ replyToMessage(email, draftText);
+ }, [email, replyToMessage]);
// A notification's Reply, once this tab has the message (mail-app.tsx).
const pendingTabReply = useNotificationTabReply((s) => s.pending);
@@ -315,27 +327,40 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
if (text !== null) handleReply(text);
}, [email, pendingTabReply, handleReply]);
- const handleReplyAll = useCallback(() => {
- if (!email) return;
+ const replyAllToMessage = useCallback((target: Email) => {
openComposeTab({
mode: 'replyAll',
- replyTo: buildReplyContext(email, writeSource),
- sourceEmailId: email.id,
+ replyTo: buildReplyContext(target, writeSource),
+ sourceEmailId: target.id,
sourceEmailSource: writeSource,
- title: buildReplySubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
- }, { source: email });
- }, [email, openComposeTab, t, writeSource]);
+ title: buildReplySubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.reply')),
+ }, { source: target });
+ }, [openComposeTab, t, writeSource]);
+
+ const handleReplyAll = useCallback(() => {
+ if (!email) return;
+ replyAllToMessage(email);
+ }, [email, replyAllToMessage]);
+
+ // A message window reads the message in its conversation (mail-p2-5).
+ const disableThreading = useSettingsStore((s) => s.disableThreading);
+ const conversationView = !!conversation && !disableThreading;
+ const viewConversation = useViewConversation({ enabled: conversationView, email, source: blobSource, onChanged: onMailChanged });
+
+ const forwardMessage = useCallback((target: Email) => {
+ openComposeTab({
+ mode: 'forward',
+ replyTo: buildReplyContext(target, writeSource),
+ sourceEmailId: target.id,
+ sourceEmailSource: writeSource,
+ title: buildForwardSubject(target.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
+ }, { source: target });
+ }, [openComposeTab, t, writeSource]);
const handleForward = useCallback(() => {
if (!email) return;
- openComposeTab({
- mode: 'forward',
- replyTo: buildReplyContext(email, writeSource),
- sourceEmailId: email.id,
- sourceEmailSource: writeSource,
- title: buildForwardSubject(email.subject || t('email_composer.new_message'), t('email_composer.prefix.forward')),
- }, { source: email });
- }, [email, openComposeTab, t, writeSource]);
+ forwardMessage(email);
+ }, [email, forwardMessage]);
// Mirrors handleForward, but attaches the original as a message/rfc822
// file instead of quoting it inline - see lib/forward-as-attachment.ts.
@@ -392,14 +417,14 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
try {
// false: not deleted (a plugin stopped it), so the tab stays open.
if (await deleteEmail(client, email.id)) {
- await offerMoveUndo('delete', client, restore, tNotifications, isPopoutWindow());
+ await offerMoveUndo('delete', client, restore, t, isPopoutWindow());
onClose();
}
} catch (err) {
console.error('Delete failed:', err);
toast.error(tNotifications('error_deleting'));
}
- }, [client, email, mailboxes, deleteEmail, onClose, tNotifications, confirmDialog, tRemove]);
+ }, [client, email, mailboxes, deleteEmail, onClose, t, tNotifications, confirmDialog, tRemove]);
const handleArchive = useCallback(async () => {
if (!client || !email) return;
@@ -409,12 +434,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const restore = restoreBatchesFor([email], client);
try {
if (!(await moveToMailbox(client, email.id, archiveMb.id, emailKeyFor(email)))) return;
- await offerMoveUndo('archive', client, restore, tNotifications, isPopoutWindow());
+ await offerMoveUndo('archive', client, restore, t, isPopoutWindow());
onClose();
} catch (err) {
console.error('Archive failed:', err);
}
- }, [client, email, mailboxes, moveToMailbox, onClose, tNotifications, confirmDialog, tRemove]);
+ }, [client, email, mailboxes, moveToMailbox, onClose, t, confirmDialog, tRemove]);
const handleToggleStar = useCallback(async () => {
if (!client || !email) return;
@@ -510,17 +535,18 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
const restore = restoreBatchesFor([email], client);
try {
if (await moveToMailbox(client, email.id, mailboxId, emailKeyFor(email))) {
- await offerMoveUndo('move', client, restore, tNotifications, isPopoutWindow());
+ await offerMoveUndo('move', client, restore, t, isPopoutWindow());
onClose();
}
} catch (err) {
console.error('Move failed:', err);
}
- }, [client, email, moveToMailbox, onClose, tNotifications]);
+ }, [client, email, moveToMailbox, onClose, t]);
// This view's message answers the same keys as a message in the mail tab,
// while its tab is the one in front. c comes from the mail page, as on any tab.
const isFocusedTab = useIsFocusedProTab();
+ const [showShortcuts, setShowShortcuts] = useState(false);
const singleKeyShortcuts = useSettingsStore((s) => s.singleKeyShortcuts);
const shortcutEmails = useMemo(() => (email ? [email] : []), [email]);
const shortcutHandlers = useMemo(() => ({
@@ -532,9 +558,13 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onDelete: () => void handleDelete(),
onMarkAsUnread: email ? () => void handleMarkAsRead(email.id, false) : undefined,
onMarkAsRead: email ? () => void handleMarkAsRead(email.id, true) : undefined,
- }), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead]);
+ // A message window has no mail page behind it to answer ?.
+ onShowHelp: inOwnWindow ? () => setShowShortcuts(true) : undefined,
+ }), [email, handleReply, handleReplyAll, handleForward, handleToggleStar, archiveActionEnabled, handleArchive, handleDelete, handleMarkAsRead, inOwnWindow]);
+ // A Pro tab answers while it is the one in front; a message window is
+ // always in front of itself (it has no tabs).
useKeyboardShortcuts({
- enabled: isFocusedTab && !!email,
+ enabled: (isFocusedTab || inOwnWindow) && !!email && !showShortcuts,
singleKeyShortcuts,
emails: shortcutEmails,
selectedEmailId: email?.id,
@@ -633,6 +663,24 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
void viewConversation.loadBody(e) : undefined}
+ onMarkConversationEmailRead={conversationView ? (e) => void viewConversation.markRead(e) : undefined}
+ onReplyConversationEmail={conversationView ? async (e) => {
+ // The reply quotes the message, so it needs the body.
+ const full = e.bodyValues ? e : await viewConversation.loadBody(e);
+ replyToMessage(full ?? e);
+ } : undefined}
+ onReplyAllConversationEmail={conversationView ? async (e) => {
+ const full = e.bodyValues ? e : await viewConversation.loadBody(e);
+ replyAllToMessage(full ?? e);
+ } : undefined}
+ onForwardConversationEmail={conversationView ? async (e) => {
+ const full = e.bodyValues ? e : await viewConversation.loadBody(e);
+ forwardMessage(full ?? e);
+ } : undefined}
blobSource={blobSource}
isLoading={isLoading}
onReply={handleReply}
@@ -659,6 +707,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
/>
+ {inOwnWindow && setShowShortcuts(false)} />}
{inOwnWindow && (
Promise;
+ /** An unfolded message was read. */
+ markRead: (target: Email) => Promise;
+}
+
+/**
+ * The conversation around a message read outside the mail page (a message
+ * window), fetched the way the mail page's reading pane fetches it: the
+ * thread's ids, then their list properties, drafts left out except the open
+ * one, oldest first. A conversation of one message is none.
+ */
+export function useViewConversation({ enabled, email, source, onChanged }: {
+ enabled: boolean;
+ email: Email | null;
+ source: EmailBlobContext;
+ onChanged?: () => void;
+}): ViewConversation {
+ const threadId = enabled ? email?.threadId ?? null : null;
+ const openId = email?.id ?? null;
+ const [result, setResult] = useState<{ threadId: string; emails: Email[] } | null>(null);
+ const [pending, setPending] = useState(false);
+ const seqRef = useRef(0);
+ const onChangedRef = useRef(onChanged);
+ onChangedRef.current = onChanged;
+
+ useEffect(() => {
+ const seq = ++seqRef.current;
+ const client = source.client;
+ if (!threadId || !client) {
+ setResult(null);
+ setPending(false);
+ return;
+ }
+ setPending(true);
+ void (async () => {
+ const thread = await client.getThread(threadId, source.accountId);
+ const ids = thread?.emailIds ?? [];
+ if (ids.length < 2) return null;
+ const emails = await client.getSomeEmails(ids, source.accountId);
+ return emails
+ .filter((e) => e.id === openId || !e.keywords?.$draft)
+ .sort((a, b) => a.receivedAt.localeCompare(b.receivedAt));
+ })().catch(() => null).then((emails) => {
+ if (seq !== seqRef.current) return;
+ setResult(emails && emails.length > 1 ? { threadId, emails } : null);
+ setPending(false);
+ });
+ }, [threadId, openId, source.client, source.accountId]);
+
+ const patch = useCallback((id: string, change: (e: Email) => Email) => {
+ setResult((prev) => (prev ? { ...prev, emails: prev.emails.map((e) => (e.id === id ? change(e) : e)) } : prev));
+ }, []);
+
+ const loadBody = useCallback(async (target: Email) => {
+ const client = source.client;
+ if (!client) return null;
+ const seq = seqRef.current;
+ const full = await client.getEmail(target.id, source.accountId).catch(() => null);
+ if (!full || seq !== seqRef.current) return null;
+ patch(target.id, (e) => ({ ...full, keywords: e.keywords }));
+ return full;
+ }, [source.client, source.accountId, patch]);
+
+ const markRead = useCallback(async (target: Email) => {
+ const client = source.client;
+ if (!client || target.keywords?.$seen) return;
+ try {
+ if (emailHooks.onBeforeEmailReadStateChange.size > 0
+ && !(await emailHooks.onBeforeEmailReadStateChange.intercept({ emailIds: [target.id], read: true }))) {
+ return;
+ }
+ await client.markAsRead(target.id, true, source.accountId);
+ void emailHooks.onEmailReadStateChange.emit({ emailIds: [target.id], read: true });
+ patch(target.id, (e) => ({ ...e, keywords: { ...e.keywords, $seen: true } }));
+ onChangedRef.current?.();
+ } catch (error) {
+ console.error("Failed to mark conversation message read:", error);
+ }
+ }, [source.client, source.accountId, patch]);
+
+ return {
+ emails: result && result.threadId === threadId ? result.emails : undefined,
+ pending: pending && !!threadId,
+ loadBody,
+ markRead,
+ };
+}
diff --git a/components/ui/__tests__/first-start-notice.test.tsx b/components/ui/__tests__/first-start-notice.test.tsx
index 3f891cf14..8e03d1925 100644
--- a/components/ui/__tests__/first-start-notice.test.tsx
+++ b/components/ui/__tests__/first-start-notice.test.tsx
@@ -74,3 +74,35 @@ describe('first-start notices', () => {
expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
});
});
+
+// pass-2 mail-p2-19: offline, the desktop app showed the offline bar and, under
+// it, this notice four lines tall saying much the same.
+describe('first-start notices and the offline bar', () => {
+ afterEach(() => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => true });
+ });
+
+ it('wait while offline and come back online', () => {
+ render( );
+ act(() => showFirstStartNotice({ id: 'kept', text: 'Mail is kept on this device.' }));
+ expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
+ act(() => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => false });
+ window.dispatchEvent(new Event('offline'));
+ });
+ expect(screen.queryByText('Mail is kept on this device.')).toBeNull();
+ act(() => {
+ Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => true });
+ window.dispatchEvent(new Event('online'));
+ });
+ expect(screen.getByText('Mail is kept on this device.')).toBeTruthy();
+ });
+
+ it('keep to one line, the whole text on hover', () => {
+ render( );
+ act(() => showFirstStartNotice({ id: 'long', text: 'A long text that would wrap to four lines in a narrow list.' }));
+ const text = screen.getByText('A long text that would wrap to four lines in a narrow list.');
+ expect(text.className).toContain('truncate');
+ expect(text.getAttribute('title')).toBe('A long text that would wrap to four lines in a narrow list.');
+ });
+});
diff --git a/components/ui/__tests__/toast-layout.test.tsx b/components/ui/__tests__/toast-layout.test.tsx
index 335d03634..7b1e42f30 100644
--- a/components/ui/__tests__/toast-layout.test.tsx
+++ b/components/ui/__tests__/toast-layout.test.tsx
@@ -43,6 +43,14 @@ describe('toast layout', () => {
expect(container).toHaveClass('[html[data-composer-open]_&]:bottom-20');
});
+ // compose-p2-17: "Draft saved" covered the quick reply box.
+ it('sits over the quick reply box, by its height', () => {
+ render( );
+ const container = toastContainer();
+ expect(container).toHaveClass('[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ expect(container).toHaveClass('max-sm:[html:has([data-quick-reply-box])_&]:bottom-[calc(var(--quick-reply-height,5rem)+0.75rem)]');
+ });
+
it('sits above a registered bottom bar on the desktop too, such as a form footer', () => {
render( );
const container = toastContainer();
diff --git a/components/ui/__tests__/welcome-banner.test.tsx b/components/ui/__tests__/welcome-banner.test.tsx
index 3af02db83..624276352 100644
--- a/components/ui/__tests__/welcome-banner.test.tsx
+++ b/components/ui/__tests__/welcome-banner.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen } from '@testing-library/react';
+import { fireEvent, render, renderHook, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
@@ -12,7 +12,7 @@ vi.mock('@/hooks/use-media-query', () => ({
vi.mock('@/components/tour/tour-provider', () => ({ useTour: () => ({ startTour: vi.fn() }) }));
vi.mock('@/i18n/navigation', () => ({ useRouter: () => ({ push: vi.fn() }) }));
-const { WelcomeBanner } = await import('../welcome-banner');
+const { WelcomeBanner, useWelcomeBannerDue } = await import('../welcome-banner');
const { useSettingsStore } = await import('@/stores/settings-store');
describe('WelcomeBanner tips', () => {
@@ -63,3 +63,38 @@ describe('WelcomeBanner and Escape', () => {
expect(useSettingsStore.getState().onboardingCompleted).toBe(false);
});
});
+
+// pass2 mail-p2-17: settings are per account, so a second account started
+// with onboardingCompleted false and showed the tips again on the same device.
+describe('WelcomeBanner once per device', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ device.isMobile = false;
+ });
+
+ it('stays away for an account added on a device that has seen the tips', () => {
+ localStorage.setItem('onboarding_completed', 'true');
+ useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
+ const { container } = render( );
+ expect(container.querySelector('[data-welcome]')).toBeNull();
+ });
+
+ it('is not due for that account either, so first-start notices are not held back', () => {
+ localStorage.setItem('onboarding_completed', 'true');
+ useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
+ const { result } = renderHook(() => useWelcomeBannerDue());
+ expect(result.current).toBe(false);
+ });
+
+ it('shows on a device that has not seen them', () => {
+ useSettingsStore.setState({ onboardingCompleted: false, showOnboardingOnNewDevices: false });
+ render( );
+ expect(screen.getByRole('complementary')).toBeInTheDocument();
+ });
+
+ it('points at the bar on the left, not a sidebar menu', () => {
+ useSettingsStore.setState({ onboardingCompleted: false });
+ render( );
+ expect(screen.getByText('tip_sidebar')).toBeInTheDocument();
+ });
+});
diff --git a/components/ui/first-start-notice.tsx b/components/ui/first-start-notice.tsx
index a9b9d0fbd..1db257de4 100644
--- a/components/ui/first-start-notice.tsx
+++ b/components/ui/first-start-notice.tsx
@@ -5,6 +5,7 @@ import { Info, X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { IconButton } from "@/components/ui/icon-button";
import { closeFirstStartNotice, useCurrentFirstStartNotice } from "@/lib/first-start-notices";
+import { useConnectionState } from "@/hooks/use-connection-state";
import { useWelcomeBannerDue } from "@/components/ui/welcome-banner";
/**
@@ -12,21 +13,25 @@ import { useWelcomeBannerDue } from "@/components/ui/welcome-banner";
* lib/first-start-notices). In the page's flow, it covers nothing. It waits while the
* welcome tips are due, wherever they show (above the list or in the reading pane),
* so the first start has one greeting at a time. `hidden` while something else greets
- * the user there.
+ * the user there. Offline, the offline bar above says what matters then, so the
+ * notice waits too.
+ *
+ * One line: a longer text is cut, and shows whole on hover.
*/
export function FirstStartNoticeBar({ hidden = false }: { hidden?: boolean }) {
const tCommon = useTranslations("common");
const notice = useCurrentFirstStartNotice();
+ const { offline } = useConnectionState();
const welcomeDue = useWelcomeBannerDue();
- if (!notice || hidden || welcomeDue) return null;
+ if (!notice || hidden || offline || welcomeDue) return null;
return (
-
-
{notice.text}
+
+
{notice.text}
{notice.action && (
s.showOnboardingOnNewDevices);
const [due, setDue] = useState(false);
useEffect(() => {
- if (!onboardingCompleted) {
- setDue(true);
- return;
- }
- let seenHere = true;
- try {
- seenHere = localStorage.getItem(ONBOARDING_KEY) === "true";
- } catch { /* localStorage unavailable */ }
- setDue(showOnboardingOnNewDevices && !seenHere);
+ setDue(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice()));
}, [onboardingCompleted, showOnboardingOnNewDevices]);
return due;
}
+function seenOnThisDevice(): boolean {
+ try {
+ return localStorage.getItem(ONBOARDING_KEY) === "true";
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * The tips show once per device. Settings are kept per account, so an
+ * account added later starts with `onboardingCompleted` false; the device's
+ * own flag keeps the tips from coming back for it.
+ */
+export function welcomeTipsDue(onboardingCompleted: boolean, showOnNewDevices: boolean, seenHere: boolean): boolean {
+ if (seenHere) return false;
+ return !onboardingCompleted || showOnNewDevices;
+}
+
/**
* First-run tips. `list` sits above the message list (phones, and layouts
* without a reading pane beside the list); `pane` is a card in the empty
@@ -64,19 +74,7 @@ export function WelcomeBanner({ variant = "list" }: { variant?: "list" | "pane"
}, [onboardingCompleted, updateSetting]);
useEffect(() => {
- if (!onboardingCompleted) {
- setVisible(true);
- return;
- }
- if (showOnboardingOnNewDevices) {
- try {
- if (localStorage.getItem(ONBOARDING_KEY) !== "true") {
- setVisible(true);
- return;
- }
- } catch { /* localStorage unavailable */ }
- }
- setVisible(false);
+ setVisible(welcomeTipsDue(onboardingCompleted, showOnboardingOnNewDevices, seenOnThisDevice()));
}, [onboardingCompleted, showOnboardingOnNewDevices]);
const dismiss = useCallback(() => {
diff --git a/hooks/use-all-inboxes-unread.ts b/hooks/use-all-inboxes-unread.ts
new file mode 100644
index 000000000..bf3e04553
--- /dev/null
+++ b/hooks/use-all-inboxes-unread.ts
@@ -0,0 +1,38 @@
+"use client";
+
+import { useShallow } from "zustand/react/shallow";
+import { useEmailStore } from "@/stores/email-store";
+import { useSettingsStore } from "@/stores/settings-store";
+import { useAccountStore } from "@/stores/account-store";
+import { useAuthStore } from "@/stores/auth-store";
+import { totalInboxUnread } from "@/lib/account-inbox-unread";
+
+/**
+ * Unread mail in the inboxes of the signed-in accounts that aren't on
+ * screen, while "count every account" is on in Notifications (the default).
+ * The other accounts' folder lists are what the account switcher reads
+ * (useProMultiAccountMailboxes); one not loaded yet adds nothing.
+ */
+export function useOtherAccountsUnread(): number {
+ const enabled = useSettingsStore((s) => s.unreadBadgeAllAccounts);
+ const activeAccountId = useAuthStore((s) => s.activeAccountId);
+ const otherIds = useAccountStore(
+ useShallow((s) => (enabled ? s.accounts.filter((a) => a.isConnected && a.id !== activeAccountId).map((a) => a.id) : [])),
+ );
+ const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
+ return totalInboxUnread(otherIds, accountMailboxes);
+}
+
+/**
+ * The number the app shows for new mail: the open account's own inbox plus,
+ * by the same setting, every other account's. The favicon, the app badge,
+ * the rail's Mail entry and the tab title all show this one number
+ * (usability test 2026-11-04, mail-6).
+ */
+export function useAllInboxesUnread(): number {
+ // `role === 'inbox'` alone is not enough: shared and group inboxes ship in
+ // the same `mailboxes` array, so on a delegated setup the first match can
+ // be somebody else's inbox.
+ const own = useEmailStore((s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0);
+ return own + useOtherAccountsUnread();
+}
diff --git a/hooks/use-list-row-shape.ts b/hooks/use-list-row-shape.ts
index 719b947d6..c0e9d7874 100644
--- a/hooks/use-list-row-shape.ts
+++ b/hooks/use-list-row-shape.ts
@@ -36,3 +36,19 @@ export const LIST_COLUMN_CLASS = {
date: 'w-28 shrink-0 text-end',
size: 'w-16 shrink-0 text-end',
} as const;
+
+/**
+ * Whether a stacked row puts its preview after the subject, on the subject's
+ * line, instead of on a line of its own (mail-15). The regular density does
+ * on a computer, which brings a row from about 96 px to about 68 px, so a
+ * 1366×768 screen lists about ten. Comfortable keeps two preview lines, and
+ * phones keep the preview under the subject, where the line is too short.
+ */
+export function showsPreviewOnSubjectLine(options: {
+ showPreview: boolean;
+ density: string;
+ isMobile: boolean;
+ mountainView: boolean;
+}): boolean {
+ return options.showPreview && options.density === 'regular' && !options.isMobile && !options.mountainView;
+}
diff --git a/lib/__tests__/desktop-shortcut-keys.test.ts b/lib/__tests__/desktop-shortcut-keys.test.ts
index 672a63395..717e86502 100644
--- a/lib/__tests__/desktop-shortcut-keys.test.ts
+++ b/lib/__tests__/desktop-shortcut-keys.test.ts
@@ -13,7 +13,7 @@ const row = (description: string): ShortcutEntry => {
describe('the shortcut help in the desktop app', () => {
it('lists the Outlook keys its menus run beside the single keys', () => {
expect(shortcutKeyFor(row('shortcuts.actions.reply'), 'windows')).toBe('Mod + R / r');
- expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / R / a');
+ expect(shortcutKeyFor(row('shortcuts.actions.reply_all'), 'linux')).toBe('Mod + Shift + R / Shift + R');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'mac')).toBe('Mod + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.actions.forward'), 'windows')).toBe('Ctrl + F / Ctrl + Shift + F / f');
expect(shortcutKeyFor(row('shortcuts.global.search'), 'windows')).toBe('Ctrl + E / /');
diff --git a/lib/__tests__/empty-folder-text.test.ts b/lib/__tests__/empty-folder-text.test.ts
new file mode 100644
index 000000000..4de4a6db3
--- /dev/null
+++ b/lib/__tests__/empty-folder-text.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest';
+import { emptyFolderLabelKey, emptyFolderPrompt } from '../empty-folder-text';
+
+// p2-14: the confirm said "All emails in this folder will be permanently
+// deleted" with no number; it now says how many, and names Trash or Spam.
+
+describe('emptyFolderPrompt', () => {
+ it('names Trash and counts its messages', () => {
+ expect(emptyFolderPrompt({ role: 'trash', totalEmails: 146 }, false)).toEqual({
+ title: 'mail_p2_folders.empty_trash',
+ message: 'mail_p2_folders.empty_trash_confirm',
+ values: { count: 146 },
+ confirm: 'mail_p2_folders.empty_trash',
+ });
+ });
+
+ it('names Spam and counts its messages', () => {
+ const prompt = emptyFolderPrompt({ role: 'junk', totalEmails: 3 }, false);
+ expect(prompt.title).toBe('mail_p2_folders.empty_spam');
+ expect(prompt.message).toBe('mail_p2_folders.empty_spam_confirm');
+ expect(prompt.values).toEqual({ count: 3 });
+ });
+
+ it('keeps the old wording for other folders', () => {
+ expect(emptyFolderPrompt({ role: null, totalEmails: 3 }, true).message).toBe('email_list.empty_folder.confirm_message_trash');
+ expect(emptyFolderLabelKey('inbox')).toBeNull();
+ });
+});
diff --git a/lib/__tests__/format-date-weekday.test.ts b/lib/__tests__/format-date-weekday.test.ts
new file mode 100644
index 000000000..d0f3db4ec
--- /dev/null
+++ b/lib/__tests__/format-date-weekday.test.ts
@@ -0,0 +1,33 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { formatDate } from '../utils';
+import { useSettingsStore } from '@/stores/settings-store';
+
+// mail-p2-10: on Wednesday Nov 4 at 10:40, a mail from Wednesday Oct 28 at
+// 17:40 read "Wed 17:40", as if it came later today. The weekday names only
+// the last six calendar days; older mail shows its date.
+
+describe('formatDate weekday window', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-11-04T09:40:00Z')); // Wed 10:40 in Berlin
+ useSettingsStore.setState({ dateFormat: 'smart', timeFormat: '24h', dateLocale: 'auto', timeZone: 'Europe/Berlin' });
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('gives a mail from the same weekday last week its date, not the weekday', () => {
+ expect(formatDate(new Date('2026-10-28T16:40:00Z'))).toBe('Oct 28');
+ });
+
+ it('keeps the weekday for the last six calendar days', () => {
+ expect(formatDate(new Date('2026-10-29T16:40:00Z'))).toBe('Thu 17:40');
+ expect(formatDate(new Date('2026-11-03T22:40:00Z'))).toBe('Tue 23:40');
+ });
+
+ it('counts days in the display zone, not 24-hour periods', () => {
+ // Late on Oct 28 in Berlin is less than seven 24-hour periods ago but
+ // still last Wednesday.
+ expect(formatDate(new Date('2026-10-28T22:30:00Z'))).toBe('Oct 28');
+ });
+});
diff --git a/lib/__tests__/held-send.test.ts b/lib/__tests__/held-send.test.ts
new file mode 100644
index 000000000..3a8fc956c
--- /dev/null
+++ b/lib/__tests__/held-send.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+import type { Email, Mailbox } from '@/lib/jmap/types';
+import { addEcho, isUndoWindow, mergeEchoes, sentEcho, settleSent, withoutHeldSend } from '../held-send';
+
+const mb = (role: string, totalEmails: number, extra: Partial = {}) =>
+ ({ id: role, name: role, role, totalEmails, unreadEmails: 0, ...extra }) as Mailbox;
+const now = Date.parse('2026-11-04T10:00:00Z');
+const soon = new Date(now + 5000).toISOString();
+
+describe('withoutHeldSend', () => {
+ it('shows Sent and Scheduled as they were before a send in its Undo window', () => {
+ const view = withoutHeldSend([mb('inbox', 146), mb('sent', 52)], 1, { sendAt: soon, countsBefore: { sent: 51, scheduled: 0 } }, now);
+ expect(view.scheduledTotal).toBe(0);
+ expect(view.mailboxes.find((m) => m.role === 'sent')!.totalEmails).toBe(51);
+ expect(view.mailboxes.find((m) => m.role === 'inbox')!.totalEmails).toBe(146);
+ });
+
+ it('shows a count that fell meanwhile as it is', () => {
+ const view = withoutHeldSend([mb('sent', 49)], 0, { sendAt: soon, countsBefore: { sent: 51, scheduled: 1 } }, now);
+ expect(view.mailboxes[0].totalEmails).toBe(49);
+ expect(view.scheduledTotal).toBe(0);
+ });
+
+ it('counts a mail scheduled for later at once', () => {
+ const tomorrow = new Date(now + 86_400_000).toISOString();
+ const view = withoutHeldSend([mb('sent', 52)], 1, { sendAt: tomorrow, countsBefore: { sent: 51, scheduled: 0 } }, now);
+ expect(view.scheduledTotal).toBe(1);
+ expect(view.mailboxes[0].totalEmails).toBe(52);
+ });
+
+ it('leaves a shared Sent folder alone', () => {
+ const view = withoutHeldSend([mb('sent', 9, { isShared: true })], 0, { sendAt: soon, countsBefore: { sent: 5, scheduled: 0 } }, now);
+ expect(view.mailboxes[0].totalEmails).toBe(9);
+ });
+});
+
+describe('isUndoWindow', () => {
+ it('is two minutes at most', () => {
+ expect(isUndoWindow(soon, now)).toBe(true);
+ expect(isUndoWindow(new Date(now + 121_000).toISOString(), now)).toBe(false);
+ expect(isUndoWindow(undefined, now)).toBe(false);
+ });
+});
+
+describe('echoes of sent replies', () => {
+ const server = [{ id: 'a' }, { id: 'b' }] as Email[];
+ const echo = sentEcho({ emailId: 'c', threadId: 't', to: ['sam@example.org'], subject: 'Re: x', text: 'Thanks', fromEmail: 'me@example.org' });
+
+ it('is read, carries its text and the server id', () => {
+ expect(echo.id).toBe('c');
+ expect(echo.keywords.$seen).toBe(true);
+ expect(Object.values(echo.bodyValues!)[0].value).toBe('Thanks');
+ expect(echo.sentEcho).toBe(true);
+ });
+
+ it('is added once', () => {
+ expect(addEcho(addEcho(server, echo), echo).map((e) => e.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('gives way to the server copy, and stays while the server lacks it', () => {
+ expect(mergeEchoes([...server, { id: 'c' } as Email], [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'c']);
+ expect(mergeEchoes(server, [...server, echo]).map((e) => (e.sentEcho ? 'echo' : e.id))).toEqual(['a', 'b', 'echo']);
+ });
+
+ it('keeps the body of a message already unfolded', () => {
+ const loaded = { id: 'a', keywords: {}, mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'Hi' } } } as unknown as Email;
+ const listed = { id: 'a', keywords: { $seen: true }, mailboxIds: { archive: true } } as unknown as Email;
+ const [merged] = mergeEchoes([listed], [loaded]);
+ expect(merged.bodyValues).toEqual(loaded.bodyValues);
+ expect(merged.keywords).toEqual({ $seen: true });
+ expect(merged.mailboxIds).toEqual({ archive: true });
+ });
+});
+
+describe('settleSent', () => {
+ it('drops the waiting mark once the send time passed, keeps later ones', () => {
+ const past = { id: 'a', isScheduled: true, scheduledSendAt: new Date(now - 1000).toISOString() } as Email;
+ const later = { id: 'b', isScheduled: true, scheduledSendAt: new Date(now + 60_000).toISOString() } as Email;
+ const [a, b] = settleSent([past, later], now);
+ expect(a.isScheduled).toBe(false);
+ expect(b.isScheduled).toBe(true);
+ });
+});
diff --git a/lib/__tests__/move-menu.test.ts b/lib/__tests__/move-menu.test.ts
index 1cfa8fa36..637017ab4 100644
--- a/lib/__tests__/move-menu.test.ts
+++ b/lib/__tests__/move-menu.test.ts
@@ -46,8 +46,24 @@ const ids = (nodes: MoveMenuNode[]): string[] => nodes.flatMap((n) => [n.id, ...
describe('buildMoveMenu', () => {
it('lists system folders first in mail-client order, then the rest by name', () => {
const { nodes } = buildMoveMenu(mailboxes, { currentMailboxId: 'work', ownerAccountId: 'A', locale: 'en' });
- // Drafts can't take a moved message; the server's sortOrder doesn't count.
- expect(names(nodes)).toEqual(['Inbox', 'Sent Items', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
+ // Drafts and Sent can't take a moved message (pass 2 p2-18 for Sent);
+ // the server's sortOrder doesn't put a folder above the Inbox.
+ expect(names(nodes)).toEqual(['Inbox', 'Archive', 'Junk Mail', 'Trash', 'apple', 'Read only', 'Receipts', 'Work', 'Zettelkasten']);
+ });
+
+ // p2-18: the sidebar showed Receipts > 2026, 2025, the Move list 2025, 2026.
+ it("orders the user's folders as the sidebar does: set order, then years newest first", () => {
+ const boxes = [
+ own('inbox', 'Inbox', { role: 'inbox', sortOrder: 10 }),
+ own('receipts', 'Receipts'),
+ own('y2025', '2025', { parentId: 'receipts' }),
+ own('y2026', '2026', { parentId: 'receipts' }),
+ own('b', 'Beta', { sortOrder: 2 }),
+ own('a', 'Alpha', { sortOrder: 3 }),
+ ];
+ const { nodes } = buildMoveMenu(boxes, { ownerAccountId: 'A', locale: 'en' });
+ expect(names(nodes)).toEqual(['Inbox', 'Receipts', 'Beta', 'Alpha']);
+ expect(names(nodes.find((n) => n.id === 'receipts')!.children)).toEqual(['2026', '2025']);
});
it('keeps the nesting, sorts numbers as numbers, and keeps a read-only parent as a step', () => {
@@ -65,7 +81,7 @@ describe('buildMoveMenu', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const { nodes, targetCount } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox] });
expect(nodes[0]).toMatchObject({ id: 'inbox', enabled: false, current: true });
- expect(targetCount).toBe(13);
+ expect(targetCount).toBe(12);
});
it('lets a search hit from another folder move into the open one (#1452)', () => {
@@ -90,8 +106,8 @@ describe('buildMoveMenu', () => {
const inInbox = { mailboxIds: { inbox: true } } as unknown as Email;
const recentIds = (account: string | undefined) => (account === 'A' ? ['gone', 'inbox', 'acme', 'S:projects', 'sent', 'trash'] : []);
const { recent } = buildMoveMenu(mailboxes, { currentMailboxId: 'inbox', ownerAccountId: 'A', emails: [inInbox], recentIds });
- // A deleted folder and the open one drop out; three show.
- expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'sent']);
+ // A deleted folder, the open one and Sent drop out; three show.
+ expect(recent.map((n) => n.id)).toEqual(['acme', 'S:projects', 'trash']);
});
});
diff --git a/lib/__tests__/offline-key-guard.test.ts b/lib/__tests__/offline-key-guard.test.ts
new file mode 100644
index 000000000..46319a5a0
--- /dev/null
+++ b/lib/__tests__/offline-key-guard.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, vi } from 'vitest';
+import { guardOfflineMailChanges } from '../offline-key-guard';
+
+describe('keys that change mail, offline', () => {
+ it('answer with the refusal instead of running, and run again once they can', () => {
+ let can = false;
+ const archive = vi.fn();
+ const next = vi.fn();
+ const refused = vi.fn();
+ const handlers = guardOfflineMailChanges({ onArchive: archive, onNextEmail: next }, () => can, refused);
+ handlers.onArchive();
+ expect(archive).not.toHaveBeenCalled();
+ expect(refused).toHaveBeenCalledTimes(1);
+ handlers.onNextEmail();
+ expect(next).toHaveBeenCalledTimes(1);
+ can = true;
+ handlers.onArchive();
+ expect(archive).toHaveBeenCalledTimes(1);
+ expect(refused).toHaveBeenCalledTimes(1);
+ });
+
+ it('leaves a key with no handler without one', () => {
+ const handlers = guardOfflineMailChanges({ onArchive: undefined as (() => void) | undefined }, () => false, vi.fn());
+ expect(handlers.onArchive).toBeUndefined();
+ });
+});
diff --git a/lib/__tests__/reply-drafts.test.ts b/lib/__tests__/reply-drafts.test.ts
new file mode 100644
index 000000000..47454c9a3
--- /dev/null
+++ b/lib/__tests__/reply-drafts.test.ts
@@ -0,0 +1,54 @@
+import { describe, expect, it, vi } from 'vitest';
+import { answersMessage, findReplyDraft, threadDrafts } from '@/lib/reply-drafts';
+import type { Email } from '@/lib/jmap/types';
+
+const message = { id: 'm1', messageId: ['abc@example.org'] as unknown as string };
+const draft = (id: string, extra: Partial = {}) => ({
+ id, threadId: 't1', keywords: { $draft: true }, receivedAt: '2026-11-02T10:00:00Z', ...extra,
+}) as Email;
+
+describe('answersMessage', () => {
+ it('matches a draft whose In-Reply-To names the message, brackets or not', () => {
+ expect(answersMessage(draft('d1', { inReplyTo: [''] }), message)).toBe(true);
+ expect(answersMessage(draft('d1', { inReplyTo: ['abc@example.org'] }), { messageId: 'abc@example.org' })).toBe(true);
+ });
+
+ it('leaves out a reply to another message, and a sent message', () => {
+ expect(answersMessage(draft('d1', { inReplyTo: ['other@example.org'] }), message)).toBe(false);
+ expect(answersMessage({ inReplyTo: ['abc@example.org'], keywords: {} }, message)).toBe(false);
+ });
+});
+
+describe('findReplyDraft', () => {
+ it('reads list rows without In-Reply-To in full and picks the newest that answers', async () => {
+ const full: Record = {
+ d1: draft('d1', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-01T10:00:00Z' }),
+ d2: draft('d2', { inReplyTo: ['abc@example.org'], receivedAt: '2026-11-03T10:00:00Z' }),
+ d3: draft('d3', { inReplyTo: ['other@example.org'], receivedAt: '2026-11-04T10:00:00Z' }),
+ };
+ const getEmail = vi.fn(async (id: string) => full[id] ?? null);
+ const rows = Object.values(full).map(({ inReplyTo: _drop, ...row }) => row as Email);
+ const found = await findReplyDraft(rows, message, getEmail);
+ expect(found?.id).toBe('d2');
+ expect(getEmail).toHaveBeenCalledWith('d3');
+ expect(getEmail).not.toHaveBeenCalledWith('d1');
+ });
+
+ it('finds nothing for a message without a Message-ID', async () => {
+ const getEmail = vi.fn();
+ expect(await findReplyDraft([draft('d1')], { id: 'm1' }, getEmail)).toBeNull();
+ expect(getEmail).not.toHaveBeenCalled();
+ });
+});
+
+describe('threadDrafts', () => {
+ it('returns the drafts of a conversation, and asks nothing more of a single message', async () => {
+ const client = {
+ getThread: vi.fn(async (id: string) => (id === 't1' ? { emailIds: ['m1', 'd1'] } : { emailIds: ['x'] })),
+ getSomeEmails: vi.fn(async () => [{ id: 'm1', keywords: {} }, draft('d1')] as Email[]),
+ };
+ expect((await threadDrafts(client, 't1', 'acc')).map((e) => e.id)).toEqual(['d1']);
+ expect(await threadDrafts(client, 't2', 'acc')).toEqual([]);
+ expect(client.getSomeEmails).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/__tests__/selection-toast.test.ts b/lib/__tests__/selection-toast.test.ts
new file mode 100644
index 000000000..e4d28ffe0
--- /dev/null
+++ b/lib/__tests__/selection-toast.test.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from 'vitest';
+import type { Email } from '@/lib/jmap/types';
+import { batchToastTitle, selectionRowCount } from '../selection-toast';
+
+// mail-18: a batch toast said "5 emails archived" under a bar that said
+// "5 conversations". The toasts now count the rows the bar counts.
+
+const t = (key: string, values?: Record) => `${key} ${JSON.stringify(values ?? {})}`;
+const mail = (id: string, threadId: string) => ({ id, threadId }) as Email;
+
+describe('selection toasts', () => {
+ it('counts conversations, not the messages a conversation row ticks', () => {
+ const selected = [mail('a', 't1'), mail('b', 't1'), mail('c', 't2')];
+ expect(selectionRowCount(selected, 3, false)).toBe(2);
+ expect(selectionRowCount(selected, 3, true)).toBe(3);
+ expect(selectionRowCount([], 4, false)).toBe(4);
+ });
+
+ it('names conversations while the list groups them', () => {
+ expect(batchToastTitle(t, 'archive', 5, false)).toBe('mail_p2_list.conversations_archived {"count":5}');
+ expect(batchToastTitle(t, 'trash', 2, false)).toBe('mail_p2_list.conversations_to_trash {"count":2}');
+ expect(batchToastTitle(t, 'delete', 2, false)).toBe('mail_p2_list.conversations_deleted {"count":2}');
+ expect(batchToastTitle(t, 'move', 3, false, 'Family')).toBe('mail_p2_list.conversations_moved_to {"count":3,"mailbox":"Family"}');
+ });
+
+ it('keeps the email wording with conversations off', () => {
+ expect(batchToastTitle(t, 'archive', 5, true)).toBe('notifications.emails_archived {"count":5}');
+ expect(batchToastTitle(t, 'move', 3, true, 'Family')).toBe('mail_list_extra.moved_count_to {"count":3,"mailbox":"Family"}');
+ });
+});
diff --git a/lib/__tests__/spam-applicable.test.ts b/lib/__tests__/spam-applicable.test.ts
new file mode 100644
index 000000000..0f39c0b1f
--- /dev/null
+++ b/lib/__tests__/spam-applicable.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { spamApplicableIn } from '../spam-applicable';
+
+describe('spamApplicableIn', () => {
+ it('offers Report spam on received mail', () => {
+ for (const role of ['inbox', 'archive', 'junk', null, undefined]) expect(spamApplicableIn(role)).toBe(true);
+ });
+
+ it('not on your own outgoing mail, nor in Trash', () => {
+ for (const role of ['sent', 'drafts', 'scheduled', 'trash']) expect(spamApplicableIn(role)).toBe(false);
+ });
+});
diff --git a/lib/__tests__/tab-title.test.ts b/lib/__tests__/tab-title.test.ts
index 1b0073fe8..f375b2890 100644
--- a/lib/__tests__/tab-title.test.ts
+++ b/lib/__tests__/tab-title.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { formatTabTitle, mailTitleContext } from '../tab-title';
+import { formatTabTitle, mailTitleContext, mailboxTitleLine } from '../tab-title';
describe('formatTabTitle', () => {
it('puts the context first, the account next and the app name last', () => {
@@ -50,3 +50,25 @@ describe('mailTitleContext', () => {
expect(mailTitleContext(view, { showSubject: false })).toBe('Reply');
});
});
+
+describe('mailboxTitleLine', () => {
+ const t = (key: string, values?: Record) => `${key} ${JSON.stringify(values)}`;
+
+ it('names the folder with its unread count', () => {
+ expect(mailboxTitleLine({ name: 'Family', unread: 2 }, t)).toBe('Family (2)');
+ expect(mailboxTitleLine({ name: 'Family', unread: 0 }, t)).toBe('Family');
+ });
+
+ // mail-6: the title said 10 while 30 were unread in all accounts.
+ it('adds the other accounts on the Inbox', () => {
+ expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 20, ownInbox: true }, t))
+ .toBe('mail_p2_folders.title_all_accounts {"name":"Inbox","count":10,"total":30}');
+ expect(mailboxTitleLine({ name: 'Inbox', unread: 0, otherUnread: 20, ownInbox: true }, t))
+ .toBe('mail_p2_folders.title_all_accounts_only {"name":"Inbox","total":20}');
+ });
+
+ it('leaves other folders and an Inbox with no other unread mail alone', () => {
+ expect(mailboxTitleLine({ name: 'Family', unread: 1, otherUnread: 20 }, t)).toBe('Family (1)');
+ expect(mailboxTitleLine({ name: 'Inbox', unread: 10, otherUnread: 0, ownInbox: true }, t)).toBe('Inbox (10)');
+ });
+});
diff --git a/lib/__tests__/window-controls-host.test.tsx b/lib/__tests__/window-controls-host.test.tsx
index 416ccd5ed..b2c6a7771 100644
--- a/lib/__tests__/window-controls-host.test.tsx
+++ b/lib/__tests__/window-controls-host.test.tsx
@@ -341,6 +341,28 @@ describe('the window controls over the app', () => {
expect(root.hasAttribute(CONTROLS_LEFT_ATTRIBUTE)).toBe(true);
});
+ // mail-p2-16: the desktop app sends geometrychange while the page still has
+ // its old width; the controls' share came out of that and stayed wrong.
+ it('measures again with the new width once the window has resized', () => {
+ const before = window.innerWidth;
+ const fake = overlayAt(0, before - 137);
+ render( );
+ expect(rightInset()).toBe('137px');
+ act(() => {
+ (fake as unknown as { getTitlebarAreaRect: () => object }).getTitlebarAreaRect = () => ({ x: 0, y: 0, width: 800 - 137, height: 56 });
+ fake.dispatchEvent(new Event('geometrychange'));
+ });
+ act(() => {
+ Object.defineProperty(window, 'innerWidth', { value: 800, configurable: true, writable: true });
+ window.dispatchEvent(new Event('resize'));
+ });
+ try {
+ expect(rightInset()).toBe('137px');
+ } finally {
+ Object.defineProperty(window, 'innerWidth', { value: before, configurable: true, writable: true });
+ }
+ });
+
it('follows geometrychange, and takes everything back when the overlay goes', () => {
const fake = overlayAt(0, window.innerWidth - 138);
render( );
diff --git a/lib/compose/__tests__/attachment-order.test.ts b/lib/compose/__tests__/attachment-order.test.ts
index d5337caea..de1376bed 100644
--- a/lib/compose/__tests__/attachment-order.test.ts
+++ b/lib/compose/__tests__/attachment-order.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import { attachmentsForDisplay } from '../attachment-order';
describe('the order of the composer attachment chips', () => {
- it('puts failed, then uploading files first, and keeps the rest in the order added', () => {
+ it('puts failed files first, and keeps the rest, uploading or not, in the order added', () => {
const files = [
{ name: 'a.pdf' },
{ name: 'b.pdf', uploading: true },
@@ -11,8 +11,8 @@ describe('the order of the composer attachment chips', () => {
{ name: 'broken.png', error: 'failed' },
];
const shown = attachmentsForDisplay(files);
- expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'b.pdf', 'big.zip', 'a.pdf', 'c.pdf']);
+ expect(shown.map(({ att }) => att.name)).toEqual(['broken.png', 'a.pdf', 'b.pdf', 'c.pdf', 'big.zip']);
// Each keeps its place in the composer's list, for Remove.
- expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
+ expect(shown.map(({ index }) => index)).toEqual([4, 0, 1, 2, 3]);
});
});
diff --git a/lib/compose/attachment-order.ts b/lib/compose/attachment-order.ts
index 158d340d6..594011a40 100644
--- a/lib/compose/attachment-order.ts
+++ b/lib/compose/attachment-order.ts
@@ -1,13 +1,14 @@
/**
* The composer's attachment chips in the order they're shown: failed first,
- * then still uploading, then the rest, each group in the order added. The
- * list scrolls past about two rows, so the files that need an eye on them
- * are the ones always in view. `index` is the place in the composer's list.
+ * then the rest in the order added. A file still uploading keeps its place:
+ * moved to the front, it jumped to the end once done and went out of sight
+ * past the rows on screen (compose-p2-11). `index` is the place in the
+ * composer's list.
*/
export function attachmentsForDisplay(
attachments: readonly T[],
): Array<{ att: T; index: number }> {
- const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
+ const rank = (att: T) => (att.error ? 0 : 1);
return attachments
.map((att, index) => ({ att, index }))
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
diff --git a/lib/desktop/__tests__/os-commands.test.ts b/lib/desktop/__tests__/os-commands.test.ts
index cd724ed85..d904c486c 100644
--- a/lib/desktop/__tests__/os-commands.test.ts
+++ b/lib/desktop/__tests__/os-commands.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge, DesktopOsCommand } from '../types';
import type { NotificationActionRequest, NotificationActionStatus } from '@/lib/pwa-notify/page-actions';
@@ -44,6 +44,12 @@ async function run(command: DesktopOsCommand) {
const text = (value: string) => new TextEncoder().encode(value);
+// The handler pulls in much of the app the first time; load it once here, with room to spare,
+// so the first test isn't the one that times out on a busy machine.
+beforeAll(async () => {
+ await import('../os-commands');
+}, 60_000);
+
beforeEach(async () => {
vi.clearAllMocks();
authenticated = true;
diff --git a/lib/empty-folder-text.ts b/lib/empty-folder-text.ts
new file mode 100644
index 000000000..929bc32a7
--- /dev/null
+++ b/lib/empty-folder-text.ts
@@ -0,0 +1,38 @@
+/**
+ * What "Empty Trash" / "Empty Spam" is called and what its confirm says, as
+ * keys of the root catalogue. The confirm counts the messages that go
+ * (usability test 2026-11-04, p2-14). Other folders keep the old wording:
+ * nothing in the app offers to empty them, but a plugin can still ask.
+ */
+export function emptyFolderLabelKey(role: string | null | undefined): string | null {
+ if (role === "trash") return "mail_p2_folders.empty_trash";
+ if (role === "junk") return "mail_p2_folders.empty_spam";
+ return null;
+}
+
+export interface EmptyFolderPrompt {
+ title: string;
+ message: string;
+ values?: Record;
+ confirm: string;
+}
+
+export function emptyFolderPrompt(
+ mailbox: { role?: string | null; totalEmails?: number },
+ movesToTrash: boolean,
+): EmptyFolderPrompt {
+ const label = emptyFolderLabelKey(mailbox.role);
+ if (label) {
+ return {
+ title: label,
+ message: mailbox.role === "trash" ? "mail_p2_folders.empty_trash_confirm" : "mail_p2_folders.empty_spam_confirm",
+ values: { count: mailbox.totalEmails ?? 0 },
+ confirm: label,
+ };
+ }
+ return {
+ title: "email_list.empty_folder.confirm_title",
+ message: movesToTrash ? "email_list.empty_folder.confirm_message_trash" : "email_list.empty_folder.confirm_message",
+ confirm: "email_list.empty_folder.confirm_button",
+ };
+}
diff --git a/lib/held-send.ts b/lib/held-send.ts
new file mode 100644
index 000000000..fbbda01db
--- /dev/null
+++ b/lib/held-send.ts
@@ -0,0 +1,121 @@
+import type { Email, Mailbox } from '@/lib/jmap/types';
+
+/**
+ * A message in its Undo send window: sent to the server with a short hold, so
+ * the server already files it in Sent and lists its submission. Until the
+ * window is over it isn't sent as far as the user can tell (mail-p2-8):
+ * Sent and Scheduled don't count it, and the message it answers isn't marked
+ * as answered yet.
+ */
+export const UNDO_WINDOW_MS = 120_000;
+
+export interface HeldSendCounts {
+ /** Sent's total before the send, when the send went through the open login. */
+ sent?: number;
+ /** The Scheduled count before the send. */
+ scheduled: number;
+}
+
+export function isUndoWindow(sendAt: string | undefined, now = Date.now()): boolean {
+ const at = sendAt ? Date.parse(sendAt) : NaN;
+ return Number.isFinite(at) && at - now <= UNDO_WINDOW_MS;
+}
+
+/**
+ * The folder list and Scheduled count to show while a send waits in its
+ * Undo window: neither goes above what it was before the send. A count that
+ * fell meanwhile (a mail deleted) shows as it is.
+ */
+export function withoutHeldSend(
+ mailboxes: Mailbox[],
+ scheduledTotal: number,
+ held: { sendAt: string; countsBefore?: HeldSendCounts } | null | undefined,
+ now = Date.now(),
+): { mailboxes: Mailbox[]; scheduledTotal: number } {
+ if (!held?.countsBefore || !isUndoWindow(held.sendAt, now)) return { mailboxes, scheduledTotal };
+ const before = held.countsBefore;
+ const sentBefore = before.sent;
+ const adjusted = sentBefore === undefined ? mailboxes : mailboxes.map((mb) =>
+ mb.role === 'sent' && !mb.isShared && mb.totalEmails > sentBefore ? { ...mb, totalEmails: sentBefore } : mb);
+ return { mailboxes: adjusted, scheduledTotal: Math.min(scheduledTotal, before.scheduled) };
+}
+
+/**
+ * The reply just sent, as the conversation shows it until the server's copy
+ * is read back (mail-p2-1). With the server's id when it gave one, so the
+ * real message replaces it in place.
+ */
+export function sentEcho(input: {
+ emailId?: string;
+ threadId: string;
+ fromEmail?: string;
+ fromName?: string;
+ to: string[];
+ cc?: string[];
+ subject: string;
+ text: string;
+ now?: Date;
+}): Email {
+ const at = (input.now ?? new Date()).toISOString();
+ const preview = input.text.replace(/\s+/g, ' ').trim().slice(0, 256);
+ return {
+ id: input.emailId ?? `sent-echo-${at}`,
+ blobId: '',
+ threadId: input.threadId,
+ mailboxIds: {},
+ keywords: { $seen: true },
+ size: 0,
+ receivedAt: at,
+ sentAt: at,
+ from: input.fromEmail ? [{ name: input.fromName ?? '', email: input.fromEmail }] : [],
+ to: input.to.map((email) => ({ name: '', email })),
+ cc: (input.cc ?? []).map((email) => ({ name: '', email })),
+ subject: input.subject,
+ preview,
+ hasAttachment: false,
+ textBody: [{ partId: 'sent-echo', blobId: '', size: 0, type: 'text/plain' }],
+ htmlBody: [],
+ attachments: [],
+ bodyValues: { 'sent-echo': { value: input.text, isEncodingProblem: false, isTruncated: false } },
+ sentEcho: true,
+ } as unknown as Email;
+}
+
+/**
+ * The conversation with a just-sent reply added at its end (once).
+ */
+export function addEcho(conversation: Email[], echo: Email): Email[] {
+ if (conversation.some((e) => e.id === echo.id)) return conversation;
+ return [...conversation, echo];
+}
+
+/**
+ * The server's reading of the conversation, with the replies shown before it
+ * had them kept at the end: a server that threads a new message a moment
+ * later (or an offline send that waits in the outbox) doesn't make the reply
+ * vanish again.
+ */
+export function mergeEchoes(server: Email[], shown: Email[]): Email[] {
+ const ids = new Set(server.map((e) => e.id));
+ const byId = new Map(shown.map((e) => [e.id, e]));
+ // The thread read carries list properties only: a message already unfolded
+ // keeps the body it loaded, with the server's flags and folders.
+ const merged = server.map((e) => {
+ const had = byId.get(e.id);
+ return had?.bodyValues && !e.bodyValues && !had.sentEcho
+ ? { ...had, keywords: e.keywords, mailboxIds: e.mailboxIds }
+ : e;
+ });
+ const echoes = shown.filter((e) => e.sentEcho && !ids.has(e.id));
+ return echoes.length ? [...merged, ...echoes] : merged;
+}
+
+/**
+ * Messages whose send time has passed are sent: a thread read during the
+ * Undo window still marks them as waiting.
+ */
+export function settleSent(emails: Email[], now = Date.now()): Email[] {
+ return emails.map((e) => (e.isScheduled && e.scheduledSendAt && Date.parse(e.scheduledSendAt) <= now
+ ? { ...e, isScheduled: false, scheduledSendAt: undefined, scheduledUndoStatus: undefined }
+ : e));
+}
diff --git a/lib/jmap/types.ts b/lib/jmap/types.ts
index 39de51ac8..eb4ab7387 100644
--- a/lib/jmap/types.ts
+++ b/lib/jmap/types.ts
@@ -113,6 +113,11 @@ export interface Email {
scheduledAccountId?: string;
isScheduled?: boolean;
isSmimeScheduled?: boolean;
+ /**
+ * Client-only: a reply just sent, shown in the open conversation before the
+ * server's copy is read back (lib/held-send.ts).
+ */
+ sentEcho?: boolean;
}
export interface SendEmailResult {
diff --git a/lib/keyboard-shortcuts/mail.ts b/lib/keyboard-shortcuts/mail.ts
index f9cdf761f..e7aa88ad8 100644
--- a/lib/keyboard-shortcuts/mail.ts
+++ b/lib/keyboard-shortcuts/mail.ts
@@ -10,14 +10,17 @@ export const MAIL_SHORTCUTS = {
{ key: "n", description: "shortcuts.navigation.next_unread" },
{ key: "g → i / s / t / d / a", description: "shortcuts.navigation.go_to_folder", layout: "mountain-view" },
],
+ // The same keys the toolbar tooltips and message menus show
+ // (components/email/message-action-shortcuts.ts). `a` and `#` still work,
+ // unadvertised (mail-p2-13).
actions: [
{ key: "r", description: "shortcuts.actions.reply", desktopKey: "Mod + R" },
- { key: "R / a", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" },
+ { key: "Shift + R", description: "shortcuts.actions.reply_all", desktopKey: "Mod + Shift + R" },
// Ctrl+F forwards with a message selected (Outlook) and searches elsewhere; Ctrl+Shift+F still works.
{ key: "f", description: "shortcuts.actions.forward", desktopKey: "Mod + Shift + F", desktopPcKey: "Ctrl + F / Ctrl + Shift + F" },
{ key: "s", description: "shortcuts.actions.star" },
{ key: "e", description: "shortcuts.actions.archive", feature: "archiveActionEnabled" },
- { key: "# / Del", description: "shortcuts.actions.delete" },
+ { key: "Del", description: "shortcuts.actions.delete" },
{ key: "u", description: "shortcuts.actions.mark_unread", desktopPcKey: "Ctrl + U" },
{ key: "Shift + I", description: "shortcuts.actions.mark_read", desktopWindowsKey: "Ctrl + Q" },
{ key: "!", description: "shortcuts.actions.toggle_spam" },
diff --git a/lib/move-menu.ts b/lib/move-menu.ts
index a2f320d1b..caf495d62 100644
--- a/lib/move-menu.ts
+++ b/lib/move-menu.ts
@@ -6,9 +6,11 @@
* - The message's own account at the top. Other accounts a move can reach
* (shared ones) come after it, each under its own name.
* - System folders first, in the order mail clients use (Inbox, Drafts,
- * Sent, Archive, Spam, Trash), then the user's folders by name, sorted
- * for the interface language. The server's sortOrder doesn't count here:
- * the sidebar honours it, a Move list is for finding a folder by name.
+ * Sent, Archive, Spam, Trash), then the user's folders in the sidebar's
+ * order: an order the user set, year folders newest first, then by name
+ * for the interface language (usability test 2026-11-04, p2-18). The
+ * server's sortOrder doesn't put a user's folder above the Inbox here.
+ * - Not Drafts or Sent: mail isn't moved into either (pass-1 mail-12).
* - Nesting kept. The open folder stays in the list, disabled, when the
* messages are all in it (#1452: a search hit from elsewhere can move in).
* - The last few folders moved to, per account, as "Recent".
@@ -37,6 +39,8 @@ export interface MoveMenuNode {
/** Names of the folders above it, outermost first. */
path: string[];
children: MoveMenuNode[];
+ /** The folder's sortOrder, which orders the user's folders as the sidebar does. */
+ sortOrder?: number;
}
export interface MoveMenuAccount {
@@ -75,10 +79,21 @@ function rolePosition(role: string | null | undefined): number {
return at < 0 ? SYSTEM_FOLDER_ORDER.length : at;
}
+const YEAR = /^\d{4}$/;
+
+type Sortable = Pick & { sortOrder?: number };
+
export function compareMoveNodes(collator: Intl.Collator) {
- return (a: Pick, b: Pick): number => {
+ return (a: Sortable, b: Sortable): number => {
const byRole = rolePosition(a.role) - rolePosition(b.role);
if (byRole !== 0) return byRole;
+ // The user's own folders as the sidebar lists them (lib/utils.ts
+ // buildMailboxTree): a set order first, then years newest first.
+ if (!a.role && !b.role) {
+ const byOrder = (a.sortOrder ?? 0) - (b.sortOrder ?? 0);
+ if (byOrder !== 0) return byOrder;
+ if (YEAR.test(a.name) && YEAR.test(b.name)) return Number(b.name) - Number(a.name);
+ }
return collator.compare(a.name, b.name);
};
}
@@ -91,9 +106,9 @@ function makeCollator(locale?: string): Intl.Collator {
}
}
-/** A target: may take mail, isn't Drafts, isn't a virtual header. */
+/** A target: may take mail, isn't Drafts or Sent, isn't a virtual header. */
function isTarget(mailbox: Mailbox): boolean {
- return mailbox.role !== "drafts" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems;
+ return mailbox.role !== "drafts" && mailbox.role !== "sent" && !mailbox.id.startsWith("shared-") && !!mailbox.myRights?.mayAddItems;
}
/** One account's folders as a sorted tree, with only the branches that lead somewhere. */
@@ -128,7 +143,7 @@ function accountTree(
// A folder that can't take mail stays as a step to the ones below it;
// the open folder stays so the list reads like the sidebar.
if (!enabled && !children.length && !(current && isTarget(box))) continue;
- out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children });
+ out.push({ id: box.id, name: box.name, role: box.role ?? null, enabled, current, path, children, sortOrder: box.sortOrder });
}
return out.sort(compare);
};
diff --git a/lib/offline-key-guard.ts b/lib/offline-key-guard.ts
new file mode 100644
index 000000000..349318fad
--- /dev/null
+++ b/lib/offline-key-guard.ts
@@ -0,0 +1,28 @@
+/**
+ * Keys that change mail on the server: archive, delete, read/unread, spam,
+ * star. Offline with no mail kept on the device they can't run, and before
+ * this they did nothing at all.
+ */
+export const MAIL_CHANGE_SHORTCUTS = ['onArchive', 'onDelete', 'onMarkAsRead', 'onMarkAsUnread', 'onToggleSpam', 'onToggleStar'] as const;
+
+type Handler = (...args: never[]) => unknown;
+
+/**
+ * The same handlers, with those keys answering `onRefused` instead while
+ * `canChangeMail()` is false. A key with no handler stays without one.
+ */
+export function guardOfflineMailChanges(handlers: T, canChangeMail: () => boolean, onRefused: () => void): T {
+ const guarded = { ...handlers } as Record;
+ for (const key of MAIL_CHANGE_SHORTCUTS) {
+ const handler = (handlers as Record)[key] as Handler | undefined;
+ if (typeof handler !== 'function') continue;
+ guarded[key] = (...args: never[]) => {
+ if (!canChangeMail()) {
+ onRefused();
+ return undefined;
+ }
+ return handler(...args);
+ };
+ }
+ return guarded as T;
+}
diff --git a/lib/outgoing/__tests__/undo-send-countdown.test.ts b/lib/outgoing/__tests__/undo-send-countdown.test.ts
new file mode 100644
index 000000000..cbc39721d
--- /dev/null
+++ b/lib/outgoing/__tests__/undo-send-countdown.test.ts
@@ -0,0 +1,79 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { toast, useToastStore } from '@/stores/toast-store';
+import { runUndoSendCountdown } from '../send-progress-toast';
+
+// compose-p2-12: "Sending in 5 seconds" stayed the same for 5 s and then
+// went, with no word that the message went.
+
+const T0 = Date.parse('2026-11-04T10:00:00Z');
+
+beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(T0);
+ useToastStore.setState({ toasts: [] });
+});
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+function start(view?: () => void, maxSeconds = 5) {
+ const id = toast.success('Sending in 5 seconds', {
+ duration: 5000,
+ pauseOnHover: false,
+ action: { label: 'Undo', onClick: () => {} },
+ secondaryAction: { label: 'Send now', onClick: () => {} },
+ });
+ const stop = runUndoSendCountdown(id, {
+ sendAt: T0 + 5600,
+ undoEndsAt: T0 + 4600,
+ maxSeconds,
+ sendingIn: (s) => `Sending in ${s} seconds`,
+ sent: 'Sent',
+ view: view ? { label: 'View', onClick: view } : undefined,
+ });
+ const current = () => useToastStore.getState().toasts.find((t) => t.id === id);
+ return { id, stop, current };
+}
+
+describe('the undo send toast', () => {
+ it('counts down, never above the delay the user picked', () => {
+ const { current } = start();
+ // 5.6 s left would round up to 6; the setting says 5.
+ expect(current()?.title).toBe('Sending in 5 seconds');
+ vi.advanceTimersByTime(1000);
+ expect(current()?.title).toBe('Sending in 5 seconds');
+ vi.advanceTimersByTime(1000);
+ expect(current()?.title).toBe('Sending in 4 seconds');
+ vi.advanceTimersByTime(1500);
+ expect(current()?.title).toBe('Sending in 3 seconds');
+ });
+
+ it('turns into "Sent" with View as Undo ends, with a fresh time to read it', () => {
+ const view = vi.fn();
+ const { current } = start(view);
+ vi.advanceTimersByTime(4600);
+ const shown = current()!;
+ expect(shown.title).toBe('Sent');
+ expect(shown.action?.label).toBe('View');
+ expect(shown.secondaryAction).toBeUndefined();
+ expect(shown.pauseOnHover).toBeUndefined();
+ expect(shown.duration).not.toBe(5000);
+ shown.action!.onClick();
+ expect(view).toHaveBeenCalled();
+ });
+
+ it('says "Sent" without View when the message can\'t be opened from here', () => {
+ const { current } = start(undefined);
+ vi.advanceTimersByTime(4600);
+ expect(current()?.title).toBe('Sent');
+ expect(current()?.action).toBeUndefined();
+ });
+
+ it('leaves a toast that is gone (Undo, Send now) alone', () => {
+ const { id, current } = start();
+ toast.dismiss(id);
+ vi.advanceTimersByTime(6000);
+ expect(current()).toBeUndefined();
+ expect(useToastStore.getState().toasts).toHaveLength(0);
+ });
+});
diff --git a/lib/outgoing/send-progress-toast.ts b/lib/outgoing/send-progress-toast.ts
index b94e8753f..e4fda0f13 100644
--- a/lib/outgoing/send-progress-toast.ts
+++ b/lib/outgoing/send-progress-toast.ts
@@ -4,7 +4,7 @@
* stay on screen, greyed and without a word, until the server answered.
*/
-import { toast } from '@/stores/toast-store';
+import { toast, useToastStore } from '@/stores/toast-store';
import { announce } from '@/lib/announce';
import { effectiveToastDuration } from '@/lib/toast-duration';
@@ -46,3 +46,78 @@ export function startSendProgress(labels: SendProgressLabels): SendProgress {
},
};
}
+
+/** How long past the end of Undo the undo toast is given, so the change to "Sent" comes first. */
+export const UNDO_TOAST_SLACK_MS = 1000;
+
+export interface UndoCountdownOptions {
+ /** When the server lets the message go (ms since epoch). */
+ sendAt: number;
+ /** When Undo stops being offered (ms since epoch); the toast turns into "Sent" then. */
+ undoEndsAt: number;
+ /** The delay the user picked, in seconds: the count never shows more. */
+ maxSeconds?: number;
+ sendingIn: (seconds: number) => string;
+ sent: string;
+ /** View on "Sent": opens the message. Without it there is no View. */
+ view?: { label: string; onClick: () => void };
+ now?: () => number;
+}
+
+/**
+ * The undo-send toast `id`, counting down to the send ("Sending in 5
+ * seconds", 4, 3…), then "Sent" with View for the usual time, as Gmail does
+ * (compose-p2-12). It used to show the same number until it went, with no
+ * word that the message went. Stops by itself once the toast is gone (Undo,
+ * Send now, closed). Returns a stop for the caller.
+ */
+/**
+ * Whole seconds until a held send goes, at least 1 and never more than the
+ * delay the user picked: the server's time runs a moment past it, which
+ * showed "Sending in 6 seconds" for a 5 s delay.
+ */
+export function secondsUntilSend(sendAt: number, now: number, maxSeconds?: number): number {
+ const left = Math.max(1, Math.ceil((sendAt - now) / 1000));
+ return maxSeconds && maxSeconds > 0 ? Math.min(left, maxSeconds) : left;
+}
+
+export function runUndoSendCountdown(id: string, options: UndoCountdownOptions): () => void {
+ const now = options.now ?? Date.now;
+ const present = () => useToastStore.getState().toasts.some((t) => t.id === id);
+ const secondsLeft = () => secondsUntilSend(options.sendAt, now(), options.maxSeconds);
+ let shown = -1;
+ const tick = () => {
+ if (!present()) return stop();
+ const seconds = secondsLeft();
+ if (seconds === shown) return;
+ shown = seconds;
+ toast.update(id, { title: options.sendingIn(seconds) });
+ };
+ const finish = () => {
+ stop();
+ const current = useToastStore.getState().toasts.find((t) => t.id === id);
+ if (!current) return;
+ const action = options.view;
+ let duration = effectiveToastDuration({ type: 'success', action });
+ // A new duration starts the toast's countdown afresh; the same one wouldn't.
+ if (duration === current.duration) duration += 1;
+ toast.update(id, {
+ type: 'success',
+ title: options.sent,
+ message: undefined,
+ action,
+ secondaryAction: undefined,
+ pauseOnHover: undefined,
+ duration,
+ });
+ announce(options.sent);
+ };
+ const interval = setInterval(tick, 250);
+ const timer = setTimeout(finish, Math.max(0, options.undoEndsAt - now()));
+ function stop() {
+ clearInterval(interval);
+ clearTimeout(timer);
+ }
+ tick();
+ return stop;
+}
diff --git a/lib/popout/__tests__/moved-undo.test.ts b/lib/popout/__tests__/moved-undo.test.ts
index 67549099e..ad34c244f 100644
--- a/lib/popout/__tests__/moved-undo.test.ts
+++ b/lib/popout/__tests__/moved-undo.test.ts
@@ -37,7 +37,15 @@ describe('moved-undo', () => {
it('with no main window to take it, the message window keeps the Undo itself', async () => {
await offerMoveUndo('archive', alice as never, [{ client: alice as never, points: POINTS }], t, true);
- expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['email_archived']);
+ // The main window's words (mail-18), not "Email archived".
+ expect(useToastStore.getState().toasts.map((toast) => toast.title)).toEqual(['mail_list_extra.archived']);
expect(undoHistory()).toHaveLength(1);
});
+
+ it('says "Moved to Trash" for a delete with Undo, and "Email deleted" only for one for good', async () => {
+ await offerMoveUndo('delete', alice as never, [{ client: alice as never, points: POINTS }], t, true);
+ await offerMoveUndo('delete', alice as never, null, t, true);
+ expect(useToastStore.getState().toasts.map((toast) => toast.title))
+ .toEqual(expect.arrayContaining(['mail_list_extra.moved_to_trash', 'notifications.email_deleted']));
+ });
});
diff --git a/lib/popout/channel.ts b/lib/popout/channel.ts
index b249c5bb4..cd7bcb987 100644
--- a/lib/popout/channel.ts
+++ b/lib/popout/channel.ts
@@ -53,6 +53,8 @@ export interface PopoutSentNotice {
localAccountId?: string;
submissionAccountId?: string;
} | null;
+ /** A send held for undo in the pop-out itself: when the server lets it go. */
+ releasesAt?: string;
}
/**
@@ -127,6 +129,8 @@ export interface DraftKeptNotice {
accountId?: string;
/** The login it was saved through. */
localAccountId?: string;
+ /** Thrown away with the bin: "Draft discarded" with Undo, deleted after. */
+ discarded?: boolean;
}
export interface PopoutChannel {
diff --git a/lib/popout/moved-undo.ts b/lib/popout/moved-undo.ts
index 334a2de12..01b085aef 100644
--- a/lib/popout/moved-undo.ts
+++ b/lib/popout/moved-undo.ts
@@ -7,12 +7,19 @@ import { announceMovedToMainWindow, type PopoutMovedNotice } from './channel';
type MoveKind = PopoutMovedNotice['kind'];
type Translate = (key: string, values?: Record) => string;
-/** The toast's title for each kind, in the `notifications` namespace. */
-const TITLE_KEY: Record = {
- archive: 'email_archived',
- delete: 'email_deleted',
- move: 'email_moved',
-};
+/**
+ * The toast's title for each kind, the main window's words (mail-18):
+ * "Archived", "Moved to Trash", and "Email deleted" only for a delete for
+ * good, which has nothing to restore.
+ */
+function titleKey(kind: MoveKind, hasRestore: boolean): string {
+ if (kind === 'archive') return 'mail_list_extra.archived';
+ if (kind === 'delete') return hasRestore ? 'mail_list_extra.moved_to_trash' : 'notifications.email_deleted';
+ return 'notifications.email_moved';
+}
+
+/** `t` of the `notifications` namespace, for the Undo labels. */
+const notificationsOf = (t: Translate): Translate => (key, values) => t(`notifications.${key}`, values);
/** A move this window made, with logins in place of clients, so another window can undo it. */
export function movedNotice(kind: MoveKind, batches: EmailRestoreBatch[] | null): PopoutMovedNotice {
@@ -50,11 +57,11 @@ export async function offerMoveUndo(
inOwnWindow: boolean,
): Promise {
if (inOwnWindow && (await announceMovedToMainWindow(movedNotice(kind, restore)))) return;
- toastWithUndo(t(TITLE_KEY[kind]), undoMoveAction(client, restore, undoLabels(t)));
+ toastWithUndo(t(titleKey(kind, !!restore?.length)), undoMoveAction(client, restore, undoLabels(notificationsOf(t))));
}
-/** The main window's side of offerMoveUndo. `t` is of the `notifications` namespace. */
+/** The main window's side of offerMoveUndo. `t` is the root translator, as there. */
export function takeOverMoved(notice: PopoutMovedNotice, t: Translate): void {
const batches = restoreBatchesFromNotice(notice);
- toastWithUndo(t(TITLE_KEY[notice.kind]), undoMoveAction(useAuthStore.getState().client, batches, undoLabels(t)));
+ toastWithUndo(t(titleKey(notice.kind, notice.batches.length > 0)), undoMoveAction(useAuthStore.getState().client, batches, undoLabels(notificationsOf(t))));
}
diff --git a/lib/reply-drafts.ts b/lib/reply-drafts.ts
new file mode 100644
index 000000000..08d2d9b33
--- /dev/null
+++ b/lib/reply-drafts.ts
@@ -0,0 +1,58 @@
+import type { Email } from '@/lib/jmap/types';
+import { stripMessageIdBrackets } from '@/lib/email-threading';
+
+/**
+ * A reply saved as a draft, found from the message it answers (compose-p2-6).
+ * The reader marks that message with it, and Reply opens it again instead of
+ * starting a second draft, as Gmail and Outlook do.
+ */
+
+/** The message's own Message-IDs, bare. JMAP sends a list; older code a string. */
+function messageIdsOf(email: Pick): string[] {
+ const raw = email.messageId as unknown;
+ const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
+ return list.filter((id): id is string => typeof id === 'string').map(stripMessageIdBrackets).filter(Boolean);
+}
+
+/** Whether `draft` is a reply to `email` (its In-Reply-To names it). */
+export function answersMessage(draft: Pick, email: Pick): boolean {
+ if (!draft.keywords?.$draft) return false;
+ const ids = messageIdsOf(email);
+ if (ids.length === 0) return false;
+ return (draft.inReplyTo ?? []).some((id) => ids.includes(stripMessageIdBrackets(id)));
+}
+
+const newestFirst = (a: Pick, b: Pick) =>
+ (b.receivedAt ?? '').localeCompare(a.receivedAt ?? '');
+
+/**
+ * The newest of a conversation's drafts that answers `email`, in full. List
+ * rows carry no In-Reply-To, so a draft without it is read once more through
+ * `getEmail`; one that has it (a full fetch) is taken as it is.
+ */
+export async function findReplyDraft(
+ drafts: readonly Email[],
+ email: Pick,
+ getEmail: (id: string) => Promise,
+): Promise {
+ if (messageIdsOf(email).length === 0) return null;
+ const candidates = drafts.filter((d) => d.id !== email.id && d.keywords?.$draft).sort(newestFirst);
+ for (const candidate of candidates) {
+ const full = candidate.inReplyTo !== undefined ? candidate : await getEmail(candidate.id).catch(() => null);
+ if (full && answersMessage(full, email)) return full;
+ }
+ return null;
+}
+
+/** A conversation's drafts, read fresh (after a composer closed or a draft went). */
+export async function threadDrafts(
+ client: { getThread(threadId: string, accountId?: string): Promise<{ emailIds: string[] } | null>; getSomeEmails(ids: string[], accountId?: string): Promise },
+ threadId: string,
+ accountId: string | undefined,
+): Promise {
+ const thread = await client.getThread(threadId, accountId);
+ const ids = thread?.emailIds ?? [];
+ if (ids.length < 2) return [];
+ const members = await client.getSomeEmails(ids, accountId);
+ return members.filter((e) => e.keywords?.$draft);
+}
diff --git a/lib/selection-toast.ts b/lib/selection-toast.ts
new file mode 100644
index 000000000..a0f124973
--- /dev/null
+++ b/lib/selection-toast.ts
@@ -0,0 +1,47 @@
+import type { Email } from "@/lib/jmap/types";
+import { threadKeyFor } from "@/lib/thread-utils";
+
+type Translate = (key: string, values?: Record) => string;
+
+/**
+ * How many rows a selection covers, as the selection bar counts them:
+ * conversations, or messages with conversations off. A conversation row ticks
+ * every message of it the list holds, so the selected keys alone count too
+ * many. `keyCount` is the number of selected keys, for keys no loaded message
+ * stands behind.
+ */
+export function selectionRowCount(selected: readonly Email[], keyCount: number, disableThreading: boolean): number {
+ if (disableThreading || selected.length === 0) return keyCount;
+ return new Set(selected.map((email) => threadKeyFor(email))).size;
+}
+
+export type BatchToastAction = "archive" | "trash" | "delete" | "move";
+
+/**
+ * The toast after a batch action, counting what the bar counted (mail-18):
+ * "5 conversations archived" under a bar that says "5 conversations".
+ * `t` is the root translator.
+ */
+export function batchToastTitle(
+ t: Translate,
+ action: BatchToastAction,
+ count: number,
+ disableThreading: boolean,
+ mailbox?: string,
+): string {
+ if (disableThreading) {
+ if (action === "archive") return t("notifications.emails_archived", { count });
+ if (action === "move") {
+ return mailbox
+ ? t("mail_list_extra.moved_count_to", { count, mailbox })
+ : t("notifications.emails_moved", { count });
+ }
+ return t("notifications.emails_deleted", { count });
+ }
+ if (action === "archive") return t("mail_p2_list.conversations_archived", { count });
+ if (action === "trash") return t("mail_p2_list.conversations_to_trash", { count });
+ if (action === "delete") return t("mail_p2_list.conversations_deleted", { count });
+ return mailbox
+ ? t("mail_p2_list.conversations_moved_to", { count, mailbox })
+ : t("mail_p2_list.conversations_moved", { count });
+}
diff --git a/lib/spam-applicable.ts b/lib/spam-applicable.ts
new file mode 100644
index 000000000..df7cb8e5f
--- /dev/null
+++ b/lib/spam-applicable.ts
@@ -0,0 +1,8 @@
+/**
+ * Whether "Report spam" makes sense for mail in a folder of this role. Not on
+ * your own outgoing mail (Sent, Drafts, Scheduled), and not in Trash, where
+ * the mail is already thrown out.
+ */
+export function spamApplicableIn(role: string | null | undefined): boolean {
+ return !["sent", "drafts", "scheduled", "trash"].includes(role || "");
+}
diff --git a/lib/tab-title.ts b/lib/tab-title.ts
index 87fa1ce2f..503e28918 100644
--- a/lib/tab-title.ts
+++ b/lib/tab-title.ts
@@ -34,3 +34,25 @@ export function mailTitleContext(
): string | null {
return view.composer || (showSubject ? view.subject : null) || view.mailbox || null;
}
+
+type Translate = (key: string, values?: Record) => string;
+
+/**
+ * The mailbox line of the tab title: "Inbox (10)", or for the open account's
+ * own Inbox while other accounts have unread mail too, "Inbox (10, 30 in all
+ * accounts)", the number the favicon and the rail show (usability test
+ * 2026-11-04, mail-6). `otherUnread` is 0 when Notifications counts the open
+ * account only.
+ */
+export function mailboxTitleLine(
+ { name, unread, otherUnread = 0, ownInbox = false }: { name: string; unread: number; otherUnread?: number; ownInbox?: boolean },
+ t: Translate,
+): string {
+ if (ownInbox && otherUnread > 0) {
+ const total = unread + otherUnread;
+ return unread > 0
+ ? t('mail_p2_folders.title_all_accounts', { name, count: unread, total })
+ : t('mail_p2_folders.title_all_accounts_only', { name, total });
+ }
+ return unread > 0 ? `${name} (${unread})` : name;
+}
diff --git a/lib/utils.ts b/lib/utils.ts
index b7ad19197..03f8e928f 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -193,8 +193,13 @@ export function formatDate(
return cachedDateFormat(numericLocale, { timeZone, year: "2-digit", month: "numeric", day: "numeric" }).format(d);
}
- const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
- if (daysAgo < 7) {
+ // Count calendar days in the display zone, not 24-hour periods: a mail
+ // from last Wednesday must never read "Wed" on a Wednesday (mail-p2-10).
+ // The weekday only names the last six days; older mail gets its date.
+ const daysAgo = Math.round(
+ (Date.UTC(nowWall.year, nowWall.month - 1, nowWall.day) - Date.UTC(dWall.year, dWall.month - 1, dWall.day)) / 86400000,
+ );
+ if (daysAgo >= 0 && daysAgo < 7) {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = cachedDateFormat(uiLocale, { timeZone, weekday: "short" })
diff --git a/locales/ar/common.json b/locales/ar/common.json
index 7f5aad8bc..1f509956e 100644
--- a/locales/ar/common.json
+++ b/locales/ar/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "مجلد جديد",
"new_subfolder_title": "مجلد فرعي جديد"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {تمت أرشفة محادثة واحدة} two {تمت أرشفة محادثتين} few {تمت أرشفة # محادثات} many {تمت أرشفة # محادثة} other {تمت أرشفة # محادثة}}",
+ "conversations_to_trash": "{count, plural, one {تم نقل محادثة واحدة إلى المهملات} two {تم نقل محادثتين إلى المهملات} few {تم نقل # محادثات إلى المهملات} many {تم نقل # محادثة إلى المهملات} other {تم نقل # محادثة إلى المهملات}}",
+ "conversations_deleted": "{count, plural, one {تم حذف محادثة واحدة} two {تم حذف محادثتين} few {تم حذف # محادثات} many {تم حذف # محادثة} other {تم حذف # محادثة}}",
+ "conversations_moved_to": "{count, plural, one {تم نقل محادثة واحدة إلى {mailbox}} two {تم نقل محادثتين إلى {mailbox}} few {تم نقل # محادثات إلى {mailbox}} many {تم نقل # محادثة إلى {mailbox}} other {تم نقل # محادثة إلى {mailbox}}}",
+ "conversations_moved": "{count, plural, one {تم نقل محادثة واحدة} two {تم نقل محادثتين} few {تم نقل # محادثات} many {تم نقل # محادثة} other {تم نقل # محادثة}}",
+ "select_all_in_folder": "{count, plural, one {تحديد المحادثة في {folder}} two {تحديد المحادثتين في {folder}} few {تحديد كل المحادثات الـ # في {folder}} many {تحديد كل المحادثات الـ # في {folder}} other {تحديد كل المحادثات الـ # في {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {تحديد الرسالة في {folder}} two {تحديد الرسالتين في {folder}} few {تحديد كل الرسائل الـ # في {folder}} many {تحديد كل الرسائل الـ # في {folder}} other {تحديد كل الرسائل الـ # في {folder}}}",
+ "select_all_conversations": "{count, plural, one {تحديد المحادثة} two {تحديد المحادثتين} few {تحديد كل المحادثات الـ #} many {تحديد كل المحادثات الـ #} other {تحديد كل المحادثات الـ #}}",
+ "select_all_emails": "{count, plural, one {تحديد الرسالة} two {تحديد الرسالتين} few {تحديد كل الرسائل الـ #} many {تحديد كل الرسائل الـ #} other {تحديد كل الرسائل الـ #}}",
+ "selecting_all": "جارٍ التحديد…"
+ },
"shared_tags": {
"title": "الوسوم المشتركة",
"description": "وسوم صناديق البريد الجماعية الخاصة بك. يرى كل من في المجموعة الأسماء والألوان نفسها على رسائلها.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "جدولة الإرسال",
- "schedule_send_description": "اختر متى يجب على الخادم إرسال هذه الرسالة.",
+ "schedule_send_description": "اختر متى يتم إرسالها.",
"schedule_preset": {
"this_morning": "هذا الصباح",
"this_afternoon": "بعد ظهر اليوم",
@@ -1163,6 +1175,7 @@
"edit_link": "تعديل الرابط",
"remove_link": "إزالة الرابط",
"link_address": "عنوان الرابط",
+ "link_text": "النص",
"link_invalid": "يجب ألا يحتوي العنوان على مسافات",
"table": "جدول",
"clear_formatting": "مسح التنسيق",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "لا يزال المجلد \"{name}\" يحتوي على {count, plural, one {رسالة واحدة} other {# رسائل}}. هل تريد حذف المجلد وكل رسائله؟ لا يمكن التراجع عن هذا.",
"delete_with_contents_confirm": "حذف المجلد والرسائل"
},
+ "mail_p2_folders": {
+ "all_folders": "كل المجلدات",
+ "empty_trash": "إفراغ المهملات",
+ "empty_spam": "إفراغ البريد العشوائي",
+ "empty_trash_confirm": "حذف {count, plural, zero {الرسائل} one {الرسالة} two {الرسالتين} few {كل الرسائل الـ#} many {كل الرسائل الـ#} other {كل الرسائل الـ#}} في المهملات نهائيًا؟ لا يمكن التراجع عن ذلك.",
+ "empty_spam_confirm": "حذف {count, plural, zero {الرسائل} one {الرسالة} two {الرسالتين} few {كل الرسائل الـ#} many {كل الرسائل الـ#} other {كل الرسائل الـ#}} في البريد العشوائي نهائيًا؟ لا يمكن التراجع عن ذلك.",
+ "title_all_accounts": "{name} ({count}، {total} في كل الحسابات)",
+ "title_all_accounts_only": "{name} ({total} في كل الحسابات)"
+ },
"uxs_folders": {
"manage_accounts": "إدارة الحسابات…",
"apply_to_existing": "{count, plural, one {تطبيق على رسالة موجودة واحدة في {folder}} other {تطبيق على # رسائل موجودة في {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, zero {عرض # رسالة أقدم} one {عرض رسالة أقدم واحدة} two {عرض رسالتين أقدم} few {عرض # رسائل أقدم} many {عرض # رسالة أقدم} other {عرض # رسالة أقدم}}",
"return_to_list_follows_layout": "ما لم تغيّره، يتبع هذا التخطيط: متوقف عندما تكون القائمة والرسالة جنبًا إلى جنب، ومفعّل في غير ذلك."
},
+ "mail_p2_conv": {
+ "show_message": "عرض الرسالة",
+ "back_to_reply": "العودة إلى ردك"
+ },
"identities": {
"modal_title": "إدارة هويات الإرسال",
"create_new": "إنشاء هوية جديدة",
@@ -4105,6 +4131,9 @@
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال",
"view": "عرض",
+ "template_saved": "تم حفظ القالب",
+ "schedule_anyway": "الجدولة على أي حال",
+ "schedule_warnings_title": "هل تريد جدولة هذه الرسالة على أي حال؟",
"picker_button": "إضافة من جهات الاتصال",
"picker_title": "إضافة مستلمين",
"tab_contacts": "جهات الاتصال",
@@ -5560,7 +5589,7 @@
"title": "مرحبًا بك في صندوق بريدك",
"tip_compose": "اضغط c لإنشاء رسالة جديدة",
"tip_shortcuts": "اضغط ؟ لعرض جميع اختصارات لوحة المفاتيح",
- "tip_sidebar": "ستجد التقويم وجهات الاتصال والإعدادات في قائمة الشريط الجانبي",
+ "tip_sidebar": "التقويم وجهات الاتصال والإعدادات في الشريط على اليمين",
"tip_settings": "خصّص تجربتك من الإعدادات",
"tip_compose_touch": "اضغط زر القلم لكتابة رسالة جديدة",
"tip_sidebar_touch": "ستجد التقويم وجهات الاتصال والإعدادات في الشريط السفلي",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "البحث في البريد عن «{text}»"
},
+ "mail_p2_desk": {
+ "reader_offline": "أنت غير متصل. تحتاج هذه الرسالة إلى اتصال لفتحها.",
+ "reader_failed": "تعذّر تحميل هذه الرسالة.",
+ "retry": "إعادة المحاولة",
+ "action_offline": "أنت غير متصل. يحتاج هذا إلى اتصال.",
+ "offline_bar": "أنت غير متصل. قراءة البريد وتغييره يحتاجان إلى اتصال.",
+ "offline_default_on": "يُحفظ بريدك على هذا الجهاز، لذا يمكنك قراءته دون اتصال."
+ },
"desktopMenuBar": {
"always_show_label": "إظهار شريط القوائم دائمًا",
"always_show_description": "وإلا فسيظهر عند الضغط على Alt.",
diff --git a/locales/ca/common.json b/locales/ca/common.json
index 54e0d1fba..663e91eb4 100644
--- a/locales/ca/common.json
+++ b/locales/ca/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Carpeta nova",
"new_subfolder_title": "Subcarpeta nova"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversa arxivada} other {# converses arxivades}}",
+ "conversations_to_trash": "{count, plural, one {# conversa moguda a la Paperera} other {# converses mogudes a la Paperera}}",
+ "conversations_deleted": "{count, plural, one {# conversa suprimida} other {# converses suprimides}}",
+ "conversations_moved_to": "{count, plural, one {# conversa moguda a {mailbox}} other {# converses mogudes a {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversa moguda} other {# converses mogudes}}",
+ "select_all_in_folder": "{count, plural, one {Selecciona la # conversa de {folder}} other {Selecciona les # converses de {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Selecciona el # correu de {folder}} other {Selecciona els # correus de {folder}}}",
+ "select_all_conversations": "{count, plural, one {Selecciona la # conversa} other {Selecciona les # converses}}",
+ "select_all_emails": "{count, plural, one {Selecciona el # correu} other {Selecciona els # correus}}",
+ "selecting_all": "S'està seleccionant…"
+ },
"shared_tags": {
"title": "Etiquetes compartides",
"description": "Etiquetes de les vostres bústies de grup. Tothom del grup veu els mateixos noms i colors al seu correu.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Programa l'enviament",
- "schedule_send_description": "Trieu quan ha d'alliberar el servidor aquest missatge.",
+ "schedule_send_description": "Tria quan s'ha d'enviar.",
"schedule_preset": {
"this_morning": "Aquest matí",
"this_afternoon": "Aquesta tarda",
@@ -1163,6 +1175,7 @@
"edit_link": "Edita l'enllaç",
"remove_link": "Treu l'enllaç",
"link_address": "Adreça de l'enllaç",
+ "link_text": "Text",
"link_invalid": "L'adreça no pot tenir espais",
"table": "Taula",
"clear_formatting": "Neteja el format",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "La carpeta \"{name}\" encara conté {count, plural, one {1 missatge} other {# missatges}}. Vols suprimir la carpeta i tots els seus missatges? No es pot desfer.",
"delete_with_contents_confirm": "Suprimeix la carpeta i els missatges"
},
+ "mail_p2_folders": {
+ "all_folders": "Totes les carpetes",
+ "empty_trash": "Buida la paperera",
+ "empty_spam": "Buida el correu brossa",
+ "empty_trash_confirm": "Vols suprimir per sempre {count, plural, one {el missatge} other {els # missatges}} de la paperera? No es pot desfer.",
+ "empty_spam_confirm": "Vols suprimir per sempre {count, plural, one {el missatge} other {els # missatges}} del correu brossa? No es pot desfer.",
+ "title_all_accounts": "{name} ({count}, {total} a tots els comptes)",
+ "title_all_accounts_only": "{name} ({total} a tots els comptes)"
+ },
"uxs_folders": {
"manage_accounts": "Gestiona els comptes…",
"apply_to_existing": "{count, plural, one {Aplica a # missatge existent de {folder}} other {Aplica a # missatges existents de {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Mostra # missatge anterior} other {Mostra # missatges anteriors}}",
"return_to_list_follows_layout": "Mentre no ho canviïs, segueix el disseny: desactivat quan la llista i el missatge són l'un al costat de l'altre, activat en els altres casos."
},
+ "mail_p2_conv": {
+ "show_message": "Mostra el missatge",
+ "back_to_reply": "Torna a la resposta"
+ },
"identities": {
"modal_title": "Gestiona les identitats d'enviament",
"create_new": "Crea una identitat nova",
@@ -4105,6 +4131,9 @@
"sending": "S'està enviant…",
"sent": "Enviat",
"view": "Mostra",
+ "template_saved": "Plantilla desada",
+ "schedule_anyway": "Programa igualment",
+ "schedule_warnings_title": "Vols programar aquest missatge igualment?",
"picker_button": "Afegeix des dels contactes",
"picker_title": "Afegeix destinataris",
"tab_contacts": "Contactes",
@@ -5560,7 +5589,7 @@
"title": "Us donem la benvinguda a la bústia",
"tip_compose": "Premeu c per redactar un correu nou",
"tip_shortcuts": "Premeu ? per veure totes les dreceres de teclat",
- "tip_sidebar": "Trobeu el Calendari, els Contactes i la Configuració al menú de la barra lateral",
+ "tip_sidebar": "Calendari, Contactes i Configuració són a la barra de l'esquerra",
"tip_settings": "Personalitzeu la vostra experiència a la Configuració",
"tip_compose_touch": "Toca el botó del llapis per escriure un correu nou",
"tip_sidebar_touch": "Trobaràs el Calendari, els Contactes i la Configuració a la barra inferior",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Cerca «{text}» al correu"
},
+ "mail_p2_desk": {
+ "reader_offline": "No tens connexió. Aquest missatge necessita connexió per obrir-se.",
+ "reader_failed": "No s'ha pogut carregar aquest missatge.",
+ "retry": "Torna-ho a provar",
+ "action_offline": "No tens connexió. Això necessita connexió.",
+ "offline_bar": "No tens connexió. Llegir i canviar el correu necessita connexió.",
+ "offline_default_on": "El teu correu es guarda en aquest dispositiu, així el pots llegir sense connexió."
+ },
"desktopMenuBar": {
"always_show_label": "Mostra sempre la barra de menús",
"always_show_description": "Si no, apareix en prémer Alt.",
diff --git a/locales/cs/common.json b/locales/cs/common.json
index fba0fac85..a7e8211ee 100644
--- a/locales/cs/common.json
+++ b/locales/cs/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nová složka",
"new_subfolder_title": "Nová podsložka"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# konverzace archivována} few {# konverzace archivovány} many {# konverzace archivováno} other {# konverzací archivováno}}",
+ "conversations_to_trash": "{count, plural, one {# konverzace přesunuta do koše} few {# konverzace přesunuty do koše} many {# konverzace přesunuto do koše} other {# konverzací přesunuto do koše}}",
+ "conversations_deleted": "{count, plural, one {# konverzace smazána} few {# konverzace smazány} many {# konverzace smazáno} other {# konverzací smazáno}}",
+ "conversations_moved_to": "{count, plural, one {# konverzace přesunuta do složky {mailbox}} few {# konverzace přesunuty do složky {mailbox}} many {# konverzace přesunuto do složky {mailbox}} other {# konverzací přesunuto do složky {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# konverzace přesunuta} few {# konverzace přesunuty} many {# konverzace přesunuto} other {# konverzací přesunuto}}",
+ "select_all_in_folder": "{count, plural, one {Vybrat # konverzaci ve složce {folder}} few {Vybrat všechny # konverzace ve složce {folder}} many {Vybrat všech # konverzace ve složce {folder}} other {Vybrat všech # konverzací ve složce {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Vybrat # zprávu ve složce {folder}} few {Vybrat všechny # zprávy ve složce {folder}} many {Vybrat všech # zprávy ve složce {folder}} other {Vybrat všech # zpráv ve složce {folder}}}",
+ "select_all_conversations": "{count, plural, one {Vybrat # konverzaci} few {Vybrat všechny # konverzace} many {Vybrat všech # konverzace} other {Vybrat všech # konverzací}}",
+ "select_all_emails": "{count, plural, one {Vybrat # zprávu} few {Vybrat všechny # zprávy} many {Vybrat všech # zprávy} other {Vybrat všech # zpráv}}",
+ "selecting_all": "Vybírání…"
+ },
"shared_tags": {
"title": "Sdílené štítky",
"description": "Štítky vašich skupinových schránek. Všichni ve skupině vidí u její pošty stejné názvy a barvy.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Zadejte URL",
"sending": "Odesílání...",
"schedule_send": "Naplánovat odeslání",
- "schedule_send_description": "Vyberte, kdy má server tuto zprávu odeslat.",
+ "schedule_send_description": "Vyberte, kdy se má odeslat.",
"schedule_preset": {
"this_morning": "Dnes ráno",
"this_afternoon": "Dnes odpoledne",
@@ -1163,6 +1175,7 @@
"edit_link": "Upravit odkaz",
"remove_link": "Odebrat odkaz",
"link_address": "Adresa odkazu",
+ "link_text": "Text",
"link_invalid": "Adresa nesmí obsahovat mezery",
"table": "Tabulka",
"clear_formatting": "Vymazat formátování",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Složka „{name}“ stále obsahuje {count, plural, one {1 zprávu} few {# zprávy} other {# zpráv}}. Smazat složku i všechny její zprávy? Tuto akci nelze vrátit.",
"delete_with_contents_confirm": "Smazat složku i zprávy"
},
+ "mail_p2_folders": {
+ "all_folders": "Všechny složky",
+ "empty_trash": "Vysypat koš",
+ "empty_spam": "Vyprázdnit spam",
+ "empty_trash_confirm": "Trvale smazat {count, plural, one {zprávu} few {všechny # zprávy} many {všech # zprávy} other {všech # zpráv}} v koši? Nelze to vrátit zpět.",
+ "empty_spam_confirm": "Trvale smazat {count, plural, one {zprávu} few {všechny # zprávy} many {všech # zprávy} other {všech # zpráv}} ve spamu? Nelze to vrátit zpět.",
+ "title_all_accounts": "{name} ({count}, {total} ve všech účtech)",
+ "title_all_accounts_only": "{name} ({total} ve všech účtech)"
+ },
"uxs_folders": {
"manage_accounts": "Spravovat účty…",
"apply_to_existing": "{count, plural, one {Použít na # existující zprávu ve složce {folder}} few {Použít na # existující zprávy ve složce {folder}} many {Použít na # existující zprávy ve složce {folder}} other {Použít na # existujících zpráv ve složce {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Zobrazit # starší zprávu} few {Zobrazit # starší zprávy} many {Zobrazit # starší zprávy} other {Zobrazit # starších zpráv}}",
"return_to_list_follows_layout": "Dokud to nezměníte, řídí se to rozvržením: vypnuto, když jsou seznam a zpráva vedle sebe, jinak zapnuto."
},
+ "mail_p2_conv": {
+ "show_message": "Zobrazit zprávu",
+ "back_to_reply": "Zpět k odpovědi"
+ },
"identities": {
"modal_title": "Správa identit odesílatele",
"create_new": "Vytvořit novou identitu",
@@ -4105,6 +4131,9 @@
"sending": "Odesílání…",
"sent": "Odesláno",
"view": "Zobrazit",
+ "template_saved": "Šablona uložena",
+ "schedule_anyway": "Přesto naplánovat",
+ "schedule_warnings_title": "Přesto tuto zprávu naplánovat?",
"picker_button": "Přidat z kontaktů",
"picker_title": "Přidat příjemce",
"tab_contacts": "Kontakty",
@@ -5298,7 +5327,7 @@
"title": "Vítejte ve své poštovní schránce",
"tip_compose": "Stiskněte c pro napsání nové zprávy",
"tip_shortcuts": "Stiskněte ? pro zobrazení všech klávesových zkratek",
- "tip_sidebar": "Kalendář, Kontakty a Nastavení najdete v postranním panelu",
+ "tip_sidebar": "Kalendář, Kontakty a Nastavení najdete na liště vlevo",
"tip_settings": "Přizpůsobte si aplikaci v Nastavení",
"tip_compose_touch": "Klepnutím na tlačítko s tužkou napíšete nový e-mail",
"tip_sidebar_touch": "Kalendář, Kontakty a Nastavení najdete na liště dole",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Hledat v poště „{text}“"
},
+ "mail_p2_desk": {
+ "reader_offline": "Jste offline. Tato zpráva se otevře jen s připojením.",
+ "reader_failed": "Tuto zprávu se nepodařilo načíst.",
+ "retry": "Zkusit znovu",
+ "action_offline": "Jste offline. K tomu je potřeba připojení.",
+ "offline_bar": "Jste offline. Ke čtení a úpravám pošty je potřeba připojení.",
+ "offline_default_on": "Vaše pošta je uložena v tomto zařízení, takže ji můžete číst offline."
+ },
"desktopMenuBar": {
"always_show_label": "Vždy zobrazovat řádek nabídek",
"always_show_description": "Jinak se zobrazí po stisknutí klávesy Alt.",
diff --git a/locales/da/common.json b/locales/da/common.json
index f60bfb332..01e4c6686 100644
--- a/locales/da/common.json
+++ b/locales/da/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Ny mappe",
"new_subfolder_title": "Ny undermappe"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# samtale arkiveret} other {# samtaler arkiveret}}",
+ "conversations_to_trash": "{count, plural, one {# samtale flyttet til papirkurven} other {# samtaler flyttet til papirkurven}}",
+ "conversations_deleted": "{count, plural, one {# samtale slettet} other {# samtaler slettet}}",
+ "conversations_moved_to": "{count, plural, one {# samtale flyttet til {mailbox}} other {# samtaler flyttet til {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# samtale flyttet} other {# samtaler flyttet}}",
+ "select_all_in_folder": "{count, plural, one {Vælg den # samtale i {folder}} other {Vælg alle # samtaler i {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Vælg den # e-mail i {folder}} other {Vælg alle # e-mails i {folder}}}",
+ "select_all_conversations": "{count, plural, one {Vælg den # samtale} other {Vælg alle # samtaler}}",
+ "select_all_emails": "{count, plural, one {Vælg den # e-mail} other {Vælg alle # e-mails}}",
+ "selecting_all": "Vælger…"
+ },
"shared_tags": {
"title": "Delte tags",
"description": "Tags i dine gruppepostkasser. Alle i gruppen ser de samme navne og farver på dens mail.",
@@ -1112,7 +1124,7 @@
},
"link_url_prompt": "Indtast URL'en",
"schedule_send": "Planlæg afsendelse",
- "schedule_send_description": "Vælg hvornår serveren skal frigive denne besked.",
+ "schedule_send_description": "Vælg hvornår den skal sendes.",
"schedule_preset": {
"this_morning": "I dag formiddag",
"this_afternoon": "I eftermiddag",
@@ -1163,6 +1175,7 @@
"edit_link": "Rediger link",
"remove_link": "Fjern link",
"link_address": "Linkadresse",
+ "link_text": "Tekst",
"link_invalid": "Adressen må ikke indeholde mellemrum",
"table": "Tabel",
"clear_formatting": "Ryd formatering",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Mappen \"{name}\" indeholder stadig {count, plural, one {1 besked} other {# beskeder}}. Slet mappen og alle dens beskeder? Dette kan ikke fortrydes.",
"delete_with_contents_confirm": "Slet mappe og beskeder"
},
+ "mail_p2_folders": {
+ "all_folders": "Alle mapper",
+ "empty_trash": "Tøm papirkurven",
+ "empty_spam": "Tøm spam",
+ "empty_trash_confirm": "Slet {count, plural, one {beskeden} other {alle # beskeder}} i papirkurven for altid? Det kan ikke fortrydes.",
+ "empty_spam_confirm": "Slet {count, plural, one {beskeden} other {alle # beskeder}} i spam for altid? Det kan ikke fortrydes.",
+ "title_all_accounts": "{name} ({count}, {total} i alle konti)",
+ "title_all_accounts_only": "{name} ({total} i alle konti)"
+ },
"uxs_folders": {
"manage_accounts": "Administrer konti…",
"apply_to_existing": "{count, plural, one {Anvend på # eksisterende besked i {folder}} other {Anvend på # eksisterende beskeder i {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Vis # ældre besked} other {Vis # ældre beskeder}}",
"return_to_list_follows_layout": "Indtil du ændrer det, følger det layoutet: fra, når listen og beskeden står side om side, ellers til."
},
+ "mail_p2_conv": {
+ "show_message": "Vis beskeden",
+ "back_to_reply": "Tilbage til dit svar"
+ },
"identities": {
"modal_title": "Administrér afsenderidentiteter",
"create_new": "Opret ny identitet",
@@ -4105,6 +4131,9 @@
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
+ "template_saved": "Skabelon gemt",
+ "schedule_anyway": "Planlæg alligevel",
+ "schedule_warnings_title": "Planlæg denne besked alligevel?",
"picker_button": "Tilføj fra kontakter",
"picker_title": "Tilføj modtagere",
"tab_contacts": "Kontakter",
@@ -5560,7 +5589,7 @@
"title": "Velkommen til din postkasse",
"tip_compose": "Tryk c for at skrive en ny e-mail",
"tip_shortcuts": "Tryk ? for at se alle tastaturgenveje",
- "tip_sidebar": "Find Kalender, Kontakter og Indstillinger i sidepanelets menu",
+ "tip_sidebar": "Kalender, Kontakter og Indstillinger findes i bjælken til venstre",
"tip_settings": "Tilpas din oplevelse i Indstillinger",
"tip_compose_touch": "Tryk på blyantknappen for at skrive en ny e-mail",
"tip_sidebar_touch": "Kalender, Kontakter og Indstillinger finder du i bjælken nederst",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Søg efter “{text}” i mail"
},
+ "mail_p2_desk": {
+ "reader_offline": "Du er offline. Denne besked kræver forbindelse for at åbne.",
+ "reader_failed": "Beskeden kunne ikke indlæses.",
+ "retry": "Prøv igen",
+ "action_offline": "Du er offline. Det kræver forbindelse.",
+ "offline_bar": "Du er offline. Læsning og ændring af mail kræver forbindelse.",
+ "offline_default_on": "Din mail gemmes på denne enhed, så du kan læse den offline."
+ },
"desktopMenuBar": {
"always_show_label": "Vis altid menulinjen",
"always_show_description": "Ellers vises den, når du trykker på Alt.",
diff --git a/locales/de/common.json b/locales/de/common.json
index ed063e582..7ac6799b1 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Neuer Ordner",
"new_subfolder_title": "Neuer Unterordner"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# Unterhaltung archiviert} other {# Unterhaltungen archiviert}}",
+ "conversations_to_trash": "{count, plural, one {# Unterhaltung in den Papierkorb verschoben} other {# Unterhaltungen in den Papierkorb verschoben}}",
+ "conversations_deleted": "{count, plural, one {# Unterhaltung gelöscht} other {# Unterhaltungen gelöscht}}",
+ "conversations_moved_to": "{count, plural, one {# Unterhaltung nach {mailbox} verschoben} other {# Unterhaltungen nach {mailbox} verschoben}}",
+ "conversations_moved": "{count, plural, one {# Unterhaltung verschoben} other {# Unterhaltungen verschoben}}",
+ "select_all_in_folder": "{count, plural, one {Die # Unterhaltung in {folder} auswählen} other {Alle # Unterhaltungen in {folder} auswählen}}",
+ "select_all_emails_in_folder": "{count, plural, one {Die # E-Mail in {folder} auswählen} other {Alle # E-Mails in {folder} auswählen}}",
+ "select_all_conversations": "{count, plural, one {Die # Unterhaltung auswählen} other {Alle # Unterhaltungen auswählen}}",
+ "select_all_emails": "{count, plural, one {Die # E-Mail auswählen} other {Alle # E-Mails auswählen}}",
+ "selecting_all": "Wird ausgewählt…"
+ },
"shared_tags": {
"title": "Geteilte Labels",
"description": "Labels Ihrer Gruppenpostfächer. Alle in der Gruppe sehen dieselben Namen und Farben an ihren Nachrichten.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "URL eingeben",
"sending": "Wird gesendet...",
"schedule_send": "Senden planen",
- "schedule_send_description": "Wählen Sie, wann der Server diese Nachricht freigeben soll.",
+ "schedule_send_description": "Wählen Sie, wann gesendet werden soll.",
"schedule_preset": {
"this_morning": "Heute früh",
"this_afternoon": "Heute Nachmittag",
@@ -1163,6 +1175,7 @@
"edit_link": "Link bearbeiten",
"remove_link": "Link entfernen",
"link_address": "Linkadresse",
+ "link_text": "Text",
"link_invalid": "Die Adresse darf keine Leerzeichen enthalten",
"table": "Tabelle",
"clear_formatting": "Formatierung löschen",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Der Ordner „{name}“ enthält noch {count, plural, one {1 Nachricht} other {# Nachrichten}}. Ordner samt allen Nachrichten löschen? Das kann nicht rückgängig gemacht werden.",
"delete_with_contents_confirm": "Ordner und Nachrichten löschen"
},
+ "mail_p2_folders": {
+ "all_folders": "Alle Ordner",
+ "empty_trash": "Papierkorb leeren",
+ "empty_spam": "Spam leeren",
+ "empty_trash_confirm": "{count, plural, one {Die Nachricht} other {Alle # Nachrichten}} im Papierkorb endgültig löschen? Das lässt sich nicht rückgängig machen.",
+ "empty_spam_confirm": "{count, plural, one {Die Nachricht} other {Alle # Nachrichten}} im Spam endgültig löschen? Das lässt sich nicht rückgängig machen.",
+ "title_all_accounts": "{name} ({count}, {total} in allen Konten)",
+ "title_all_accounts_only": "{name} ({total} in allen Konten)"
+ },
"uxs_folders": {
"manage_accounts": "Konten verwalten…",
"apply_to_existing": "{count, plural, one {Auf # vorhandene Nachricht in {folder} anwenden} other {Auf # vorhandene Nachrichten in {folder} anwenden}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {# ältere Nachricht anzeigen} other {# ältere Nachrichten anzeigen}}",
"return_to_list_follows_layout": "Solange Sie es nicht ändern, richtet sich das nach dem Layout: aus, wenn Liste und Nachricht nebeneinander stehen, sonst an."
},
+ "mail_p2_conv": {
+ "show_message": "Nachricht anzeigen",
+ "back_to_reply": "Zurück zur Antwort"
+ },
"identities": {
"modal_title": "Sendeidentitäten verwalten",
"create_new": "Neue Identität erstellen",
@@ -4105,6 +4131,9 @@
"sending": "Wird gesendet…",
"sent": "Gesendet",
"view": "Anzeigen",
+ "template_saved": "Vorlage gespeichert",
+ "schedule_anyway": "Trotzdem planen",
+ "schedule_warnings_title": "Diese Nachricht trotzdem planen?",
"picker_button": "Aus Kontakten hinzufügen",
"picker_title": "Empfänger hinzufügen",
"tab_contacts": "Kontakte",
@@ -5298,7 +5327,7 @@
"title": "Willkommen in Ihrem Postfach",
"tip_compose": "Drücken Sie c, um eine neue E-Mail zu verfassen",
"tip_shortcuts": "Drücken Sie ?, um alle Tastaturkürzel anzuzeigen",
- "tip_sidebar": "Finden Sie Kalender, Kontakte und Einstellungen im Seitenmenü",
+ "tip_sidebar": "Kalender, Kontakte und Einstellungen finden Sie in der Leiste links",
"tip_settings": "Passen Sie Ihre Erfahrung in den Einstellungen an",
"tip_compose_touch": "Tippen Sie auf den Stift, um eine neue E-Mail zu schreiben",
"tip_sidebar_touch": "Kalender, Kontakte und Einstellungen finden Sie in der Leiste unten",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "E-Mails nach „{text}“ durchsuchen"
},
+ "mail_p2_desk": {
+ "reader_offline": "Sie sind offline. Diese Nachricht lässt sich nur mit Verbindung öffnen.",
+ "reader_failed": "Diese Nachricht konnte nicht geladen werden.",
+ "retry": "Erneut versuchen",
+ "action_offline": "Sie sind offline. Dafür braucht es eine Verbindung.",
+ "offline_bar": "Sie sind offline. Zum Lesen und Ändern von E-Mails braucht es eine Verbindung.",
+ "offline_default_on": "Ihre E-Mails werden auf diesem Gerät gespeichert, damit Sie sie auch offline lesen können."
+ },
"desktopMenuBar": {
"always_show_label": "Menüleiste immer anzeigen",
"always_show_description": "Sonst erscheint sie, wenn Sie Alt drücken.",
diff --git a/locales/en/common.json b/locales/en/common.json
index 33466a43c..42e828a2c 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "New folder",
"new_subfolder_title": "New subfolder"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversation archived} other {# conversations archived}}",
+ "conversations_to_trash": "{count, plural, one {# conversation moved to Trash} other {# conversations moved to Trash}}",
+ "conversations_deleted": "{count, plural, one {# conversation deleted} other {# conversations deleted}}",
+ "conversations_moved_to": "{count, plural, one {# conversation moved to {mailbox}} other {# conversations moved to {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversation moved} other {# conversations moved}}",
+ "select_all_in_folder": "{count, plural, one {Select the # conversation in {folder}} other {Select all # conversations in {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Select the # email in {folder}} other {Select all # emails in {folder}}}",
+ "select_all_conversations": "{count, plural, one {Select the # conversation} other {Select all # conversations}}",
+ "select_all_emails": "{count, plural, one {Select the # email} other {Select all # emails}}",
+ "selecting_all": "Selecting…"
+ },
"shared_tags": {
"title": "Shared tags",
"description": "Tags of your group mailboxes. Everyone in the group sees the same names and colors on its mail.",
@@ -1114,7 +1126,7 @@
"unfinished": ["TODO", "TBD", "TBA", "TBC", "FIXME", "XXX"]
},
"schedule_send": "Schedule send",
- "schedule_send_description": "Choose when the server should release this message.",
+ "schedule_send_description": "Pick when to send it.",
"schedule_preset": {
"this_morning": "This morning",
"this_afternoon": "This afternoon",
@@ -1165,6 +1177,7 @@
"edit_link": "Edit link",
"remove_link": "Remove link",
"link_address": "Link address",
+ "link_text": "Text",
"link_invalid": "The address can't contain spaces",
"table": "Table",
"clear_formatting": "Clear formatting",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "The folder \"{name}\" still contains {count, plural, one {1 message} other {# messages}}. Delete the folder and all of its messages? This cannot be undone.",
"delete_with_contents_confirm": "Delete folder and messages"
},
+ "mail_p2_folders": {
+ "all_folders": "All folders",
+ "empty_trash": "Empty Trash",
+ "empty_spam": "Empty Spam",
+ "empty_trash_confirm": "Delete {count, plural, one {the message} other {all # messages}} in Trash forever? You can't undo this.",
+ "empty_spam_confirm": "Delete {count, plural, one {the message} other {all # messages}} in Spam forever? You can't undo this.",
+ "title_all_accounts": "{name} ({count}, {total} in all accounts)",
+ "title_all_accounts_only": "{name} ({total} in all accounts)"
+ },
"uxs_folders": {
"manage_accounts": "Manage accounts…",
"apply_to_existing": "{count, plural, one {Apply to # existing message in {folder}} other {Apply to # existing messages in {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Show # older message} other {Show # older messages}}",
"return_to_list_follows_layout": "Until you change it, this follows the layout: off when the list and the message are side by side, on otherwise."
},
+ "mail_p2_conv": {
+ "show_message": "Show the message",
+ "back_to_reply": "Back to your reply"
+ },
"identities": {
"modal_title": "Manage sending identities",
"create_new": "New identity",
@@ -4105,6 +4131,9 @@
"sending": "Sending…",
"sent": "Sent",
"view": "View",
+ "template_saved": "Template saved",
+ "schedule_anyway": "Schedule anyway",
+ "schedule_warnings_title": "Schedule this message anyway?",
"picker_button": "Add from contacts",
"picker_title": "Add recipients",
"tab_contacts": "Contacts",
@@ -5560,7 +5589,7 @@
"title": "Welcome to your mailbox",
"tip_compose": "Press c to compose a new email",
"tip_shortcuts": "Press ? to see all keyboard shortcuts",
- "tip_sidebar": "Find Calendar, Contacts and Settings in the sidebar menu",
+ "tip_sidebar": "Calendar, Contacts and Settings are in the bar on the left",
"tip_settings": "Customize your experience in Settings",
"tip_compose_touch": "Tap the pencil button to write a new email",
"tip_sidebar_touch": "Find Calendar, Contacts and Settings in the bar at the bottom",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Search mail for \"{text}\""
},
+ "mail_p2_desk": {
+ "reader_offline": "You're offline. This message needs a connection to open.",
+ "reader_failed": "This message couldn't be loaded.",
+ "retry": "Try again",
+ "action_offline": "You're offline. This needs a connection.",
+ "offline_bar": "You're offline. Reading and changing mail needs a connection.",
+ "offline_default_on": "Your mail is kept on this device, so you can read it offline."
+ },
"desktopMenuBar": {
"always_show_label": "Always show the menu bar",
"always_show_description": "Otherwise it shows when you press Alt.",
diff --git a/locales/es/common.json b/locales/es/common.json
index 9550ee59b..a17023539 100644
--- a/locales/es/common.json
+++ b/locales/es/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nueva carpeta",
"new_subfolder_title": "Nueva subcarpeta"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversación archivada} other {# conversaciones archivadas}}",
+ "conversations_to_trash": "{count, plural, one {# conversación movida a la papelera} other {# conversaciones movidas a la papelera}}",
+ "conversations_deleted": "{count, plural, one {# conversación eliminada} other {# conversaciones eliminadas}}",
+ "conversations_moved_to": "{count, plural, one {# conversación movida a {mailbox}} other {# conversaciones movidas a {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversación movida} other {# conversaciones movidas}}",
+ "select_all_in_folder": "{count, plural, one {Seleccionar la # conversación de {folder}} other {Seleccionar las # conversaciones de {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Seleccionar el # correo de {folder}} other {Seleccionar los # correos de {folder}}}",
+ "select_all_conversations": "{count, plural, one {Seleccionar la # conversación} other {Seleccionar las # conversaciones}}",
+ "select_all_emails": "{count, plural, one {Seleccionar el # correo} other {Seleccionar los # correos}}",
+ "selecting_all": "Seleccionando…"
+ },
"shared_tags": {
"title": "Etiquetas compartidas",
"description": "Etiquetas de tus buzones de grupo. Todos en el grupo ven los mismos nombres y colores en su correo.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Introduce la URL",
"sending": "Enviando...",
"schedule_send": "Programar envío",
- "schedule_send_description": "Elige cuándo debe liberar el servidor este mensaje.",
+ "schedule_send_description": "Elige cuándo enviarlo.",
"schedule_preset": {
"this_morning": "Esta mañana",
"this_afternoon": "Esta tarde",
@@ -1163,6 +1175,7 @@
"edit_link": "Editar enlace",
"remove_link": "Quitar enlace",
"link_address": "Dirección del enlace",
+ "link_text": "Texto",
"link_invalid": "La dirección no puede contener espacios",
"table": "Tabla",
"clear_formatting": "Borrar formato",
@@ -3881,6 +3894,15 @@
"create": "Crear",
"rename_confirm": "Renombrar"
},
+ "mail_p2_folders": {
+ "all_folders": "Todas las carpetas",
+ "empty_trash": "Vaciar papelera",
+ "empty_spam": "Vaciar spam",
+ "empty_trash_confirm": "¿Eliminar para siempre {count, plural, one {el mensaje} other {los # mensajes}} de la papelera? No se puede deshacer.",
+ "empty_spam_confirm": "¿Eliminar para siempre {count, plural, one {el mensaje} other {los # mensajes}} de spam? No se puede deshacer.",
+ "title_all_accounts": "{name} ({count}, {total} en todas las cuentas)",
+ "title_all_accounts_only": "{name} ({total} en todas las cuentas)"
+ },
"uxs_folders": {
"manage_accounts": "Gestionar cuentas…",
"apply_to_existing": "{count, plural, one {Aplicar a # mensaje existente en {folder}} other {Aplicar a # mensajes existentes en {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Mostrar # mensaje anterior} other {Mostrar # mensajes anteriores}}",
"return_to_list_follows_layout": "Mientras no lo cambies, sigue el diseño: desactivado cuando la lista y el mensaje están uno al lado del otro, activado en los demás casos."
},
+ "mail_p2_conv": {
+ "show_message": "Mostrar el mensaje",
+ "back_to_reply": "Volver a tu respuesta"
+ },
"identities": {
"modal_title": "Administrar Identidades de Envío",
"create_new": "Crear Nueva Identidad",
@@ -4105,6 +4131,9 @@
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
+ "template_saved": "Plantilla guardada",
+ "schedule_anyway": "Programar de todos modos",
+ "schedule_warnings_title": "¿Programar este mensaje de todos modos?",
"picker_button": "Añadir desde contactos",
"picker_title": "Añadir destinatarios",
"tab_contacts": "Contactos",
@@ -5298,7 +5327,7 @@
"title": "Bienvenido a tu buzón",
"tip_compose": "Pulsa c para redactar un nuevo correo",
"tip_shortcuts": "Pulsa ? para ver todos los atajos de teclado",
- "tip_sidebar": "Encuentra Calendario, Contactos y Ajustes en el menú lateral",
+ "tip_sidebar": "Calendario, Contactos y Ajustes están en la barra de la izquierda",
"tip_settings": "Personaliza tu experiencia en Ajustes",
"tip_compose_touch": "Toca el botón del lápiz para escribir un correo nuevo",
"tip_sidebar_touch": "Encontrarás Calendario, Contactos y Ajustes en la barra inferior",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Buscar «{text}» en el correo"
},
+ "mail_p2_desk": {
+ "reader_offline": "No tienes conexión. Este mensaje necesita conexión para abrirse.",
+ "reader_failed": "No se ha podido cargar este mensaje.",
+ "retry": "Reintentar",
+ "action_offline": "No tienes conexión. Esto necesita conexión.",
+ "offline_bar": "No tienes conexión. Leer y cambiar el correo necesita conexión.",
+ "offline_default_on": "Tu correo se guarda en este dispositivo, así puedes leerlo sin conexión."
+ },
"desktopMenuBar": {
"always_show_label": "Mostrar siempre la barra de menús",
"always_show_description": "Si no, aparece al pulsar Alt.",
diff --git a/locales/et/common.json b/locales/et/common.json
index b748649b7..c2f6ab243 100644
--- a/locales/et/common.json
+++ b/locales/et/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Uus kaust",
"new_subfolder_title": "Uus alamkaust"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# vestlus arhiveeritud} other {# vestlust arhiveeritud}}",
+ "conversations_to_trash": "{count, plural, one {# vestlus liigutati prügikasti} other {# vestlust liigutati prügikasti}}",
+ "conversations_deleted": "{count, plural, one {# vestlus kustutatud} other {# vestlust kustutatud}}",
+ "conversations_moved_to": "{count, plural, one {# vestlus liigutati kausta {mailbox}} other {# vestlust liigutati kausta {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# vestlus liigutatud} other {# vestlust liigutatud}}",
+ "select_all_in_folder": "{count, plural, one {Vali # vestlus kaustas {folder}} other {Vali kõik # vestlust kaustas {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Vali # e-kiri kaustas {folder}} other {Vali kõik # e-kirja kaustas {folder}}}",
+ "select_all_conversations": "{count, plural, one {Vali # vestlus} other {Vali kõik # vestlust}}",
+ "select_all_emails": "{count, plural, one {Vali # e-kiri} other {Vali kõik # e-kirja}}",
+ "selecting_all": "Valimine…"
+ },
"shared_tags": {
"title": "Jagatud sildid",
"description": "Sinu grupipostkastide sildid. Kõik grupis näevad selle kirjadel samu nimesid ja värve.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Ajasta saatmine",
- "schedule_send_description": "Vali, millal server peaks selle sõnumi välja saatma.",
+ "schedule_send_description": "Vali, millal see saata.",
"schedule_preset": {
"this_morning": "Täna hommikul",
"this_afternoon": "Täna pärastlõunal",
@@ -1163,6 +1175,7 @@
"edit_link": "Muuda linki",
"remove_link": "Eemalda link",
"link_address": "Lingi aadress",
+ "link_text": "Tekst",
"link_invalid": "Aadressis ei tohi olla tühikuid",
"table": "Tabel",
"clear_formatting": "Eemalda vormindus",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Kaust \"{name}\" sisaldab veel {count, plural, one {1 sõnum} other {# sõnumit}}. Kas kustutada kaust ja kõik selle sõnumid? Seda ei saa tagasi võtta.",
"delete_with_contents_confirm": "Kustuta kaust ja sõnumid"
},
+ "mail_p2_folders": {
+ "all_folders": "Kõik kaustad",
+ "empty_trash": "Tühjenda prügikast",
+ "empty_spam": "Tühjenda rämpspost",
+ "empty_trash_confirm": "Kas kustutada prügikastist {count, plural, one {kiri} other {kõik # kirja}} jäädavalt? Seda ei saa tagasi võtta.",
+ "empty_spam_confirm": "Kas kustutada rämpspostist {count, plural, one {kiri} other {kõik # kirja}} jäädavalt? Seda ei saa tagasi võtta.",
+ "title_all_accounts": "{name} ({count}, {total} kõigil kontodel)",
+ "title_all_accounts_only": "{name} ({total} kõigil kontodel)"
+ },
"uxs_folders": {
"manage_accounts": "Halda kontosid…",
"apply_to_existing": "{count, plural, one {Rakenda # olemasolevale kirjale kaustas {folder}} other {Rakenda # olemasolevale kirjale kaustas {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Näita # vanemat sõnumit} other {Näita # vanemat sõnumit}}",
"return_to_list_follows_layout": "Kuni te seda ei muuda, järgib see paigutust: väljas, kui loend ja sõnum on kõrvuti, muidu sees."
},
+ "mail_p2_conv": {
+ "show_message": "Näita kirja",
+ "back_to_reply": "Tagasi vastuse juurde"
+ },
"identities": {
"modal_title": "Halda saatja identiteete",
"create_new": "Loo uus identiteet",
@@ -4105,6 +4131,9 @@
"sending": "Saatmine…",
"sent": "Saadetud",
"view": "Vaata",
+ "template_saved": "Mall salvestatud",
+ "schedule_anyway": "Ajasta ikkagi",
+ "schedule_warnings_title": "Kas ajastada see kiri ikkagi?",
"picker_button": "Lisa kontaktidest",
"picker_title": "Lisa saajaid",
"tab_contacts": "Kontaktid",
@@ -5560,7 +5589,7 @@
"title": "Tere tulemast sinu postkasti",
"tip_compose": "Vajuta c uue e-kirja kirjutamiseks",
"tip_shortcuts": "Vajuta ? kõigi kiirklahvide nägemiseks",
- "tip_sidebar": "Kalender, Kontaktid ja Seaded leiad külgmenüüst",
+ "tip_sidebar": "Kalender, Kontaktid ja Seaded on vasakul ribal",
"tip_settings": "Kohanda oma kogemust seadetes",
"tip_compose_touch": "Uue kirja kirjutamiseks puuduta pliiatsinuppu",
"tip_sidebar_touch": "Kalender, kontaktid ja seaded asuvad alumisel ribal",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Otsi kirjadest „{text}”"
},
+ "mail_p2_desk": {
+ "reader_offline": "Oled võrguühenduseta. Selle kirja avamiseks on vaja ühendust.",
+ "reader_failed": "Seda kirja ei saanud laadida.",
+ "retry": "Proovi uuesti",
+ "action_offline": "Oled võrguühenduseta. Selleks on vaja ühendust.",
+ "offline_bar": "Oled võrguühenduseta. Kirjade lugemiseks ja muutmiseks on vaja ühendust.",
+ "offline_default_on": "Sinu kirjad hoitakse selles seadmes, nii et saad neid ka ühenduseta lugeda."
+ },
"desktopMenuBar": {
"always_show_label": "Näita menüüriba alati",
"always_show_description": "Muidu ilmub see Alt-klahvi vajutamisel.",
diff --git a/locales/fa/common.json b/locales/fa/common.json
index 700e074fe..017f48419 100644
--- a/locales/fa/common.json
+++ b/locales/fa/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "پوشه جدید",
"new_subfolder_title": "زیرپوشه جدید"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# مکالمه بایگانی شد} other {# مکالمه بایگانی شد}}",
+ "conversations_to_trash": "{count, plural, one {# مکالمه به زبالهدان منتقل شد} other {# مکالمه به زبالهدان منتقل شد}}",
+ "conversations_deleted": "{count, plural, one {# مکالمه حذف شد} other {# مکالمه حذف شد}}",
+ "conversations_moved_to": "{count, plural, one {# مکالمه به {mailbox} منتقل شد} other {# مکالمه به {mailbox} منتقل شد}}",
+ "conversations_moved": "{count, plural, one {# مکالمه منتقل شد} other {# مکالمه منتقل شد}}",
+ "select_all_in_folder": "{count, plural, one {انتخاب # مکالمه در {folder}} other {انتخاب همه # مکالمه در {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {انتخاب # ایمیل در {folder}} other {انتخاب همه # ایمیل در {folder}}}",
+ "select_all_conversations": "{count, plural, one {انتخاب # مکالمه} other {انتخاب همه # مکالمه}}",
+ "select_all_emails": "{count, plural, one {انتخاب # ایمیل} other {انتخاب همه # ایمیل}}",
+ "selecting_all": "در حال انتخاب…"
+ },
"shared_tags": {
"title": "برچسبهای مشترک",
"description": "برچسبهای صندوقهای گروهی شما. همهٔ اعضای گروه نامها و رنگهای یکسانی روی ایمیلهای آن میبینند.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "زمانبندی ارسال",
- "schedule_send_description": "زمان انتشار پیام توسط سرور را انتخاب کنید.",
+ "schedule_send_description": "زمان ارسال را انتخاب کنید.",
"schedule_preset": {
"this_morning": "امروز صبح",
"this_afternoon": "امروز بعدازظهر",
@@ -1163,6 +1175,7 @@
"edit_link": "ویرایش پیوند",
"remove_link": "حذف پیوند",
"link_address": "نشانی پیوند",
+ "link_text": "متن",
"link_invalid": "نشانی نباید فاصله داشته باشد",
"table": "جدول",
"clear_formatting": "پاک کردن قالببندی",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "پوشه «{name}» هنوز شامل {count, plural, one {۱ پیام} other {# پیام}} است. پوشه و همه پیامهای آن حذف شود؟ این عمل قابل بازگشت نیست.",
"delete_with_contents_confirm": "حذف پوشه و پیامها"
},
+ "mail_p2_folders": {
+ "all_folders": "همه پوشهها",
+ "empty_trash": "خالی کردن زبالهدان",
+ "empty_spam": "خالی کردن هرزنامه",
+ "empty_trash_confirm": "{count, plural, one {پیام} other {همه # پیام}} زبالهدان برای همیشه حذف شود؟ این کار برگشتپذیر نیست.",
+ "empty_spam_confirm": "{count, plural, one {پیام} other {همه # پیام}} هرزنامه برای همیشه حذف شود؟ این کار برگشتپذیر نیست.",
+ "title_all_accounts": "{name} ({count}، {total} در همه حسابها)",
+ "title_all_accounts_only": "{name} ({total} در همه حسابها)"
+ },
"uxs_folders": {
"manage_accounts": "مدیریت حسابها…",
"apply_to_existing": "{count, plural, one {اعمال روی # پیام موجود در {folder}} other {اعمال روی # پیام موجود در {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {نمایش # پیام قدیمیتر} other {نمایش # پیام قدیمیتر}}",
"return_to_list_follows_layout": "تا وقتی آن را تغییر ندهید، از چیدمان پیروی میکند: وقتی فهرست و پیام کنار هم هستند خاموش، در غیر این صورت روشن."
},
+ "mail_p2_conv": {
+ "show_message": "نمایش پیام",
+ "back_to_reply": "بازگشت به پاسخ شما"
+ },
"identities": {
"modal_title": "مدیریت هویتهای ارسال",
"create_new": "ایجاد هویت جدید",
@@ -4105,6 +4131,9 @@
"sending": "در حال ارسال…",
"sent": "ارسال شد",
"view": "مشاهده",
+ "template_saved": "الگو ذخیره شد",
+ "schedule_anyway": "زمانبندی به هر حال",
+ "schedule_warnings_title": "این پیام به هر حال زمانبندی شود؟",
"picker_button": "افزودن از مخاطبین",
"picker_title": "افزودن گیرندگان",
"tab_contacts": "مخاطبین",
@@ -5560,7 +5589,7 @@
"title": "به صندوق پستی خود خوش آمدید",
"tip_compose": "کلید c را برای ایمیل جدید فشار دهید",
"tip_shortcuts": "کلید ? را برای مشاهده میانبرها فشار دهید",
- "tip_sidebar": "تقویم، مخاطبین و تنظیمات در نوار کناری",
+ "tip_sidebar": "تقویم، مخاطبان و تنظیمات در نوار سمت راست هستند",
"tip_settings": "تجربه خود را در تنظیمات سفارشی کنید",
"tip_compose_touch": "برای نوشتن ایمیل جدید روی دکمه مداد بزنید",
"tip_sidebar_touch": "تقویم، مخاطبین و تنظیمات در نوار پایین هستند",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "جستجوی «{text}» در نامهها"
},
+ "mail_p2_desk": {
+ "reader_offline": "آفلاین هستید. برای باز کردن این پیام اتصال لازم است.",
+ "reader_failed": "این پیام بارگیری نشد.",
+ "retry": "تلاش دوباره",
+ "action_offline": "آفلاین هستید. برای این کار اتصال لازم است.",
+ "offline_bar": "آفلاین هستید. برای خواندن و تغییر ایمیل اتصال لازم است.",
+ "offline_default_on": "ایمیلهای شما روی این دستگاه نگه داشته میشود تا آفلاین هم بخوانید."
+ },
"desktopMenuBar": {
"always_show_label": "همیشه نوار منو نمایش داده شود",
"always_show_description": "در غیر این صورت با فشردن Alt نمایش داده میشود.",
diff --git a/locales/fr/common.json b/locales/fr/common.json
index 248be0cdd..f8a48670f 100644
--- a/locales/fr/common.json
+++ b/locales/fr/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nouveau dossier",
"new_subfolder_title": "Nouveau sous-dossier"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversation archivée} other {# conversations archivées}}",
+ "conversations_to_trash": "{count, plural, one {# conversation déplacée dans la corbeille} other {# conversations déplacées dans la corbeille}}",
+ "conversations_deleted": "{count, plural, one {# conversation supprimée} other {# conversations supprimées}}",
+ "conversations_moved_to": "{count, plural, one {# conversation déplacée vers {mailbox}} other {# conversations déplacées vers {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversation déplacée} other {# conversations déplacées}}",
+ "select_all_in_folder": "{count, plural, one {Sélectionner la # conversation de {folder}} other {Sélectionner les # conversations de {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Sélectionner le # e-mail de {folder}} other {Sélectionner les # e-mails de {folder}}}",
+ "select_all_conversations": "{count, plural, one {Sélectionner la # conversation} other {Sélectionner les # conversations}}",
+ "select_all_emails": "{count, plural, one {Sélectionner le # e-mail} other {Sélectionner les # e-mails}}",
+ "selecting_all": "Sélection…"
+ },
"shared_tags": {
"title": "Étiquettes partagées",
"description": "Les étiquettes de vos boîtes aux lettres de groupe. Tous les membres du groupe voient les mêmes noms et couleurs sur ses messages.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Saisissez l'URL",
"sending": "Envoi...",
"schedule_send": "Planifier l’envoi",
- "schedule_send_description": "Choisissez quand le serveur doit libérer ce message.",
+ "schedule_send_description": "Choisissez quand l’envoyer.",
"schedule_preset": {
"this_morning": "Ce matin",
"this_afternoon": "Cet après-midi",
@@ -1163,6 +1175,7 @@
"edit_link": "Modifier le lien",
"remove_link": "Retirer le lien",
"link_address": "Adresse du lien",
+ "link_text": "Texte",
"link_invalid": "L'adresse ne peut pas contenir d'espaces",
"table": "Tableau",
"clear_formatting": "Effacer la mise en forme",
@@ -3881,6 +3894,15 @@
"create": "Créer",
"rename_confirm": "Renommer"
},
+ "mail_p2_folders": {
+ "all_folders": "Tous les dossiers",
+ "empty_trash": "Vider la corbeille",
+ "empty_spam": "Vider le spam",
+ "empty_trash_confirm": "Supprimer définitivement {count, plural, one {le message} other {les # messages}} de la corbeille ? Impossible de revenir en arrière.",
+ "empty_spam_confirm": "Supprimer définitivement {count, plural, one {le message} other {les # messages}} du spam ? Impossible de revenir en arrière.",
+ "title_all_accounts": "{name} ({count}, {total} dans tous les comptes)",
+ "title_all_accounts_only": "{name} ({total} dans tous les comptes)"
+ },
"uxs_folders": {
"manage_accounts": "Gérer les comptes…",
"apply_to_existing": "{count, plural, one {Appliquer à # message existant dans {folder}} other {Appliquer à # messages existants dans {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Afficher # message plus ancien} other {Afficher # messages plus anciens}}",
"return_to_list_follows_layout": "Tant que vous ne le modifiez pas, ce réglage suit la disposition : désactivé quand la liste et le message sont côte à côte, activé sinon."
},
+ "mail_p2_conv": {
+ "show_message": "Afficher le message",
+ "back_to_reply": "Revenir à votre réponse"
+ },
"identities": {
"modal_title": "Gérer les identités d'envoi",
"create_new": "Créer une nouvelle identité",
@@ -4105,6 +4131,9 @@
"sending": "Envoi…",
"sent": "Envoyé",
"view": "Afficher",
+ "template_saved": "Modèle enregistré",
+ "schedule_anyway": "Planifier quand même",
+ "schedule_warnings_title": "Planifier ce message quand même ?",
"picker_button": "Ajouter depuis les contacts",
"picker_title": "Ajouter des destinataires",
"tab_contacts": "Contacts",
@@ -5298,7 +5327,7 @@
"title": "Bienvenue dans votre boite mail",
"tip_compose": "Appuyez sur c pour composer un nouvel email",
"tip_shortcuts": "Appuyez sur ? pour voir tous les raccourcis clavier",
- "tip_sidebar": "Retrouvez le Calendrier, les Contacts et les Parametres dans le menu lateral",
+ "tip_sidebar": "Calendrier, Contacts et Paramètres sont dans la barre à gauche",
"tip_settings": "Personnalisez votre experience dans les Parametres",
"tip_compose_touch": "Touchez le bouton crayon pour écrire un nouvel e-mail",
"tip_sidebar_touch": "Agenda, Contacts et Paramètres se trouvent dans la barre du bas",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Rechercher « {text} » dans les messages"
},
+ "mail_p2_desk": {
+ "reader_offline": "Vous êtes hors ligne. Ce message a besoin d'une connexion pour s'ouvrir.",
+ "reader_failed": "Ce message n'a pas pu être chargé.",
+ "retry": "Réessayer",
+ "action_offline": "Vous êtes hors ligne. Cette action a besoin d'une connexion.",
+ "offline_bar": "Vous êtes hors ligne. Lire et modifier les e-mails demande une connexion.",
+ "offline_default_on": "Vos e-mails sont gardés sur cet appareil : vous pouvez les lire hors ligne."
+ },
"desktopMenuBar": {
"always_show_label": "Toujours afficher la barre de menus",
"always_show_description": "Sinon, elle s’affiche quand vous appuyez sur Alt.",
diff --git a/locales/he/common.json b/locales/he/common.json
index 86d5dc5d1..7458c1cbe 100644
--- a/locales/he/common.json
+++ b/locales/he/common.json
@@ -371,6 +371,18 @@
"new_folder_title": "תיקייה חדשה",
"new_subfolder_title": "תת־תיקייה חדשה"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {שיחה אחת הועברה לארכיון} two {שתי שיחות הועברו לארכיון} other {# שיחות הועברו לארכיון}}",
+ "conversations_to_trash": "{count, plural, one {שיחה אחת הועברה לאשפה} two {שתי שיחות הועברו לאשפה} other {# שיחות הועברו לאשפה}}",
+ "conversations_deleted": "{count, plural, one {שיחה אחת נמחקה} two {שתי שיחות נמחקו} other {# שיחות נמחקו}}",
+ "conversations_moved_to": "{count, plural, one {שיחה אחת הועברה אל {mailbox}} two {שתי שיחות הועברו אל {mailbox}} other {# שיחות הועברו אל {mailbox}}}",
+ "conversations_moved": "{count, plural, one {שיחה אחת הועברה} two {שתי שיחות הועברו} other {# שיחות הועברו}}",
+ "select_all_in_folder": "{count, plural, one {בחירת השיחה בתיקייה {folder}} two {בחירת שתי השיחות בתיקייה {folder}} other {בחירת כל # השיחות בתיקייה {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {בחירת ההודעה בתיקייה {folder}} two {בחירת שתי ההודעות בתיקייה {folder}} other {בחירת כל # ההודעות בתיקייה {folder}}}",
+ "select_all_conversations": "{count, plural, one {בחירת השיחה} two {בחירת שתי השיחות} other {בחירת כל # השיחות}}",
+ "select_all_emails": "{count, plural, one {בחירת ההודעה} two {בחירת שתי ההודעות} other {בחירת כל # ההודעות}}",
+ "selecting_all": "הבחירה מתבצעת…"
+ },
"shared_tags": {
"title": "תגים משותפים",
"description": "התגים של תיבות הדואר הקבוצתיות שלך. כל חברי הקבוצה רואים את אותם שמות וצבעים בדואר שלה.",
@@ -1056,7 +1068,7 @@
"unfinished": []
},
"schedule_send": "תזמן שליחה",
- "schedule_send_description": "בחר מתי השרת צריך לשחרר הודעה זו.",
+ "schedule_send_description": "בחר מתי לשלוח אותה.",
"schedule_preset": {
"this_morning": "הבוקר",
"this_afternoon": "היום אחר הצהריים",
@@ -1107,6 +1119,7 @@
"edit_link": "עריכת קישור",
"remove_link": "הסרת קישור",
"link_address": "כתובת הקישור",
+ "link_text": "טקסט",
"link_invalid": "הכתובת לא יכולה להכיל רווחים",
"table": "טבלה",
"clear_formatting": "ניקוי עיצוב",
@@ -3854,6 +3867,10 @@
"show_older": "{count, plural, one {הצגת הודעה ישנה אחת} two {הצגת שתי הודעות ישנות} other {הצגת # הודעות ישנות}}",
"return_to_list_follows_layout": "כל עוד לא תשנו זאת, זה נקבע לפי הפריסה: כבוי כשהרשימה וההודעה זו לצד זו, ופועל בכל מצב אחר."
},
+ "mail_p2_conv": {
+ "show_message": "הצגת ההודעה",
+ "back_to_reply": "חזרה לתשובה שלך"
+ },
"identities": {
"modal_title": "ניהול זהויות שליחה",
"create_new": "צור זהות חדשה",
@@ -3999,6 +4016,9 @@
"sending": "שולח…",
"sent": "נשלח",
"view": "הצג",
+ "template_saved": "התבנית נשמרה",
+ "schedule_anyway": "תזמן בכל זאת",
+ "schedule_warnings_title": "לתזמן את ההודעה בכל זאת?",
"picker_button": "הוסף מאנשי הקשר",
"picker_title": "הוספת נמענים",
"tab_contacts": "אנשי קשר",
@@ -5192,7 +5212,7 @@
"title": "ברוך הבא לתיבת הדואר שלך",
"tip_compose": "הקש c כדי לכתוב דוא\"ל חדש",
"tip_shortcuts": "לחץ על? כדי לראות את כל קיצורי המקלדת",
- "tip_sidebar": "מצא את לוח השנה, אנשי הקשר וההגדרות בתפריט סרגל הצד",
+ "tip_sidebar": "לוח השנה, אנשי הקשר וההגדרות נמצאים בסרגל שבצד ימין",
"tip_settings": "התאם אישית את החוויה שלך בהגדרות",
"tip_compose_touch": "הקישו על כפתור העיפרון כדי לכתוב הודעה חדשה",
"tip_sidebar_touch": "יומן, אנשי קשר והגדרות נמצאים בסרגל התחתון",
@@ -5680,6 +5700,15 @@
"delete_with_contents_message": "התיקייה \"{name}\" עדיין מכילה {count, plural, one {הודעה אחת} other {# הודעות}}. למחוק את התיקייה ואת כל ההודעות שבה? לא ניתן לבטל פעולה זו.",
"delete_with_contents_confirm": "מחיקת התיקייה וההודעות"
},
+ "mail_p2_folders": {
+ "all_folders": "כל התיקיות",
+ "empty_trash": "ריקון האשפה",
+ "empty_spam": "ריקון הספאם",
+ "empty_trash_confirm": "למחוק לצמיתות {count, plural, one {את ההודעה} two {את שתי ההודעות} other {את כל # ההודעות}} באשפה? אי אפשר לבטל את זה.",
+ "empty_spam_confirm": "למחוק לצמיתות {count, plural, one {את ההודעה} two {את שתי ההודעות} other {את כל # ההודעות}} בספאם? אי אפשר לבטל את זה.",
+ "title_all_accounts": "{name} ({count}, {total} בכל החשבונות)",
+ "title_all_accounts_only": "{name} ({total} בכל החשבונות)"
+ },
"uxs_folders": {
"manage_accounts": "ניהול חשבונות…",
"apply_to_existing": "{count, plural, one {החלה על הודעה קיימת אחת בתיקייה {folder}} other {החלה על # הודעות קיימות בתיקייה {folder}}}"
@@ -7277,6 +7306,14 @@
"desktop_mail": {
"context_search_mail": "חיפוש “{text}” בדואר"
},
+ "mail_p2_desk": {
+ "reader_offline": "אין חיבור. כדי לפתוח הודעה זו צריך חיבור.",
+ "reader_failed": "לא ניתן היה לטעון הודעה זו.",
+ "retry": "לנסות שוב",
+ "action_offline": "אין חיבור. לפעולה זו צריך חיבור.",
+ "offline_bar": "אין חיבור. כדי לקרוא ולשנות דואר צריך חיבור.",
+ "offline_default_on": "הדואר שלך נשמר במכשיר הזה, כך שאפשר לקרוא אותו גם בלי חיבור."
+ },
"desktopMenuBar": {
"always_show_label": "תמיד להציג את סרגל התפריטים",
"always_show_description": "אחרת הוא מופיע בלחיצה על Alt.",
diff --git a/locales/hu/common.json b/locales/hu/common.json
index cf636003c..cd6d79ba3 100644
--- a/locales/hu/common.json
+++ b/locales/hu/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Új mappa",
"new_subfolder_title": "Új almappa"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# beszélgetés archiválva} other {# beszélgetés archiválva}}",
+ "conversations_to_trash": "{count, plural, one {# beszélgetés áthelyezve a kukába} other {# beszélgetés áthelyezve a kukába}}",
+ "conversations_deleted": "{count, plural, one {# beszélgetés törölve} other {# beszélgetés törölve}}",
+ "conversations_moved_to": "{count, plural, one {# beszélgetés áthelyezve ide: {mailbox}} other {# beszélgetés áthelyezve ide: {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# beszélgetés áthelyezve} other {# beszélgetés áthelyezve}}",
+ "select_all_in_folder": "{count, plural, one {A(z) # beszélgetés kijelölése itt: {folder}} other {Mind a(z) # beszélgetés kijelölése itt: {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {A(z) # e-mail kijelölése itt: {folder}} other {Mind a(z) # e-mail kijelölése itt: {folder}}}",
+ "select_all_conversations": "{count, plural, one {A(z) # beszélgetés kijelölése} other {Mind a(z) # beszélgetés kijelölése}}",
+ "select_all_emails": "{count, plural, one {A(z) # e-mail kijelölése} other {Mind a(z) # e-mail kijelölése}}",
+ "selecting_all": "Kijelölés…"
+ },
"shared_tags": {
"title": "Megosztott címkék",
"description": "A csoportos postafiókjaid címkéi. A csoportban mindenki ugyanazokat a neveket és színeket látja a leveleken.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Késleltetett küldés",
- "schedule_send_description": "Válaszd ki, mikor küldje el a szerver az üzenetet.",
+ "schedule_send_description": "Válaszd ki, mikor menjen el.",
"schedule_preset": {
"this_morning": "Ma reggel",
"this_afternoon": "Ma délután",
@@ -1163,6 +1175,7 @@
"edit_link": "Hivatkozás szerkesztése",
"remove_link": "Hivatkozás eltávolítása",
"link_address": "Hivatkozás címe",
+ "link_text": "Szöveg",
"link_invalid": "A cím nem tartalmazhat szóközt",
"table": "Táblázat",
"clear_formatting": "Formázás törlése",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "A(z) „{name}” mappa még {count, plural, one {1 üzenetet} other {# üzenetet}} tartalmaz. Törlöd a mappát az összes üzenetével együtt? Ez nem vonható vissza.",
"delete_with_contents_confirm": "Mappa és üzenetek törlése"
},
+ "mail_p2_folders": {
+ "all_folders": "Összes mappa",
+ "empty_trash": "Kuka ürítése",
+ "empty_spam": "Spam ürítése",
+ "empty_trash_confirm": "Véglegesen törli {count, plural, one {az üzenetet} other {mind a # üzenetet}} a kukából? Ez nem vonható vissza.",
+ "empty_spam_confirm": "Véglegesen törli {count, plural, one {az üzenetet} other {mind a # üzenetet}} a spamből? Ez nem vonható vissza.",
+ "title_all_accounts": "{name} ({count}, {total} az összes fiókban)",
+ "title_all_accounts_only": "{name} ({total} az összes fiókban)"
+ },
"uxs_folders": {
"manage_accounts": "Fiókok kezelése…",
"apply_to_existing": "{count, plural, one {Alkalmazás # meglévő üzenetre itt: {folder}} other {Alkalmazás # meglévő üzenetre itt: {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {# régebbi üzenet megjelenítése} other {# régebbi üzenet megjelenítése}}",
"return_to_list_follows_layout": "Amíg nem módosítja, az elrendezést követi: ki van kapcsolva, ha a lista és az üzenet egymás mellett van, egyébként be."
},
+ "mail_p2_conv": {
+ "show_message": "Üzenet megjelenítése",
+ "back_to_reply": "Vissza a válaszhoz"
+ },
"identities": {
"modal_title": "Küldő azonosságok kezelése",
"create_new": "Új azonosság létrehozása",
@@ -4105,6 +4131,9 @@
"sending": "Küldés…",
"sent": "Elküldve",
"view": "Megtekintés",
+ "template_saved": "Sablon mentve",
+ "schedule_anyway": "Időzítés így is",
+ "schedule_warnings_title": "Időzíti így is ezt az üzenetet?",
"picker_button": "Hozzáadás a névjegyekből",
"picker_title": "Címzettek hozzáadása",
"tab_contacts": "Névjegyek",
@@ -5560,7 +5589,7 @@
"title": "Üdvözlünk a postafiókodban",
"tip_compose": "Nyomd meg a c billentyűt új e-mail írásához",
"tip_shortcuts": "Nyomd meg a ? billentyűt az összes billentyűparancs megtekintéséhez",
- "tip_sidebar": "Találd meg a Naptár, Névjegyek és Beállítások menüpontokat az oldalsávban",
+ "tip_sidebar": "A Naptár, a Névjegyek és a Beállítások a bal oldali sávban vannak",
"tip_settings": "Testreszabhatod a felhasználói élményt a Beállításokban",
"tip_compose_touch": "Új e-mail írásához koppints a ceruza gombra",
"tip_sidebar_touch": "A Naptár, a Névjegyek és a Beállítások az alsó sávban vannak",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "„{text}” keresése a levelek között"
},
+ "mail_p2_desk": {
+ "reader_offline": "Nincs kapcsolat. Ennek az üzenetnek a megnyitásához kapcsolat kell.",
+ "reader_failed": "Az üzenetet nem sikerült betölteni.",
+ "retry": "Újra",
+ "action_offline": "Nincs kapcsolat. Ehhez kapcsolat kell.",
+ "offline_bar": "Nincs kapcsolat. A levelek olvasásához és módosításához kapcsolat kell.",
+ "offline_default_on": "A leveleid ezen az eszközön is megmaradnak, így kapcsolat nélkül is olvashatod őket."
+ },
"desktopMenuBar": {
"always_show_label": "Menüsor mindig látható",
"always_show_description": "Különben az Alt billentyű lenyomásakor jelenik meg.",
diff --git a/locales/it/common.json b/locales/it/common.json
index aeb7774da..b28fdda61 100644
--- a/locales/it/common.json
+++ b/locales/it/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nuova cartella",
"new_subfolder_title": "Nuova sottocartella"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversazione archiviata} other {# conversazioni archiviate}}",
+ "conversations_to_trash": "{count, plural, one {# conversazione spostata nel cestino} other {# conversazioni spostate nel cestino}}",
+ "conversations_deleted": "{count, plural, one {# conversazione eliminata} other {# conversazioni eliminate}}",
+ "conversations_moved_to": "{count, plural, one {# conversazione spostata in {mailbox}} other {# conversazioni spostate in {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversazione spostata} other {# conversazioni spostate}}",
+ "select_all_in_folder": "{count, plural, one {Seleziona la # conversazione in {folder}} other {Seleziona tutte le # conversazioni in {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Seleziona il # messaggio in {folder}} other {Seleziona tutti i # messaggi in {folder}}}",
+ "select_all_conversations": "{count, plural, one {Seleziona la # conversazione} other {Seleziona tutte le # conversazioni}}",
+ "select_all_emails": "{count, plural, one {Seleziona il # messaggio} other {Seleziona tutti i # messaggi}}",
+ "selecting_all": "Selezione in corso…"
+ },
"shared_tags": {
"title": "Etichette condivise",
"description": "Etichette delle tue caselle di gruppo. Tutti nel gruppo vedono gli stessi nomi e colori sulla sua posta.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Inserisci l'URL",
"sending": "Invio in corso...",
"schedule_send": "Programma invio",
- "schedule_send_description": "Scegli quando il server deve rilasciare questo messaggio.",
+ "schedule_send_description": "Scegli quando inviarlo.",
"schedule_preset": {
"this_morning": "Stamattina",
"this_afternoon": "Oggi pomeriggio",
@@ -1163,6 +1175,7 @@
"edit_link": "Modifica link",
"remove_link": "Rimuovi link",
"link_address": "Indirizzo del link",
+ "link_text": "Testo",
"link_invalid": "L'indirizzo non può contenere spazi",
"table": "Tabella",
"clear_formatting": "Cancella formattazione",
@@ -3881,6 +3894,15 @@
"create": "Crea",
"rename_confirm": "Rinomina"
},
+ "mail_p2_folders": {
+ "all_folders": "Tutte le cartelle",
+ "empty_trash": "Svuota cestino",
+ "empty_spam": "Svuota spam",
+ "empty_trash_confirm": "Eliminare per sempre {count, plural, one {il messaggio} other {tutti i # messaggi}} nel cestino? Non si può annullare.",
+ "empty_spam_confirm": "Eliminare per sempre {count, plural, one {il messaggio} other {tutti i # messaggi}} nello spam? Non si può annullare.",
+ "title_all_accounts": "{name} ({count}, {total} in tutti gli account)",
+ "title_all_accounts_only": "{name} ({total} in tutti gli account)"
+ },
"uxs_folders": {
"manage_accounts": "Gestisci account…",
"apply_to_existing": "{count, plural, one {Applica a # messaggio esistente in {folder}} other {Applica a # messaggi esistenti in {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Mostra # messaggio precedente} other {Mostra # messaggi precedenti}}",
"return_to_list_follows_layout": "Finché non lo modifichi, segue il layout: disattivato quando l'elenco e il messaggio sono affiancati, attivato negli altri casi."
},
+ "mail_p2_conv": {
+ "show_message": "Mostra il messaggio",
+ "back_to_reply": "Torna alla risposta"
+ },
"identities": {
"modal_title": "Gestisci identità di invio",
"create_new": "Crea nuova identità",
@@ -4105,6 +4131,9 @@
"sending": "Invio in corso…",
"sent": "Inviato",
"view": "Visualizza",
+ "template_saved": "Modello salvato",
+ "schedule_anyway": "Programma comunque",
+ "schedule_warnings_title": "Programmare comunque questo messaggio?",
"picker_button": "Aggiungi dai contatti",
"picker_title": "Aggiungi destinatari",
"tab_contacts": "Contatti",
@@ -5298,7 +5327,7 @@
"title": "Benvenuto nella tua casella di posta",
"tip_compose": "Premi c per scrivere una nuova email",
"tip_shortcuts": "Premi ? per vedere tutte le scorciatoie da tastiera",
- "tip_sidebar": "Trova Calendario, Contatti e Impostazioni nel menu laterale",
+ "tip_sidebar": "Calendario, Contatti e Impostazioni sono nella barra a sinistra",
"tip_settings": "Personalizza la tua esperienza nelle Impostazioni",
"tip_compose_touch": "Tocca il pulsante a matita per scrivere una nuova email",
"tip_sidebar_touch": "Calendario, Contatti e Impostazioni sono nella barra in basso",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Cerca «{text}» nella posta"
},
+ "mail_p2_desk": {
+ "reader_offline": "Sei offline. Questo messaggio ha bisogno di una connessione per aprirsi.",
+ "reader_failed": "Impossibile caricare questo messaggio.",
+ "retry": "Riprova",
+ "action_offline": "Sei offline. Serve una connessione.",
+ "offline_bar": "Sei offline. Per leggere e modificare la posta serve una connessione.",
+ "offline_default_on": "La tua posta è conservata su questo dispositivo, così puoi leggerla offline."
+ },
"desktopMenuBar": {
"always_show_label": "Mostra sempre la barra dei menu",
"always_show_description": "Altrimenti compare premendo Alt.",
diff --git a/locales/ja/common.json b/locales/ja/common.json
index 53ab9e786..ac3b61e14 100644
--- a/locales/ja/common.json
+++ b/locales/ja/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "新しいフォルダー",
"new_subfolder_title": "新しいサブフォルダー"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {#件の会話をアーカイブしました}}",
+ "conversations_to_trash": "{count, plural, other {#件の会話をゴミ箱に移動しました}}",
+ "conversations_deleted": "{count, plural, other {#件の会話を削除しました}}",
+ "conversations_moved_to": "{count, plural, other {#件の会話を{mailbox}に移動しました}}",
+ "conversations_moved": "{count, plural, other {#件の会話を移動しました}}",
+ "select_all_in_folder": "{count, plural, other {{folder}の#件の会話をすべて選択}}",
+ "select_all_emails_in_folder": "{count, plural, other {{folder}の#件のメールをすべて選択}}",
+ "select_all_conversations": "{count, plural, other {#件の会話をすべて選択}}",
+ "select_all_emails": "{count, plural, other {#件のメールをすべて選択}}",
+ "selecting_all": "選択しています…"
+ },
"shared_tags": {
"title": "共有タグ",
"description": "グループメールボックスのタグです。グループの全員が、そのメールに同じ名前と色のタグを見ることができます。",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "URLを入力してください",
"sending": "送信中...",
"schedule_send": "送信を予約",
- "schedule_send_description": "サーバーがこのメッセージを送信する日時を選択します。",
+ "schedule_send_description": "送信する日時を選んでください。",
"schedule_preset": {
"this_morning": "今朝",
"this_afternoon": "今日の午後",
@@ -1163,6 +1175,7 @@
"edit_link": "リンクを編集",
"remove_link": "リンクを解除",
"link_address": "リンク先のアドレス",
+ "link_text": "テキスト",
"link_invalid": "アドレスに空白は使えません",
"table": "表",
"clear_formatting": "書式をクリア",
@@ -3881,6 +3894,15 @@
"create": "作成",
"rename_confirm": "名前を変更"
},
+ "mail_p2_folders": {
+ "all_folders": "すべてのフォルダー",
+ "empty_trash": "ゴミ箱を空にする",
+ "empty_spam": "迷惑メールを空にする",
+ "empty_trash_confirm": "ゴミ箱の{count, plural, other {# 件のメッセージ}}をすべて完全に削除しますか?元に戻せません。",
+ "empty_spam_confirm": "迷惑メールの{count, plural, other {# 件のメッセージ}}をすべて完全に削除しますか?元に戻せません。",
+ "title_all_accounts": "{name} ({count}、すべてのアカウントで {total})",
+ "title_all_accounts_only": "{name} (すべてのアカウントで {total})"
+ },
"uxs_folders": {
"manage_accounts": "アカウントを管理…",
"apply_to_existing": "{count, plural, other {{folder} の既存の # 件に適用}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, other {以前のメッセージを # 件表示}}",
"return_to_list_follows_layout": "変更するまでは、レイアウトに合わせて切り替わります。一覧とメッセージが横に並ぶ場合はオフ、それ以外はオンです。"
},
+ "mail_p2_conv": {
+ "show_message": "メッセージを表示",
+ "back_to_reply": "返信に戻る"
+ },
"identities": {
"modal_title": "送信者情報を管理",
"create_new": "新しい送信者情報を作成",
@@ -4105,6 +4131,9 @@
"sending": "送信中…",
"sent": "送信しました",
"view": "表示",
+ "template_saved": "テンプレートを保存しました",
+ "schedule_anyway": "そのまま予約",
+ "schedule_warnings_title": "このメッセージをそのまま予約しますか?",
"picker_button": "連絡先から追加",
"picker_title": "宛先を追加",
"tab_contacts": "連絡先",
@@ -5298,7 +5327,7 @@
"title": "メールボックスへようこそ",
"tip_compose": "cキーで新しいメールを作成",
"tip_shortcuts": "?キーですべてのショートカットを表示",
- "tip_sidebar": "サイドバーからカレンダー、連絡先、設定にアクセス",
+ "tip_sidebar": "カレンダー、連絡先、設定は左側のバーにあります",
"tip_settings": "設定でカスタマイズできます",
"tip_compose_touch": "鉛筆ボタンをタップして新しいメールを作成",
"tip_sidebar_touch": "カレンダー、連絡先、設定は下部のバーにあります",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "メールで「{text}」を検索"
},
+ "mail_p2_desk": {
+ "reader_offline": "オフラインです。このメッセージを開くには接続が必要です。",
+ "reader_failed": "このメッセージを読み込めませんでした。",
+ "retry": "再試行",
+ "action_offline": "オフラインです。この操作には接続が必要です。",
+ "offline_bar": "オフラインです。メールの閲覧や変更には接続が必要です。",
+ "offline_default_on": "メールはこのデバイスに保存されるので、オフラインでも読めます。"
+ },
"desktopMenuBar": {
"always_show_label": "メニューバーを常に表示",
"always_show_description": "オフにすると、Alt キーを押したときに表示されます。",
diff --git a/locales/ko/common.json b/locales/ko/common.json
index 2c914f1b4..63b597147 100644
--- a/locales/ko/common.json
+++ b/locales/ko/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "새 폴더",
"new_subfolder_title": "새 하위 폴더"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {대화 #개를 보관했어요}}",
+ "conversations_to_trash": "{count, plural, other {대화 #개를 휴지통으로 이동했어요}}",
+ "conversations_deleted": "{count, plural, other {대화 #개를 삭제했어요}}",
+ "conversations_moved_to": "{count, plural, other {대화 #개를 {mailbox}(으)로 이동했어요}}",
+ "conversations_moved": "{count, plural, other {대화 #개를 이동했어요}}",
+ "select_all_in_folder": "{count, plural, other {{folder}의 대화 #개 모두 선택}}",
+ "select_all_emails_in_folder": "{count, plural, other {{folder}의 메일 #개 모두 선택}}",
+ "select_all_conversations": "{count, plural, other {대화 #개 모두 선택}}",
+ "select_all_emails": "{count, plural, other {메일 #개 모두 선택}}",
+ "selecting_all": "선택하는 중…"
+ },
"shared_tags": {
"title": "공유 태그",
"description": "그룹 사서함의 태그예요. 그룹의 모든 사람이 해당 메일에서 같은 이름과 색상을 봐요.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "URL을 입력하세요",
"sending": "전송 중...",
"schedule_send": "보내기 예약",
- "schedule_send_description": "서버가 이 메시지를 보낼 시간을 선택하세요.",
+ "schedule_send_description": "보낼 시간을 고르세요.",
"schedule_preset": {
"this_morning": "오늘 아침",
"this_afternoon": "오늘 오후",
@@ -1163,6 +1175,7 @@
"edit_link": "링크 편집",
"remove_link": "링크 제거",
"link_address": "링크 주소",
+ "link_text": "텍스트",
"link_invalid": "주소에 공백을 넣을 수 없어요",
"table": "표",
"clear_formatting": "서식 지우기",
@@ -3881,6 +3894,15 @@
"create": "만들기",
"rename_confirm": "이름 바꾸기"
},
+ "mail_p2_folders": {
+ "all_folders": "모든 폴더",
+ "empty_trash": "휴지통 비우기",
+ "empty_spam": "스팸 비우기",
+ "empty_trash_confirm": "휴지통의 {count, plural, other {메시지 #개}}를 모두 영구 삭제할까요? 되돌릴 수 없어요.",
+ "empty_spam_confirm": "스팸의 {count, plural, other {메시지 #개}}를 모두 영구 삭제할까요? 되돌릴 수 없어요.",
+ "title_all_accounts": "{name} ({count}, 모든 계정에 {total})",
+ "title_all_accounts_only": "{name} (모든 계정에 {total})"
+ },
"uxs_folders": {
"manage_accounts": "계정 관리…",
"apply_to_existing": "{count, plural, other {{folder}의 기존 메일 #개에 적용}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, other {이전 메시지 #개 보기}}",
"return_to_list_follows_layout": "변경하기 전까지는 레이아웃을 따릅니다. 목록과 메시지가 나란히 있으면 꺼지고, 그 밖에는 켜집니다."
},
+ "mail_p2_conv": {
+ "show_message": "메시지 보기",
+ "back_to_reply": "답장으로 돌아가기"
+ },
"identities": {
"modal_title": "보내는 사람 관리",
"create_new": "새 보내는 사람 추가",
@@ -4105,6 +4131,9 @@
"sending": "보내는 중…",
"sent": "보냈어요",
"view": "보기",
+ "template_saved": "템플릿을 저장했습니다",
+ "schedule_anyway": "그래도 예약하기",
+ "schedule_warnings_title": "이 메일을 그래도 예약할까요?",
"picker_button": "연락처에서 추가",
"picker_title": "받는 사람 추가",
"tab_contacts": "연락처",
@@ -5298,7 +5327,7 @@
"title": "웹메일에 오신 것을 환영해요!",
"tip_compose": "c 키를 누르면 바로 새 메일을 쓸 수 있어요",
"tip_shortcuts": "? 키를 누르면 모든 단축키를 확인할 수 있어요",
- "tip_sidebar": "캘린더, 연락처, 설정은 사이드바 메뉴에 있어요",
+ "tip_sidebar": "캘린더, 연락처, 설정은 왼쪽 막대에 있습니다",
"tip_settings": "설정에서 나만의 스타일로 꾸며보세요",
"tip_compose_touch": "연필 버튼을 눌러 새 메일을 작성하세요",
"tip_sidebar_touch": "캘린더, 연락처, 설정은 하단 막대에 있습니다",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "메일에서 “{text}” 검색"
},
+ "mail_p2_desk": {
+ "reader_offline": "오프라인 상태입니다. 이 메시지를 열려면 연결이 필요합니다.",
+ "reader_failed": "이 메시지를 불러오지 못했습니다.",
+ "retry": "다시 시도",
+ "action_offline": "오프라인 상태입니다. 이 작업에는 연결이 필요합니다.",
+ "offline_bar": "오프라인 상태입니다. 메일을 읽고 변경하려면 연결이 필요합니다.",
+ "offline_default_on": "메일이 이 기기에 보관되어 오프라인에서도 읽을 수 있습니다."
+ },
"desktopMenuBar": {
"always_show_label": "메뉴 막대 항상 표시",
"always_show_description": "끄면 Alt 키를 누를 때 표시됩니다.",
diff --git a/locales/lv/common.json b/locales/lv/common.json
index b8d6490d7..b1b53d558 100644
--- a/locales/lv/common.json
+++ b/locales/lv/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Jauna mape",
"new_subfolder_title": "Jauna apakšmape"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, zero {# sarunas arhivētas} one {# saruna arhivēta} other {# sarunas arhivētas}}",
+ "conversations_to_trash": "{count, plural, zero {# sarunas pārvietotas uz atkritni} one {# saruna pārvietota uz atkritni} other {# sarunas pārvietotas uz atkritni}}",
+ "conversations_deleted": "{count, plural, zero {# sarunas izdzēstas} one {# saruna izdzēsta} other {# sarunas izdzēstas}}",
+ "conversations_moved_to": "{count, plural, zero {# sarunas pārvietotas uz {mailbox}} one {# saruna pārvietota uz {mailbox}} other {# sarunas pārvietotas uz {mailbox}}}",
+ "conversations_moved": "{count, plural, zero {# sarunas pārvietotas} one {# saruna pārvietota} other {# sarunas pārvietotas}}",
+ "select_all_in_folder": "{count, plural, zero {Atlasīt visas # sarunas mapē {folder}} one {Atlasīt # sarunu mapē {folder}} other {Atlasīt visas # sarunas mapē {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, zero {Atlasīt visas # vēstules mapē {folder}} one {Atlasīt # vēstuli mapē {folder}} other {Atlasīt visas # vēstules mapē {folder}}}",
+ "select_all_conversations": "{count, plural, zero {Atlasīt visas # sarunas} one {Atlasīt # sarunu} other {Atlasīt visas # sarunas}}",
+ "select_all_emails": "{count, plural, zero {Atlasīt visas # vēstules} one {Atlasīt # vēstuli} other {Atlasīt visas # vēstules}}",
+ "selecting_all": "Atlasa…"
+ },
"shared_tags": {
"title": "Kopīgotie tagi",
"description": "Jūsu grupu pastkastu tagi. Visi grupā redz vienādus nosaukumus un krāsas pie tās vēstulēm.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Ievadiet URL",
"sending": "Sūta...",
"schedule_send": "Ieplānot sūtīšanu",
- "schedule_send_description": "Izvēlieties, kad serverim jānosūta šis ziņojums.",
+ "schedule_send_description": "Izvēlieties, kad to nosūtīt.",
"schedule_preset": {
"this_morning": "Šorīt",
"this_afternoon": "Šodien pēcpusdienā",
@@ -1163,6 +1175,7 @@
"edit_link": "Rediģēt saiti",
"remove_link": "Noņemt saiti",
"link_address": "Saites adrese",
+ "link_text": "Teksts",
"link_invalid": "Adresē nedrīkst būt atstarpju",
"table": "Tabula",
"clear_formatting": "Notīrīt formatējumu",
@@ -3881,6 +3894,15 @@
"create": "Izveidot",
"rename_confirm": "Pārsaukt"
},
+ "mail_p2_folders": {
+ "all_folders": "Visas mapes",
+ "empty_trash": "Iztukšot miskasti",
+ "empty_spam": "Iztukšot mēstules",
+ "empty_trash_confirm": "Neatgriezeniski dzēst {count, plural, =1 {vēstuli} zero {visas # vēstules} one {visas # vēstuli} other {visas # vēstules}} miskastē? To nevar atsaukt.",
+ "empty_spam_confirm": "Neatgriezeniski dzēst {count, plural, =1 {vēstuli} zero {visas # vēstules} one {visas # vēstuli} other {visas # vēstules}} mēstulēs? To nevar atsaukt.",
+ "title_all_accounts": "{name} ({count}, {total} visos kontos)",
+ "title_all_accounts_only": "{name} ({total} visos kontos)"
+ },
"uxs_folders": {
"manage_accounts": "Pārvaldīt kontus…",
"apply_to_existing": "{count, plural, zero {Piemērot # esošajām vēstulēm mapē {folder}} one {Piemērot # esošajai vēstulei mapē {folder}} other {Piemērot # esošajām vēstulēm mapē {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, zero {Rādīt # vecākas ziņas} one {Rādīt # vecāku ziņu} other {Rādīt # vecākas ziņas}}",
"return_to_list_follows_layout": "Kamēr to nemaināt, tas seko izkārtojumam: izslēgts, ja saraksts un ziņa ir blakus, citādi ieslēgts."
},
+ "mail_p2_conv": {
+ "show_message": "Rādīt ziņojumu",
+ "back_to_reply": "Atpakaļ pie atbildes"
+ },
"identities": {
"modal_title": "Pārvaldīt sūtītāja identitātes",
"create_new": "Izveidot jaunu identitāti",
@@ -4105,6 +4131,9 @@
"sending": "Sūta…",
"sent": "Nosūtīts",
"view": "Skatīt",
+ "template_saved": "Veidne saglabāta",
+ "schedule_anyway": "Ieplānot tik un tā",
+ "schedule_warnings_title": "Vai tomēr ieplānot šo vēstuli?",
"picker_button": "Pievienot no kontaktiem",
"picker_title": "Pievienot saņēmējus",
"tab_contacts": "Kontakti",
@@ -5298,7 +5327,7 @@
"title": "Laipni lūdzam jūsu pastkastē",
"tip_compose": "Nospiediet c, lai rakstītu jaunu vēstuli",
"tip_shortcuts": "Nospiediet ?, lai skatītu visus īsinājumtaustiņus",
- "tip_sidebar": "Kalendāru, Kontaktus un Iestatījumus atradīsiet sānu joslā",
+ "tip_sidebar": "Kalendārs, Kontakti un Iestatījumi ir joslā kreisajā pusē",
"tip_settings": "Pielāgojiet saskarni sadaļā «Iestatījumi»",
"tip_compose_touch": "Pieskarieties zīmuļa pogai, lai rakstītu jaunu e-pastu",
"tip_sidebar_touch": "Kalendārs, Kontakti un Iestatījumi ir apakšējā joslā",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Meklēt pastā “{text}”"
},
+ "mail_p2_desk": {
+ "reader_offline": "Jūs esat bezsaistē. Šīs vēstules atvēršanai vajag savienojumu.",
+ "reader_failed": "Šo vēstuli neizdevās ielādēt.",
+ "retry": "Mēģināt vēlreiz",
+ "action_offline": "Jūs esat bezsaistē. Tam vajag savienojumu.",
+ "offline_bar": "Jūs esat bezsaistē. Pasta lasīšanai un mainīšanai vajag savienojumu.",
+ "offline_default_on": "Jūsu pasts tiek glabāts šajā ierīcē, tāpēc to var lasīt bezsaistē."
+ },
"desktopMenuBar": {
"always_show_label": "Vienmēr rādīt izvēļņu joslu",
"always_show_description": "Citādi tā parādās, nospiežot Alt.",
diff --git a/locales/mn/common.json b/locales/mn/common.json
index aad259f9d..1dc3cf353 100644
--- a/locales/mn/common.json
+++ b/locales/mn/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Шинэ хавтас",
"new_subfolder_title": "Шинэ дэд хавтас"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# харилцан яриаг архивласан} other {# харилцан яриаг архивласан}}",
+ "conversations_to_trash": "{count, plural, one {# харилцан яриаг хогийн сав руу зөөсөн} other {# харилцан яриаг хогийн сав руу зөөсөн}}",
+ "conversations_deleted": "{count, plural, one {# харилцан яриаг устгасан} other {# харилцан яриаг устгасан}}",
+ "conversations_moved_to": "{count, plural, one {# харилцан яриаг {mailbox} руу зөөсөн} other {# харилцан яриаг {mailbox} руу зөөсөн}}",
+ "conversations_moved": "{count, plural, one {# харилцан яриаг зөөсөн} other {# харилцан яриаг зөөсөн}}",
+ "select_all_in_folder": "{count, plural, one {{folder} доторх # харилцан яриаг сонгох} other {{folder} доторх бүх # харилцан яриаг сонгох}}",
+ "select_all_emails_in_folder": "{count, plural, one {{folder} доторх # имэйлийг сонгох} other {{folder} доторх бүх # имэйлийг сонгох}}",
+ "select_all_conversations": "{count, plural, one {# харилцан яриаг сонгох} other {Бүх # харилцан яриаг сонгох}}",
+ "select_all_emails": "{count, plural, one {# имэйлийг сонгох} other {Бүх # имэйлийг сонгох}}",
+ "selecting_all": "Сонгож байна…"
+ },
"shared_tags": {
"title": "Хуваалцсан тагууд",
"description": "Таны бүлгийн шуудангийн хайрцгуудын тагууд. Бүлгийн бүх гишүүн түүний захидлууд дээр ижил нэр, өнгийг хардаг.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Хуваарь илгээх",
- "schedule_send_description": "Сервер энэ мессежийг хэзээ гаргахыг сонгоно уу.",
+ "schedule_send_description": "Хэзээ илгээхээ сонгоно уу.",
"schedule_preset": {
"this_morning": "Өнөө өглөө",
"this_afternoon": "Өнөөдөр үдээс хойш",
@@ -1163,6 +1175,7 @@
"edit_link": "Холбоос засах",
"remove_link": "Холбоос хасах",
"link_address": "Холбоосын хаяг",
+ "link_text": "Текст",
"link_invalid": "Хаяг хоосон зай агуулж болохгүй",
"table": "Хүснэгт",
"clear_formatting": "Форматыг арилгах",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "\"{name}\" хавтсанд одоо ч {count} зурвас байна. Хавтас болон бүх зурвасыг устгах уу? Үүнийг буцаах боломжгүй.",
"delete_with_contents_confirm": "Хавтас болон зурвасыг устгах"
},
+ "mail_p2_folders": {
+ "all_folders": "Бүх хавтас",
+ "empty_trash": "Хогийн савыг хоослох",
+ "empty_spam": "Спамыг хоослох",
+ "empty_trash_confirm": "Хогийн саван дахь {count, plural, one {захидлыг} other {бүх # захидлыг}} бүрмөсөн устгах уу? Буцаах боломжгүй.",
+ "empty_spam_confirm": "Спам дахь {count, plural, one {захидлыг} other {бүх # захидлыг}} бүрмөсөн устгах уу? Буцаах боломжгүй.",
+ "title_all_accounts": "{name} ({count}, бүх бүртгэлд {total})",
+ "title_all_accounts_only": "{name} (бүх бүртгэлд {total})"
+ },
"uxs_folders": {
"manage_accounts": "Данснуудыг удирдах…",
"apply_to_existing": "{count, plural, one {{folder} доторх байгаа # захианд хэрэглэх} other {{folder} доторх байгаа # захианд хэрэглэх}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Өмнөх # захиа харуулах} other {Өмнөх # захиа харуулах}}",
"return_to_list_follows_layout": "Та өөрчлөхөөс нааш энэ нь байрлалаас хамаарна: жагсаалт ба захиа зэрэгцэн байвал унтраалттай, бусад үед асаалттай."
},
+ "mail_p2_conv": {
+ "show_message": "Захидлыг харах",
+ "back_to_reply": "Хариулт руу буцах"
+ },
"identities": {
"modal_title": "Илгээж буй таниулбаруудыг удирдах",
"create_new": "Шинэ таниулбар үүсгэх",
@@ -4105,6 +4131,9 @@
"sending": "Илгээж байна…",
"sent": "Илгээгдлээ",
"view": "Харах",
+ "template_saved": "Загвар хадгалагдлаа",
+ "schedule_anyway": "Ямар ч байсан хуваарьт оруулах",
+ "schedule_warnings_title": "Энэ захиаг ямар ч байсан хуваарьт оруулах уу?",
"picker_button": "Харилцагчдаас нэмэх",
"picker_title": "Хүлээн авагч нэмэх",
"tab_contacts": "Харилцагчид",
@@ -5560,7 +5589,7 @@
"title": "Таны шуудангийн хайрцагт тавтай морил",
"tip_compose": "Шинэ имэйл бичихийн тулд c товчийг дарна уу",
"tip_shortcuts": "Дарах уу? бүх гарын товчлолыг харахын тулд",
- "tip_sidebar": "Хажуугийн цэснээс Хуанли, Харилцагчид болон Тохиргоог олоорой",
+ "tip_sidebar": "Хуанли, Харилцагчид, Тохиргоо зүүн талын самбарт байна",
"tip_settings": "Тохиргооноос өөрийн туршлагаа тохируулна уу",
"tip_compose_touch": "Шинэ имэйл бичихийн тулд харандаа товчийг дарна уу",
"tip_sidebar_touch": "Хуанли, Харилцагчид болон Тохиргоо доод самбарт байна",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Захидлаас “{text}”-г хайх"
},
+ "mail_p2_desk": {
+ "reader_offline": "Та офлайн байна. Энэ захиаг нээхэд холболт хэрэгтэй.",
+ "reader_failed": "Энэ захиаг ачаалж чадсангүй.",
+ "retry": "Дахин оролдох",
+ "action_offline": "Та офлайн байна. Үүнд холболт хэрэгтэй.",
+ "offline_bar": "Та офлайн байна. Захиа унших, өөрчлөхөд холболт хэрэгтэй.",
+ "offline_default_on": "Таны захиа энэ төхөөрөмж дээр хадгалагддаг тул офлайн үед ч уншиж болно."
+ },
"desktopMenuBar": {
"always_show_label": "Цэсний мөрийг үргэлж харуулах",
"always_show_description": "Эс бөгөөс Alt товч дарахад гарч ирнэ.",
diff --git a/locales/nb/common.json b/locales/nb/common.json
index 3bb4c8441..ed22a5f81 100644
--- a/locales/nb/common.json
+++ b/locales/nb/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Ny mappe",
"new_subfolder_title": "Ny undermappe"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# samtale arkivert} other {# samtaler arkivert}}",
+ "conversations_to_trash": "{count, plural, one {# samtale flyttet til papirkurven} other {# samtaler flyttet til papirkurven}}",
+ "conversations_deleted": "{count, plural, one {# samtale slettet} other {# samtaler slettet}}",
+ "conversations_moved_to": "{count, plural, one {# samtale flyttet til {mailbox}} other {# samtaler flyttet til {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# samtale flyttet} other {# samtaler flyttet}}",
+ "select_all_in_folder": "{count, plural, one {Velg den # samtalen i {folder}} other {Velg alle # samtaler i {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Velg den # e-posten i {folder}} other {Velg alle # e-poster i {folder}}}",
+ "select_all_conversations": "{count, plural, one {Velg den # samtalen} other {Velg alle # samtaler}}",
+ "select_all_emails": "{count, plural, one {Velg den # e-posten} other {Velg alle # e-poster}}",
+ "selecting_all": "Velger…"
+ },
"shared_tags": {
"title": "Delte etiketter",
"description": "Etiketter i gruppepostkassene dine. Alle i gruppen ser de samme navnene og fargene på e-posten der.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Planlegg sending",
- "schedule_send_description": "Velg når serveren skal sende denne meldingen.",
+ "schedule_send_description": "Velg når den skal sendes.",
"schedule_preset": {
"this_morning": "I dag tidlig",
"this_afternoon": "I ettermiddag",
@@ -1163,6 +1175,7 @@
"edit_link": "Rediger lenke",
"remove_link": "Fjern lenke",
"link_address": "Lenkeadresse",
+ "link_text": "Tekst",
"link_invalid": "Adressen kan ikke inneholde mellomrom",
"table": "Tabell",
"clear_formatting": "Fjern formatering",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Mappen «{name}» inneholder fortsatt {count, plural, one {1 melding} other {# meldinger}}. Slette mappen og alle meldingene i den? Dette kan ikke angres.",
"delete_with_contents_confirm": "Slett mappe og meldinger"
},
+ "mail_p2_folders": {
+ "all_folders": "Alle mapper",
+ "empty_trash": "Tøm papirkurven",
+ "empty_spam": "Tøm søppelpost",
+ "empty_trash_confirm": "Slette {count, plural, one {meldingen} other {alle # meldingene}} i papirkurven for godt? Dette kan ikke angres.",
+ "empty_spam_confirm": "Slette {count, plural, one {meldingen} other {alle # meldingene}} i søppelpost for godt? Dette kan ikke angres.",
+ "title_all_accounts": "{name} ({count}, {total} i alle kontoer)",
+ "title_all_accounts_only": "{name} ({total} i alle kontoer)"
+ },
"uxs_folders": {
"manage_accounts": "Administrer kontoer…",
"apply_to_existing": "{count, plural, one {Bruk på # eksisterende melding i {folder}} other {Bruk på # eksisterende meldinger i {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Vis # eldre melding} other {Vis # eldre meldinger}}",
"return_to_list_follows_layout": "Inntil du endrer det, følger det oppsettet: av når listen og meldingen står side om side, ellers på."
},
+ "mail_p2_conv": {
+ "show_message": "Vis meldingen",
+ "back_to_reply": "Tilbake til svaret ditt"
+ },
"identities": {
"modal_title": "Administrer avsenderidentiteter",
"create_new": "Opprett ny identitet",
@@ -4105,6 +4131,9 @@
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
+ "template_saved": "Mal lagret",
+ "schedule_anyway": "Planlegg likevel",
+ "schedule_warnings_title": "Planlegge denne meldingen likevel?",
"picker_button": "Legg til fra kontakter",
"picker_title": "Legg til mottakere",
"tab_contacts": "Kontakter",
@@ -5560,7 +5589,7 @@
"title": "Velkommen til postkassen din",
"tip_compose": "Trykk c for å skrive en ny e-post",
"tip_shortcuts": "Trykk på ? for å se alle tastatursnarveier",
- "tip_sidebar": "Finn kalender, kontakter og innstillinger i menyen i sidefeltet",
+ "tip_sidebar": "Kalender, Kontakter og Innstillinger finner du i feltet til venstre",
"tip_settings": "Tilpass nettposten under Innstillinger",
"tip_compose_touch": "Trykk på blyantknappen for å skrive en ny e-post",
"tip_sidebar_touch": "Kalender, Kontakter og Innstillinger finner du i linjen nederst",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Søk etter «{text}» i e-post"
},
+ "mail_p2_desk": {
+ "reader_offline": "Du er frakoblet. Denne meldingen trenger tilkobling for å åpnes.",
+ "reader_failed": "Meldingen kunne ikke lastes inn.",
+ "retry": "Prøv igjen",
+ "action_offline": "Du er frakoblet. Dette trenger tilkobling.",
+ "offline_bar": "Du er frakoblet. Å lese og endre e-post trenger tilkobling.",
+ "offline_default_on": "E-posten din lagres på denne enheten, så du kan lese den uten nett."
+ },
"desktopMenuBar": {
"always_show_label": "Vis alltid menylinjen",
"always_show_description": "Ellers vises den når du trykker på Alt.",
diff --git a/locales/nl/common.json b/locales/nl/common.json
index f3749ed2d..745d09625 100644
--- a/locales/nl/common.json
+++ b/locales/nl/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nieuwe map",
"new_subfolder_title": "Nieuwe submap"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# gesprek gearchiveerd} other {# gesprekken gearchiveerd}}",
+ "conversations_to_trash": "{count, plural, one {# gesprek naar de prullenbak verplaatst} other {# gesprekken naar de prullenbak verplaatst}}",
+ "conversations_deleted": "{count, plural, one {# gesprek verwijderd} other {# gesprekken verwijderd}}",
+ "conversations_moved_to": "{count, plural, one {# gesprek verplaatst naar {mailbox}} other {# gesprekken verplaatst naar {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# gesprek verplaatst} other {# gesprekken verplaatst}}",
+ "select_all_in_folder": "{count, plural, one {Het # gesprek in {folder} selecteren} other {Alle # gesprekken in {folder} selecteren}}",
+ "select_all_emails_in_folder": "{count, plural, one {De # e-mail in {folder} selecteren} other {Alle # e-mails in {folder} selecteren}}",
+ "select_all_conversations": "{count, plural, one {Het # gesprek selecteren} other {Alle # gesprekken selecteren}}",
+ "select_all_emails": "{count, plural, one {De # e-mail selecteren} other {Alle # e-mails selecteren}}",
+ "selecting_all": "Selecteren…"
+ },
"shared_tags": {
"title": "Gedeelde labels",
"description": "Labels van je groepspostvakken. Iedereen in de groep ziet dezelfde namen en kleuren bij de e-mail ervan.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Voer de URL in",
"sending": "Bezig met verzenden...",
"schedule_send": "Verzenden plannen",
- "schedule_send_description": "Kies wanneer de server dit bericht moet vrijgeven.",
+ "schedule_send_description": "Kies wanneer het verzonden wordt.",
"schedule_preset": {
"this_morning": "Vanochtend",
"this_afternoon": "Vanmiddag",
@@ -1163,6 +1175,7 @@
"edit_link": "Link bewerken",
"remove_link": "Link verwijderen",
"link_address": "Linkadres",
+ "link_text": "Tekst",
"link_invalid": "Het adres mag geen spaties bevatten",
"table": "Tabel",
"clear_formatting": "Opmaak wissen",
@@ -3881,6 +3894,15 @@
"create": "Aanmaken",
"rename_confirm": "Hernoemen"
},
+ "mail_p2_folders": {
+ "all_folders": "Alle mappen",
+ "empty_trash": "Prullenbak legen",
+ "empty_spam": "Spam legen",
+ "empty_trash_confirm": "{count, plural, one {Het bericht} other {Alle # berichten}} in de prullenbak definitief verwijderen? Dit kan niet ongedaan worden.",
+ "empty_spam_confirm": "{count, plural, one {Het bericht} other {Alle # berichten}} in spam definitief verwijderen? Dit kan niet ongedaan worden.",
+ "title_all_accounts": "{name} ({count}, {total} in alle accounts)",
+ "title_all_accounts_only": "{name} ({total} in alle accounts)"
+ },
"uxs_folders": {
"manage_accounts": "Accounts beheren…",
"apply_to_existing": "{count, plural, one {Toepassen op # bestaand bericht in {folder}} other {Toepassen op # bestaande berichten in {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {# ouder bericht tonen} other {# oudere berichten tonen}}",
"return_to_list_follows_layout": "Zolang je het niet wijzigt, volgt dit de indeling: uit als de lijst en het bericht naast elkaar staan, anders aan."
},
+ "mail_p2_conv": {
+ "show_message": "Bericht tonen",
+ "back_to_reply": "Terug naar je antwoord"
+ },
"identities": {
"modal_title": "Verzendidentiteiten beheren",
"create_new": "Nieuwe identiteit aanmaken",
@@ -4105,6 +4131,9 @@
"sending": "Bezig met verzenden…",
"sent": "Verzonden",
"view": "Bekijken",
+ "template_saved": "Sjabloon opgeslagen",
+ "schedule_anyway": "Toch plannen",
+ "schedule_warnings_title": "Dit bericht toch plannen?",
"picker_button": "Toevoegen uit contacten",
"picker_title": "Ontvangers toevoegen",
"tab_contacts": "Contacten",
@@ -5298,7 +5327,7 @@
"title": "Welkom bij uw mailbox",
"tip_compose": "Druk op c om een nieuwe e-mail te schrijven",
"tip_shortcuts": "Druk op ? om alle sneltoetsen te bekijken",
- "tip_sidebar": "Vind Agenda, Contacten en Instellingen in het zijmenu",
+ "tip_sidebar": "Agenda, Contacten en Instellingen staan in de balk links",
"tip_settings": "Pas uw ervaring aan in Instellingen",
"tip_compose_touch": "Tik op de potloodknop om een nieuwe e-mail te schrijven",
"tip_sidebar_touch": "Agenda, Contacten en Instellingen vind je in de balk onderaan",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Zoek ‘{text}’ in e-mail"
},
+ "mail_p2_desk": {
+ "reader_offline": "Je bent offline. Dit bericht heeft een verbinding nodig om te openen.",
+ "reader_failed": "Dit bericht kon niet worden geladen.",
+ "retry": "Opnieuw proberen",
+ "action_offline": "Je bent offline. Hiervoor is een verbinding nodig.",
+ "offline_bar": "Je bent offline. Voor het lezen en wijzigen van mail is een verbinding nodig.",
+ "offline_default_on": "Je mail wordt op dit apparaat bewaard, zodat je hem offline kunt lezen."
+ },
"desktopMenuBar": {
"always_show_label": "Menubalk altijd tonen",
"always_show_description": "Anders verschijnt hij bij een druk op Alt.",
diff --git a/locales/pl/common.json b/locales/pl/common.json
index 94bd5dcfa..e41198cf5 100644
--- a/locales/pl/common.json
+++ b/locales/pl/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nowy folder",
"new_subfolder_title": "Nowy podfolder"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {Zarchiwizowano # konwersację} few {Zarchiwizowano # konwersacje} many {Zarchiwizowano # konwersacji} other {Zarchiwizowano # konwersacji}}",
+ "conversations_to_trash": "{count, plural, one {Przeniesiono # konwersację do kosza} few {Przeniesiono # konwersacje do kosza} many {Przeniesiono # konwersacji do kosza} other {Przeniesiono # konwersacji do kosza}}",
+ "conversations_deleted": "{count, plural, one {Usunięto # konwersację} few {Usunięto # konwersacje} many {Usunięto # konwersacji} other {Usunięto # konwersacji}}",
+ "conversations_moved_to": "{count, plural, one {Przeniesiono # konwersację do {mailbox}} few {Przeniesiono # konwersacje do {mailbox}} many {Przeniesiono # konwersacji do {mailbox}} other {Przeniesiono # konwersacji do {mailbox}}}",
+ "conversations_moved": "{count, plural, one {Przeniesiono # konwersację} few {Przeniesiono # konwersacje} many {Przeniesiono # konwersacji} other {Przeniesiono # konwersacji}}",
+ "select_all_in_folder": "{count, plural, one {Zaznacz # konwersację w folderze {folder}} few {Zaznacz wszystkie # konwersacje w folderze {folder}} many {Zaznacz wszystkie # konwersacji w folderze {folder}} other {Zaznacz wszystkie # konwersacji w folderze {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Zaznacz # wiadomość w folderze {folder}} few {Zaznacz wszystkie # wiadomości w folderze {folder}} many {Zaznacz wszystkie # wiadomości w folderze {folder}} other {Zaznacz wszystkie # wiadomości w folderze {folder}}}",
+ "select_all_conversations": "{count, plural, one {Zaznacz # konwersację} few {Zaznacz wszystkie # konwersacje} many {Zaznacz wszystkie # konwersacji} other {Zaznacz wszystkie # konwersacji}}",
+ "select_all_emails": "{count, plural, one {Zaznacz # wiadomość} few {Zaznacz wszystkie # wiadomości} many {Zaznacz wszystkie # wiadomości} other {Zaznacz wszystkie # wiadomości}}",
+ "selecting_all": "Zaznaczanie…"
+ },
"shared_tags": {
"title": "Wspólne etykiety",
"description": "Etykiety Twoich skrzynek grupowych. Wszyscy w grupie widzą te same nazwy i kolory przy jej wiadomościach.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Wprowadź adres URL",
"sending": "Wysyłanie...",
"schedule_send": "Zaplanuj wysyłkę",
- "schedule_send_description": "Wybierz, kiedy serwer ma wysłać tę wiadomość.",
+ "schedule_send_description": "Wybierz, kiedy ją wysłać.",
"schedule_preset": {
"this_morning": "Dziś rano",
"this_afternoon": "Dziś po południu",
@@ -1163,6 +1175,7 @@
"edit_link": "Edytuj link",
"remove_link": "Usuń link",
"link_address": "Adres linku",
+ "link_text": "Tekst",
"link_invalid": "Adres nie może zawierać spacji",
"table": "Tabela",
"clear_formatting": "Wyczyść formatowanie",
@@ -3881,6 +3894,15 @@
"create": "Utwórz",
"rename_confirm": "Zmień nazwę"
},
+ "mail_p2_folders": {
+ "all_folders": "Wszystkie foldery",
+ "empty_trash": "Opróżnij kosz",
+ "empty_spam": "Opróżnij spam",
+ "empty_trash_confirm": "Trwale usunąć {count, plural, one {wiadomość} few {wszystkie # wiadomości} many {wszystkie # wiadomości} other {wszystkie # wiadomości}} z kosza? Nie można tego cofnąć.",
+ "empty_spam_confirm": "Trwale usunąć {count, plural, one {wiadomość} few {wszystkie # wiadomości} many {wszystkie # wiadomości} other {wszystkie # wiadomości}} ze spamu? Nie można tego cofnąć.",
+ "title_all_accounts": "{name} ({count}, {total} we wszystkich kontach)",
+ "title_all_accounts_only": "{name} ({total} we wszystkich kontach)"
+ },
"uxs_folders": {
"manage_accounts": "Zarządzaj kontami…",
"apply_to_existing": "{count, plural, one {Zastosuj do # istniejącej wiadomości w folderze {folder}} few {Zastosuj do # istniejących wiadomości w folderze {folder}} many {Zastosuj do # istniejących wiadomości w folderze {folder}} other {Zastosuj do # istniejącej wiadomości w folderze {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Pokaż # starszą wiadomość} few {Pokaż # starsze wiadomości} many {Pokaż # starszych wiadomości} other {Pokaż # starszej wiadomości}}",
"return_to_list_follows_layout": "Dopóki tego nie zmienisz, ustawienie zależy od układu: wyłączone, gdy lista i wiadomość są obok siebie, w innych przypadkach włączone."
},
+ "mail_p2_conv": {
+ "show_message": "Pokaż wiadomość",
+ "back_to_reply": "Wróć do odpowiedzi"
+ },
"identities": {
"modal_title": "Zarządzaj tożsamościami nadawcy",
"create_new": "Utwórz nową tożsamość",
@@ -4105,6 +4131,9 @@
"sending": "Wysyłanie…",
"sent": "Wysłano",
"view": "Pokaż",
+ "template_saved": "Szablon zapisany",
+ "schedule_anyway": "Zaplanuj mimo to",
+ "schedule_warnings_title": "Zaplanować tę wiadomość mimo to?",
"picker_button": "Dodaj z kontaktów",
"picker_title": "Dodaj odbiorców",
"tab_contacts": "Kontakty",
@@ -5298,7 +5327,7 @@
"title": "Witaj w swojej skrzynce pocztowej",
"tip_compose": "Naciśnij c, aby napisać nową wiadomość e-mail",
"tip_shortcuts": "Naciśnij ?, aby zobaczyć wszystkie skróty klawiszowe",
- "tip_sidebar": "Kalendarz, Kontakty i Ustawienia znajdziesz w menu bocznym",
+ "tip_sidebar": "Kalendarz, Kontakty i Ustawienia są na pasku po lewej",
"tip_settings": "Dostosuj działanie aplikacji w Ustawieniach",
"tip_compose_touch": "Stuknij przycisk z ołówkiem, aby napisać nowy e-mail",
"tip_sidebar_touch": "Kalendarz, Kontakty i Ustawienia znajdziesz na dolnym pasku",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Szukaj „{text}” w poczcie"
},
+ "mail_p2_desk": {
+ "reader_offline": "Jesteś offline. Ta wiadomość otworzy się tylko z połączeniem.",
+ "reader_failed": "Nie udało się wczytać tej wiadomości.",
+ "retry": "Spróbuj ponownie",
+ "action_offline": "Jesteś offline. To wymaga połączenia.",
+ "offline_bar": "Jesteś offline. Czytanie i zmienianie poczty wymaga połączenia.",
+ "offline_default_on": "Twoja poczta jest przechowywana na tym urządzeniu, więc możesz ją czytać offline."
+ },
"desktopMenuBar": {
"always_show_label": "Zawsze pokazuj pasek menu",
"always_show_description": "W przeciwnym razie pojawia się po naciśnięciu Alt.",
diff --git a/locales/pt/common.json b/locales/pt/common.json
index 12e3228a7..7ee51ce73 100644
--- a/locales/pt/common.json
+++ b/locales/pt/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nova pasta",
"new_subfolder_title": "Nova subpasta"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversa arquivada} other {# conversas arquivadas}}",
+ "conversations_to_trash": "{count, plural, one {# conversa movida para a lixeira} other {# conversas movidas para a lixeira}}",
+ "conversations_deleted": "{count, plural, one {# conversa excluída} other {# conversas excluídas}}",
+ "conversations_moved_to": "{count, plural, one {# conversa movida para {mailbox}} other {# conversas movidas para {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversa movida} other {# conversas movidas}}",
+ "select_all_in_folder": "{count, plural, one {Selecionar a # conversa em {folder}} other {Selecionar todas as # conversas em {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Selecionar o # e-mail em {folder}} other {Selecionar todos os # e-mails em {folder}}}",
+ "select_all_conversations": "{count, plural, one {Selecionar a # conversa} other {Selecionar todas as # conversas}}",
+ "select_all_emails": "{count, plural, one {Selecionar o # e-mail} other {Selecionar todos os # e-mails}}",
+ "selecting_all": "Selecionando…"
+ },
"shared_tags": {
"title": "Etiquetas compartilhadas",
"description": "Etiquetas das suas caixas de grupo. Todos no grupo veem os mesmos nomes e cores nos e-mails dela.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Insira a URL",
"sending": "Enviando...",
"schedule_send": "Agendar envio",
- "schedule_send_description": "Escolha quando o servidor deve liberar esta mensagem.",
+ "schedule_send_description": "Escolha quando enviar.",
"schedule_preset": {
"this_morning": "Esta manhã",
"this_afternoon": "Esta tarde",
@@ -1163,6 +1175,7 @@
"edit_link": "Editar link",
"remove_link": "Remover link",
"link_address": "Endereço do link",
+ "link_text": "Texto",
"link_invalid": "O endereço não pode conter espaços",
"table": "Tabela",
"clear_formatting": "Limpar formatação",
@@ -3881,6 +3894,15 @@
"create": "Criar",
"rename_confirm": "Renomear"
},
+ "mail_p2_folders": {
+ "all_folders": "Todas as pastas",
+ "empty_trash": "Esvaziar lixeira",
+ "empty_spam": "Esvaziar spam",
+ "empty_trash_confirm": "Excluir para sempre {count, plural, one {a mensagem} other {as # mensagens}} da lixeira? Não é possível desfazer.",
+ "empty_spam_confirm": "Excluir para sempre {count, plural, one {a mensagem} other {as # mensagens}} do spam? Não é possível desfazer.",
+ "title_all_accounts": "{name} ({count}, {total} em todas as contas)",
+ "title_all_accounts_only": "{name} ({total} em todas as contas)"
+ },
"uxs_folders": {
"manage_accounts": "Gerir contas…",
"apply_to_existing": "{count, plural, one {Aplicar a # mensagem existente em {folder}} other {Aplicar a # mensagens existentes em {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Mostrar # mensagem anterior} other {Mostrar # mensagens anteriores}}",
"return_to_list_follows_layout": "Enquanto não alterar, segue o layout: desligado quando a lista e a mensagem estão lado a lado, ligado nos outros casos."
},
+ "mail_p2_conv": {
+ "show_message": "Mostrar a mensagem",
+ "back_to_reply": "Voltar à sua resposta"
+ },
"identities": {
"modal_title": "Gerenciar Identidades de Envio",
"create_new": "Criar Nova Identidade",
@@ -4105,6 +4131,9 @@
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
+ "template_saved": "Modelo guardado",
+ "schedule_anyway": "Agendar mesmo assim",
+ "schedule_warnings_title": "Agendar esta mensagem mesmo assim?",
"picker_button": "Adicionar dos contatos",
"picker_title": "Adicionar destinatários",
"tab_contacts": "Contatos",
@@ -5298,7 +5327,7 @@
"title": "Bem-vindo à sua caixa de entrada",
"tip_compose": "Pressione c para escrever um novo e-mail",
"tip_shortcuts": "Pressione ? para ver todos os atalhos de teclado",
- "tip_sidebar": "Encontre Calendário, Contatos e Configurações no menu lateral",
+ "tip_sidebar": "Calendário, Contactos e Definições estão na barra à esquerda",
"tip_settings": "Personalize sua experiência nas Configurações",
"tip_compose_touch": "Toque no botão de lápis para escrever um novo e-mail",
"tip_sidebar_touch": "Calendário, Contatos e Configurações ficam na barra inferior",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Pesquisar «{text}» no correio"
},
+ "mail_p2_desk": {
+ "reader_offline": "Está offline. Esta mensagem precisa de ligação para abrir.",
+ "reader_failed": "Não foi possível carregar esta mensagem.",
+ "retry": "Tentar novamente",
+ "action_offline": "Está offline. Isto precisa de ligação.",
+ "offline_bar": "Está offline. Ler e alterar e-mails precisa de ligação.",
+ "offline_default_on": "Os seus e-mails ficam guardados neste dispositivo, para os poder ler offline."
+ },
"desktopMenuBar": {
"always_show_label": "Mostrar sempre a barra de menus",
"always_show_description": "Caso contrário, aparece ao premir Alt.",
diff --git a/locales/ro/common.json b/locales/ro/common.json
index e55f6ff59..c3f14d6b1 100644
--- a/locales/ro/common.json
+++ b/locales/ro/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Dosar nou",
"new_subfolder_title": "Subfolder nou"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# conversație arhivată} few {# conversații arhivate} other {# de conversații arhivate}}",
+ "conversations_to_trash": "{count, plural, one {# conversație mutată în coșul de gunoi} few {# conversații mutate în coșul de gunoi} other {# de conversații mutate în coșul de gunoi}}",
+ "conversations_deleted": "{count, plural, one {# conversație ștearsă} few {# conversații șterse} other {# de conversații șterse}}",
+ "conversations_moved_to": "{count, plural, one {# conversație mutată în {mailbox}} few {# conversații mutate în {mailbox}} other {# de conversații mutate în {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# conversație mutată} few {# conversații mutate} other {# de conversații mutate}}",
+ "select_all_in_folder": "{count, plural, one {Selectează # conversație din {folder}} few {Selectează toate cele # conversații din {folder}} other {Selectează toate cele # de conversații din {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Selectează # e-mail din {folder}} few {Selectează toate cele # e-mailuri din {folder}} other {Selectează toate cele # de e-mailuri din {folder}}}",
+ "select_all_conversations": "{count, plural, one {Selectează # conversație} few {Selectează toate cele # conversații} other {Selectează toate cele # de conversații}}",
+ "select_all_emails": "{count, plural, one {Selectează # e-mail} few {Selectează toate cele # e-mailuri} other {Selectează toate cele # de e-mailuri}}",
+ "selecting_all": "Se selectează…"
+ },
"shared_tags": {
"title": "Etichete partajate",
"description": "Etichetele căsuțelor dvs. de grup. Toți membrii grupului văd aceleași nume și culori pe mesajele lui.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Programează trimiterea",
- "schedule_send_description": "Alegeți momentul în care serverul ar trebui să transmită acest mesaj.",
+ "schedule_send_description": "Alegeți când să fie trimis.",
"schedule_preset": {
"this_morning": "Azi dimineață",
"this_afternoon": "Azi după-amiază",
@@ -1163,6 +1175,7 @@
"edit_link": "Editează linkul",
"remove_link": "Elimină linkul",
"link_address": "Adresa linkului",
+ "link_text": "Text",
"link_invalid": "Adresa nu poate conține spații",
"table": "Tabel",
"clear_formatting": "Șterge formatarea",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Dosarul „{name}” încă mai conține {count, plural, one {1 mesaj} other {# mesaje}}. Ștergi dosarul și toate mesajele sale? Acțiunea nu poate fi anulată.",
"delete_with_contents_confirm": "Șterge dosarul și mesajele"
},
+ "mail_p2_folders": {
+ "all_folders": "Toate dosarele",
+ "empty_trash": "Golește coșul de gunoi",
+ "empty_spam": "Golește spamul",
+ "empty_trash_confirm": "Ștergeți definitiv {count, plural, one {mesajul} few {toate cele # mesaje} other {toate cele # de mesaje}} din coșul de gunoi? Nu se poate anula.",
+ "empty_spam_confirm": "Ștergeți definitiv {count, plural, one {mesajul} few {toate cele # mesaje} other {toate cele # de mesaje}} din spam? Nu se poate anula.",
+ "title_all_accounts": "{name} ({count}, {total} în toate conturile)",
+ "title_all_accounts_only": "{name} ({total} în toate conturile)"
+ },
"uxs_folders": {
"manage_accounts": "Gestionați conturile…",
"apply_to_existing": "{count, plural, one {Aplicați la # mesaj existent din {folder}} few {Aplicați la # mesaje existente din {folder}} other {Aplicați la # de mesaje existente din {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Afișează # mesaj mai vechi} few {Afișează # mesaje mai vechi} other {Afișează # de mesaje mai vechi}}",
"return_to_list_follows_layout": "Cât timp nu îl modificați, urmează aspectul: dezactivat când lista și mesajul sunt unul lângă altul, activat în rest."
},
+ "mail_p2_conv": {
+ "show_message": "Afișează mesajul",
+ "back_to_reply": "Înapoi la răspuns"
+ },
"identities": {
"modal_title": "Gestionarea identităților de expediere",
"create_new": "Creați o identitate nouă",
@@ -4105,6 +4131,9 @@
"sending": "Se trimite…",
"sent": "Trimis",
"view": "Vezi",
+ "template_saved": "Șablon salvat",
+ "schedule_anyway": "Programează oricum",
+ "schedule_warnings_title": "Programați totuși acest mesaj?",
"picker_button": "Adaugă din contacte",
"picker_title": "Adaugă destinatari",
"tab_contacts": "Contacte",
@@ -5560,7 +5589,7 @@
"title": "Bine ați venit în căsuța dvs. poștală",
"tip_compose": "Apăsați tasta c pentru a redacta un e-mail nou",
"tip_shortcuts": "Apăsați tasta „?” pentru a vedea toate comenzile rapide de la tastatură",
- "tip_sidebar": "Găsiți Calendar, Contacte și Setări în meniul din bara laterală",
+ "tip_sidebar": "Calendarul, Contactele și Setările sunt în bara din stânga",
"tip_settings": "Personalizați-vă experiența în Setări",
"tip_compose_touch": "Atinge butonul cu creion pentru a scrie un e-mail nou",
"tip_sidebar_touch": "Calendarul, Contactele și Setările sunt în bara de jos",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Căutare „{text}” în e-mail"
},
+ "mail_p2_desk": {
+ "reader_offline": "Sunteți offline. Acest mesaj are nevoie de conexiune ca să se deschidă.",
+ "reader_failed": "Mesajul nu a putut fi încărcat.",
+ "retry": "Încercați din nou",
+ "action_offline": "Sunteți offline. Pentru asta e nevoie de conexiune.",
+ "offline_bar": "Sunteți offline. Citirea și modificarea e-mailurilor au nevoie de conexiune.",
+ "offline_default_on": "E-mailurile dvs. sunt păstrate pe acest dispozitiv, ca să le puteți citi offline."
+ },
"desktopMenuBar": {
"always_show_label": "Afișează mereu bara de meniu",
"always_show_description": "Altfel apare la apăsarea tastei Alt.",
diff --git a/locales/ru/common.json b/locales/ru/common.json
index 621563169..115d67a87 100644
--- a/locales/ru/common.json
+++ b/locales/ru/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Новая папка",
"new_subfolder_title": "Новая вложенная папка"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# беседа перемещена в архив} few {# беседы перемещены в архив} many {# бесед перемещено в архив} other {# беседы перемещено в архив}}",
+ "conversations_to_trash": "{count, plural, one {# беседа перемещена в корзину} few {# беседы перемещены в корзину} many {# бесед перемещено в корзину} other {# беседы перемещено в корзину}}",
+ "conversations_deleted": "{count, plural, one {# беседа удалена} few {# беседы удалены} many {# бесед удалено} other {# беседы удалено}}",
+ "conversations_moved_to": "{count, plural, one {# беседа перемещена в {mailbox}} few {# беседы перемещены в {mailbox}} many {# бесед перемещено в {mailbox}} other {# беседы перемещено в {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# беседа перемещена} few {# беседы перемещены} many {# бесед перемещено} other {# беседы перемещено}}",
+ "select_all_in_folder": "{count, plural, one {Выбрать # беседу в папке {folder}} few {Выбрать все # беседы в папке {folder}} many {Выбрать все # бесед в папке {folder}} other {Выбрать все # беседы в папке {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Выбрать # письмо в папке {folder}} few {Выбрать все # письма в папке {folder}} many {Выбрать все # писем в папке {folder}} other {Выбрать все # письма в папке {folder}}}",
+ "select_all_conversations": "{count, plural, one {Выбрать # беседу} few {Выбрать все # беседы} many {Выбрать все # бесед} other {Выбрать все # беседы}}",
+ "select_all_emails": "{count, plural, one {Выбрать # письмо} few {Выбрать все # письма} many {Выбрать все # писем} other {Выбрать все # письма}}",
+ "selecting_all": "Выбор…"
+ },
"shared_tags": {
"title": "Общие теги",
"description": "Теги ваших групповых ящиков. Все в группе видят одинаковые названия и цвета у её писем.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Введите URL",
"sending": "Отправка...",
"schedule_send": "Запланировать отправку",
- "schedule_send_description": "Выберите, когда сервер должен отправить это сообщение.",
+ "schedule_send_description": "Выберите, когда отправить.",
"schedule_preset": {
"this_morning": "Сегодня утром",
"this_afternoon": "Сегодня днём",
@@ -1163,6 +1175,7 @@
"edit_link": "Изменить ссылку",
"remove_link": "Убрать ссылку",
"link_address": "Адрес ссылки",
+ "link_text": "Текст",
"link_invalid": "В адресе не должно быть пробелов",
"table": "Таблица",
"clear_formatting": "Очистить форматирование",
@@ -3881,6 +3894,15 @@
"create": "Создать",
"rename_confirm": "Переименовать"
},
+ "mail_p2_folders": {
+ "all_folders": "Все папки",
+ "empty_trash": "Очистить корзину",
+ "empty_spam": "Очистить спам",
+ "empty_trash_confirm": "Удалить {count, plural, =1 {письмо} one {все # письмо} few {все # письма} many {все # писем} other {все # письма}} в корзине навсегда? Это нельзя отменить.",
+ "empty_spam_confirm": "Удалить {count, plural, =1 {письмо} one {все # письмо} few {все # письма} many {все # писем} other {все # письма}} в спаме навсегда? Это нельзя отменить.",
+ "title_all_accounts": "{name} ({count}, {total} во всех аккаунтах)",
+ "title_all_accounts_only": "{name} ({total} во всех аккаунтах)"
+ },
"uxs_folders": {
"manage_accounts": "Управление аккаунтами…",
"apply_to_existing": "{count, plural, one {Применить к # имеющемуся письму в папке {folder}} few {Применить к # имеющимся письмам в папке {folder}} many {Применить к # имеющимся письмам в папке {folder}} other {Применить к # имеющегося письма в папке {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Показать # более раннее письмо} few {Показать # более ранних письма} many {Показать # более ранних писем} other {Показать # более раннего письма}}",
"return_to_list_follows_layout": "Пока вы это не измените, настройка зависит от макета: выключена, когда список и письмо стоят рядом, в остальных случаях включена."
},
+ "mail_p2_conv": {
+ "show_message": "Показать письмо",
+ "back_to_reply": "Вернуться к ответу"
+ },
"identities": {
"modal_title": "Управление идентификациями отправителя",
"create_new": "Создать новую идентификацию",
@@ -4105,6 +4131,9 @@
"sending": "Отправка…",
"sent": "Отправлено",
"view": "Открыть",
+ "template_saved": "Шаблон сохранён",
+ "schedule_anyway": "Всё равно запланировать",
+ "schedule_warnings_title": "Всё равно запланировать это письмо?",
"picker_button": "Добавить из контактов",
"picker_title": "Добавить получателей",
"tab_contacts": "Контакты",
@@ -5298,7 +5327,7 @@
"title": "Добро пожаловать в ваш почтовый ящик",
"tip_compose": "Нажмите c для создания нового письма",
"tip_shortcuts": "Нажмите ? для просмотра всех сочетаний клавиш",
- "tip_sidebar": "Найдите Календарь, Контакты и Настройки в боковом меню",
+ "tip_sidebar": "Календарь, контакты и настройки находятся на панели слева",
"tip_settings": "Настройте интерфейс в разделе «Настройки»",
"tip_compose_touch": "Нажмите кнопку с карандашом, чтобы написать новое письмо",
"tip_sidebar_touch": "Календарь, Контакты и Настройки находятся на нижней панели",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Найти «{text}» в почте"
},
+ "mail_p2_desk": {
+ "reader_offline": "Нет подключения. Чтобы открыть это письмо, нужно подключение.",
+ "reader_failed": "Не удалось загрузить это письмо.",
+ "retry": "Повторить",
+ "action_offline": "Нет подключения. Для этого нужно подключение.",
+ "offline_bar": "Нет подключения. Чтобы читать и изменять почту, нужно подключение.",
+ "offline_default_on": "Ваша почта хранится на этом устройстве, поэтому её можно читать без сети."
+ },
"desktopMenuBar": {
"always_show_label": "Всегда показывать строку меню",
"always_show_description": "Иначе она появляется при нажатии Alt.",
diff --git a/locales/sk/common.json b/locales/sk/common.json
index 00ebf0f8f..09bd12f91 100644
--- a/locales/sk/common.json
+++ b/locales/sk/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Nový priečinok",
"new_subfolder_title": "Nový podpriečinok"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# konverzácia archivovaná} few {# konverzácie archivované} many {# konverzácií archivovaných} other {# konverzácií archivovaných}}",
+ "conversations_to_trash": "{count, plural, one {# konverzácia presunutá do koša} few {# konverzácie presunuté do koša} many {# konverzácií presunutých do koša} other {# konverzácií presunutých do koša}}",
+ "conversations_deleted": "{count, plural, one {# konverzácia odstránená} few {# konverzácie odstránené} many {# konverzácií odstránených} other {# konverzácií odstránených}}",
+ "conversations_moved_to": "{count, plural, one {# konverzácia presunutá do {mailbox}} few {# konverzácie presunuté do {mailbox}} many {# konverzácií presunutých do {mailbox}} other {# konverzácií presunutých do {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# konverzácia presunutá} few {# konverzácie presunuté} many {# konverzácií presunutých} other {# konverzácií presunutých}}",
+ "select_all_in_folder": "{count, plural, one {Vybrať # konverzáciu v priečinku {folder}} few {Vybrať všetky # konverzácie v priečinku {folder}} many {Vybrať všetkých # konverzácií v priečinku {folder}} other {Vybrať všetkých # konverzácií v priečinku {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Vybrať # e-mail v priečinku {folder}} few {Vybrať všetky # e-maily v priečinku {folder}} many {Vybrať všetkých # e-mailov v priečinku {folder}} other {Vybrať všetkých # e-mailov v priečinku {folder}}}",
+ "select_all_conversations": "{count, plural, one {Vybrať # konverzáciu} few {Vybrať všetky # konverzácie} many {Vybrať všetkých # konverzácií} other {Vybrať všetkých # konverzácií}}",
+ "select_all_emails": "{count, plural, one {Vybrať # e-mail} few {Vybrať všetky # e-maily} many {Vybrať všetkých # e-mailov} other {Vybrať všetkých # e-mailov}}",
+ "selecting_all": "Vyberá sa…"
+ },
"shared_tags": {
"title": "Zdieľané štítky",
"description": "Štítky vašich skupinových schránok. Všetci v skupine vidia pri jej pošte rovnaké názvy a farby.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Naplánovať odoslanie",
- "schedule_send_description": "Vyberte, kedy má server túto správu odoslať.",
+ "schedule_send_description": "Vyberte, kedy sa má odoslať.",
"schedule_preset": {
"this_morning": "Dnes ráno",
"this_afternoon": "Dnes popoludní",
@@ -1163,6 +1175,7 @@
"edit_link": "Upraviť odkaz",
"remove_link": "Odstrániť odkaz",
"link_address": "Adresa odkazu",
+ "link_text": "Text",
"link_invalid": "Adresa nesmie obsahovať medzery",
"table": "Tabuľka",
"clear_formatting": "Vymazať formátovanie",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "Priečinok „{name}“ stále obsahuje {count, plural, one {1 správu} few {# správy} other {# správ}}. Odstrániť priečinok aj všetky jeho správy? Túto akciu nie je možné vrátiť späť.",
"delete_with_contents_confirm": "Odstrániť priečinok a správy"
},
+ "mail_p2_folders": {
+ "all_folders": "Všetky priečinky",
+ "empty_trash": "Vyprázdniť kôš",
+ "empty_spam": "Vyprázdniť spam",
+ "empty_trash_confirm": "Natrvalo odstrániť {count, plural, one {správu} few {všetky # správy} many {všetkých # správy} other {všetkých # správ}} v koši? Nedá sa to vrátiť späť.",
+ "empty_spam_confirm": "Natrvalo odstrániť {count, plural, one {správu} few {všetky # správy} many {všetkých # správy} other {všetkých # správ}} v spame? Nedá sa to vrátiť späť.",
+ "title_all_accounts": "{name} ({count}, {total} vo všetkých účtoch)",
+ "title_all_accounts_only": "{name} ({total} vo všetkých účtoch)"
+ },
"uxs_folders": {
"manage_accounts": "Spravovať účty…",
"apply_to_existing": "{count, plural, one {Použiť na # existujúcu správu v priečinku {folder}} few {Použiť na # existujúce správy v priečinku {folder}} many {Použiť na # existujúcej správy v priečinku {folder}} other {Použiť na # existujúcich správ v priečinku {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Zobraziť # staršiu správu} few {Zobraziť # staršie správy} many {Zobraziť # staršej správy} other {Zobraziť # starších správ}}",
"return_to_list_follows_layout": "Kým to nezmeníte, riadi sa to rozložením: vypnuté, keď sú zoznam a správa vedľa seba, inak zapnuté."
},
+ "mail_p2_conv": {
+ "show_message": "Zobraziť správu",
+ "back_to_reply": "Späť na odpoveď"
+ },
"identities": {
"modal_title": "Správa identít odosielateľa",
"create_new": "Vytvoriť novú identitu",
@@ -4105,6 +4131,9 @@
"sending": "Odosielanie…",
"sent": "Odoslané",
"view": "Zobraziť",
+ "template_saved": "Šablóna uložená",
+ "schedule_anyway": "Naplánovať aj tak",
+ "schedule_warnings_title": "Naplánovať túto správu aj tak?",
"picker_button": "Pridať z kontaktov",
"picker_title": "Pridať príjemcov",
"tab_contacts": "Kontakty",
@@ -5560,7 +5589,7 @@
"title": "Vitajte vo svojej poštovej schránke",
"tip_compose": "Stlačte c na napísanie nového e-mailu",
"tip_shortcuts": "Stlačte ? na zobrazenie všetkých klávesových skratiek",
- "tip_sidebar": "Kalendár, Kontakty a Nastavenia nájdete v postrannom paneli",
+ "tip_sidebar": "Kalendár, Kontakty a Nastavenia nájdete na lište vľavo",
"tip_settings": "Prispôsobte si aplikáciu v Nastaveniach",
"tip_compose_touch": "Ťuknutím na tlačidlo s ceruzkou napíšete nový e-mail",
"tip_sidebar_touch": "Kalendár, Kontakty a Nastavenia nájdete na lište dole",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Hľadať „{text}“ v pošte"
},
+ "mail_p2_desk": {
+ "reader_offline": "Ste offline. Táto správa sa otvorí len s pripojením.",
+ "reader_failed": "Túto správu sa nepodarilo načítať.",
+ "retry": "Skúsiť znova",
+ "action_offline": "Ste offline. Na to je potrebné pripojenie.",
+ "offline_bar": "Ste offline. Na čítanie a úpravy pošty je potrebné pripojenie.",
+ "offline_default_on": "Vaša pošta je uložená v tomto zariadení, takže ju môžete čítať offline."
+ },
"desktopMenuBar": {
"always_show_label": "Vždy zobrazovať panel ponúk",
"always_show_description": "Inak sa zobrazí po stlačení klávesu Alt.",
diff --git a/locales/th/common.json b/locales/th/common.json
index 288de3711..02312932f 100644
--- a/locales/th/common.json
+++ b/locales/th/common.json
@@ -639,6 +639,10 @@
"show_older": "{count, plural, other {แสดงข้อความก่อนหน้า # ฉบับ}}",
"return_to_list_follows_layout": "จนกว่าคุณจะเปลี่ยน ค่านี้จะเป็นไปตามเลย์เอาต์: ปิดเมื่อรายการและข้อความอยู่เคียงข้างกัน และเปิดในกรณีอื่น"
},
+ "mail_p2_conv": {
+ "show_message": "แสดงข้อความ",
+ "back_to_reply": "กลับไปที่คำตอบของคุณ"
+ },
"notifications": {
"email_sent": "ส่งอีเมลแล้ว",
"email_deleted": "ลบอีเมลแล้ว",
@@ -901,6 +905,18 @@
"new_folder_title": "สร้างโฟลเดอร์ใหม่",
"new_subfolder_title": "สร้างโฟลเดอร์ย่อยใหม่"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {เก็บถาวร # การสนทนาแล้ว}}",
+ "conversations_to_trash": "{count, plural, other {ย้าย # การสนทนาไปถังขยะแล้ว}}",
+ "conversations_deleted": "{count, plural, other {ลบ # การสนทนาแล้ว}}",
+ "conversations_moved_to": "{count, plural, other {ย้าย # การสนทนาไปยัง {mailbox} แล้ว}}",
+ "conversations_moved": "{count, plural, other {ย้าย # การสนทนาแล้ว}}",
+ "select_all_in_folder": "{count, plural, other {เลือกการสนทนาทั้งหมด # รายการใน {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, other {เลือกอีเมลทั้งหมด # ฉบับใน {folder}}}",
+ "select_all_conversations": "{count, plural, other {เลือกการสนทนาทั้งหมด # รายการ}}",
+ "select_all_emails": "{count, plural, other {เลือกอีเมลทั้งหมด # ฉบับ}}",
+ "selecting_all": "กำลังเลือก…"
+ },
"shared_tags": {
"title": "ป้ายกำกับที่แชร์",
"description": "ป้ายกำกับของกล่องจดหมายกลุ่มของคุณ ทุกคนในกลุ่มจะเห็นชื่อและสีเดียวกันบนอีเมลของกลุ่ม",
@@ -1032,6 +1048,15 @@
"delete_with_contents_message": "โฟลเดอร์ “{name}” ยังมีข้อความอยู่ {count, plural, other {# ข้อความ}} ต้องการลบโฟลเดอร์และข้อความทั้งหมดภายในหรือไม่? การดำเนินการนี้ไม่สามารถยกเลิกได้",
"delete_with_contents_confirm": "ลบโฟลเดอร์และข้อความทั้งหมด"
},
+ "mail_p2_folders": {
+ "all_folders": "โฟลเดอร์ทั้งหมด",
+ "empty_trash": "ล้างถังขยะ",
+ "empty_spam": "ล้างสแปม",
+ "empty_trash_confirm": "ลบข้อความ {count, plural, other {ทั้ง # ฉบับ}} ในถังขยะอย่างถาวรไหม ย้อนกลับไม่ได้",
+ "empty_spam_confirm": "ลบข้อความ {count, plural, other {ทั้ง # ฉบับ}} ในสแปมอย่างถาวรไหม ย้อนกลับไม่ได้",
+ "title_all_accounts": "{name} ({count}, {total} ในทุกบัญชี)",
+ "title_all_accounts_only": "{name} ({total} ในทุกบัญชี)"
+ },
"uxs_folders": {
"manage_accounts": "จัดการบัญชี…",
"apply_to_existing": "{count, plural, other {นำไปใช้กับข้อความเดิม # ฉบับใน {folder}}}"
@@ -1236,6 +1261,9 @@
"sending": "กำลังส่ง…",
"sent": "ส่งแล้ว",
"view": "ดู",
+ "template_saved": "บันทึกเทมเพลตแล้ว",
+ "schedule_anyway": "กำหนดเวลาต่อไป",
+ "schedule_warnings_title": "กำหนดเวลาส่งข้อความนี้ต่อไปหรือไม่",
"picker_button": "เพิ่มจากผู้ติดต่อ",
"picker_title": "เพิ่มผู้รับ",
"tab_contacts": "ผู้ติดต่อ",
@@ -1504,7 +1532,7 @@
"unfinished": []
},
"schedule_send": "กำหนดเวลาส่ง",
- "schedule_send_description": "เลือกเวลาที่ต้องการให้เซิร์ฟเวอร์ส่งข้อความนี้",
+ "schedule_send_description": "เลือกเวลาที่จะส่ง",
"schedule_preset": {
"this_morning": "เช้านี้",
"this_afternoon": "บ่ายนี้",
@@ -1555,6 +1583,7 @@
"edit_link": "แก้ไขลิงก์",
"remove_link": "นำลิงก์ออก",
"link_address": "ที่อยู่ลิงก์",
+ "link_text": "ข้อความ",
"link_invalid": "ที่อยู่ต้องไม่มีช่องว่าง",
"table": "ตาราง",
"clear_formatting": "ล้างการจัดรูปแบบ",
@@ -2153,7 +2182,7 @@
"title": "ยินดีต้อนรับสู่กล่องจดหมายของคุณ",
"tip_compose": "กด c เพื่อเขียนอีเมลใหม่",
"tip_shortcuts": "กด ? เพื่อดูแป้นพิมพ์ลัดทั้งหมด",
- "tip_sidebar": "เข้าถึง ปฏิทิน ผู้ติดต่อ และการตั้งค่า ได้จากเมนูแถบด้านข้าง",
+ "tip_sidebar": "ปฏิทิน รายชื่อติดต่อ และการตั้งค่าอยู่ในแถบด้านซ้าย",
"tip_settings": "ปรับแต่งประสบการณ์การใช้งานของคุณได้ที่ การตั้งค่า",
"tip_compose_touch": "แตะไอคอนดินสอเพื่อเขียนอีเมลใหม่",
"tip_sidebar_touch": "เข้าถึง ปฏิทิน ผู้ติดต่อ และการตั้งค่า ได้จากแถบเมนูด้านล่าง",
@@ -7284,6 +7313,14 @@
"desktop_mail": {
"context_search_mail": "ค้นหา “{text}” ในอีเมล"
},
+ "mail_p2_desk": {
+ "reader_offline": "คุณออฟไลน์อยู่ ข้อความนี้ต้องมีการเชื่อมต่อจึงจะเปิดได้",
+ "reader_failed": "โหลดข้อความนี้ไม่ได้",
+ "retry": "ลองอีกครั้ง",
+ "action_offline": "คุณออฟไลน์อยู่ การทำงานนี้ต้องมีการเชื่อมต่อ",
+ "offline_bar": "คุณออฟไลน์อยู่ การอ่านและแก้ไขอีเมลต้องมีการเชื่อมต่อ",
+ "offline_default_on": "อีเมลของคุณถูกเก็บไว้ในอุปกรณ์นี้ คุณจึงอ่านได้แม้ออฟไลน์"
+ },
"desktopMenuBar": {
"always_show_label": "แสดงแถบเมนูเสมอ",
"always_show_description": "มิฉะนั้นแถบเมนูจะแสดงเมื่อกด Alt",
diff --git a/locales/tr/common.json b/locales/tr/common.json
index ba1f08b24..c313a1d2f 100644
--- a/locales/tr/common.json
+++ b/locales/tr/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Yeni klasör",
"new_subfolder_title": "Yeni alt klasör"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# konuşma arşivlendi} other {# konuşma arşivlendi}}",
+ "conversations_to_trash": "{count, plural, one {# konuşma Çöp Kutusuna taşındı} other {# konuşma Çöp Kutusuna taşındı}}",
+ "conversations_deleted": "{count, plural, one {# konuşma silindi} other {# konuşma silindi}}",
+ "conversations_moved_to": "{count, plural, one {# konuşma {mailbox} klasörüne taşındı} other {# konuşma {mailbox} klasörüne taşındı}}",
+ "conversations_moved": "{count, plural, one {# konuşma taşındı} other {# konuşma taşındı}}",
+ "select_all_in_folder": "{count, plural, one {{folder} klasöründeki # konuşmayı seç} other {{folder} klasöründeki # konuşmanın tümünü seç}}",
+ "select_all_emails_in_folder": "{count, plural, one {{folder} klasöründeki # e-postayı seç} other {{folder} klasöründeki # e-postanın tümünü seç}}",
+ "select_all_conversations": "{count, plural, one {# konuşmayı seç} other {# konuşmanın tümünü seç}}",
+ "select_all_emails": "{count, plural, one {# e-postayı seç} other {# e-postanın tümünü seç}}",
+ "selecting_all": "Seçiliyor…"
+ },
"shared_tags": {
"title": "Paylaşılan etiketler",
"description": "Grup posta kutularınızın etiketleri. Gruptaki herkes iletilerinde aynı adları ve renkleri görür.",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "Göndermeyi zamanla",
- "schedule_send_description": "Sunucunun bu iletiyi ne zaman göndereceğini seçin.",
+ "schedule_send_description": "Ne zaman gönderileceğini seçin.",
"schedule_preset": {
"this_morning": "Bu sabah",
"this_afternoon": "Bu öğleden sonra",
@@ -1163,6 +1175,7 @@
"edit_link": "Bağlantıyı düzenle",
"remove_link": "Bağlantıyı kaldır",
"link_address": "Bağlantı adresi",
+ "link_text": "Metin",
"link_invalid": "Adres boşluk içeremez",
"table": "Tablo",
"clear_formatting": "Biçimlendirmeyi temizle",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "\"{name}\" klasöründe hâlâ {count} ileti var. Klasör ve tüm iletileri silinsin mi? Bu işlem geri alınamaz.",
"delete_with_contents_confirm": "Klasörü ve iletileri sil"
},
+ "mail_p2_folders": {
+ "all_folders": "Tüm klasörler",
+ "empty_trash": "Çöp Kutusunu boşalt",
+ "empty_spam": "Spam klasörünü boşalt",
+ "empty_trash_confirm": "Çöp Kutusundaki {count, plural, one {ileti} other {# iletinin tümü}} kalıcı olarak silinsin mi? Bu geri alınamaz.",
+ "empty_spam_confirm": "Spam klasöründeki {count, plural, one {ileti} other {# iletinin tümü}} kalıcı olarak silinsin mi? Bu geri alınamaz.",
+ "title_all_accounts": "{name} ({count}, tüm hesaplarda {total})",
+ "title_all_accounts_only": "{name} (tüm hesaplarda {total})"
+ },
"uxs_folders": {
"manage_accounts": "Hesapları yönet…",
"apply_to_existing": "{count, plural, one {{folder} içindeki # mevcut iletiye uygula} other {{folder} içindeki # mevcut iletiye uygula}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {# eski iletiyi göster} other {# eski iletiyi göster}}",
"return_to_list_follows_layout": "Siz değiştirene kadar bu ayar düzene göre belirlenir: liste ve ileti yan yanaysa kapalı, değilse açık."
},
+ "mail_p2_conv": {
+ "show_message": "İletiyi göster",
+ "back_to_reply": "Yanıtınıza dönün"
+ },
"identities": {
"modal_title": "Gönderme Kimliklerini Yönet",
"create_new": "Yeni Kimlik Oluştur",
@@ -4105,6 +4131,9 @@
"sending": "Gönderiliyor…",
"sent": "Gönderildi",
"view": "Görüntüle",
+ "template_saved": "Şablon kaydedildi",
+ "schedule_anyway": "Yine de zamanla",
+ "schedule_warnings_title": "Bu ileti yine de zamanlansın mı?",
"picker_button": "Kişilerden ekle",
"picker_title": "Alıcı ekle",
"tab_contacts": "Kişiler",
@@ -5560,7 +5589,7 @@
"title": "Posta kutunuza hoş geldiniz",
"tip_compose": "Yeni e-posta oluşturmak için c tuşuna basın",
"tip_shortcuts": "Tüm klavye kısayollarını görmek için ? tuşuna basın",
- "tip_sidebar": "Takvim, Kişiler ve Ayarlar'ı kenar çubuğu menüsünde bulabilirsiniz",
+ "tip_sidebar": "Takvim, Kişiler ve Ayarlar soldaki çubukta",
"tip_settings": "Deneyiminizi Ayarlar'dan özelleştirin",
"tip_compose_touch": "Yeni bir e-posta yazmak için kalem düğmesine dokunun",
"tip_sidebar_touch": "Takvim, Kişiler ve Ayarlar alttaki çubukta",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "E-postalarda “{text}” ara"
},
+ "mail_p2_desk": {
+ "reader_offline": "Çevrimdışısınız. Bu iletiyi açmak için bağlantı gerekiyor.",
+ "reader_failed": "Bu ileti yüklenemedi.",
+ "retry": "Tekrar dene",
+ "action_offline": "Çevrimdışısınız. Bunun için bağlantı gerekiyor.",
+ "offline_bar": "Çevrimdışısınız. E-postaları okumak ve değiştirmek için bağlantı gerekiyor.",
+ "offline_default_on": "E-postalarınız bu cihazda saklanır, böylece çevrimdışıyken de okuyabilirsiniz."
+ },
"desktopMenuBar": {
"always_show_label": "Menü çubuğunu her zaman göster",
"always_show_description": "Aksi halde Alt tuşuna basınca görünür.",
diff --git a/locales/uk/common.json b/locales/uk/common.json
index 663b45ecd..9bd2e544b 100644
--- a/locales/uk/common.json
+++ b/locales/uk/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Нова папка",
"new_subfolder_title": "Нова вкладена папка"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, one {# розмову заархівовано} few {# розмови заархівовано} many {# розмов заархівовано} other {# розмови заархівовано}}",
+ "conversations_to_trash": "{count, plural, one {# розмову переміщено до кошика} few {# розмови переміщено до кошика} many {# розмов переміщено до кошика} other {# розмови переміщено до кошика}}",
+ "conversations_deleted": "{count, plural, one {# розмову видалено} few {# розмови видалено} many {# розмов видалено} other {# розмови видалено}}",
+ "conversations_moved_to": "{count, plural, one {# розмову переміщено до {mailbox}} few {# розмови переміщено до {mailbox}} many {# розмов переміщено до {mailbox}} other {# розмови переміщено до {mailbox}}}",
+ "conversations_moved": "{count, plural, one {# розмову переміщено} few {# розмови переміщено} many {# розмов переміщено} other {# розмови переміщено}}",
+ "select_all_in_folder": "{count, plural, one {Вибрати # розмову в папці {folder}} few {Вибрати всі # розмови в папці {folder}} many {Вибрати всі # розмов у папці {folder}} other {Вибрати всі # розмови в папці {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, one {Вибрати # лист у папці {folder}} few {Вибрати всі # листи в папці {folder}} many {Вибрати всі # листів у папці {folder}} other {Вибрати всі # листа в папці {folder}}}",
+ "select_all_conversations": "{count, plural, one {Вибрати # розмову} few {Вибрати всі # розмови} many {Вибрати всі # розмов} other {Вибрати всі # розмови}}",
+ "select_all_emails": "{count, plural, one {Вибрати # лист} few {Вибрати всі # листи} many {Вибрати всі # листів} other {Вибрати всі # листа}}",
+ "selecting_all": "Вибір…"
+ },
"shared_tags": {
"title": "Спільні теги",
"description": "Теги ваших групових скриньок. Усі в групі бачать однакові назви й кольори на її листах.",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "Введіть URL",
"sending": "Надсилання...",
"schedule_send": "Запланувати надсилання",
- "schedule_send_description": "Виберіть, коли сервер має надіслати це повідомлення.",
+ "schedule_send_description": "Виберіть, коли надіслати.",
"schedule_preset": {
"this_morning": "Сьогодні вранці",
"this_afternoon": "Сьогодні вдень",
@@ -1163,6 +1175,7 @@
"edit_link": "Змінити посилання",
"remove_link": "Прибрати посилання",
"link_address": "Адреса посилання",
+ "link_text": "Текст",
"link_invalid": "Адреса не може містити пробілів",
"table": "Таблиця",
"clear_formatting": "Очистити форматування",
@@ -3881,6 +3894,15 @@
"create": "Створити",
"rename_confirm": "Перейменувати"
},
+ "mail_p2_folders": {
+ "all_folders": "Усі папки",
+ "empty_trash": "Очистити кошик",
+ "empty_spam": "Очистити спам",
+ "empty_trash_confirm": "Видалити {count, plural, =1 {лист} one {усі # лист} few {усі # листи} many {усі # листів} other {усі # листа}} в кошику назавжди? Це не можна скасувати.",
+ "empty_spam_confirm": "Видалити {count, plural, =1 {лист} one {усі # лист} few {усі # листи} many {усі # листів} other {усі # листа}} у спамі назавжди? Це не можна скасувати.",
+ "title_all_accounts": "{name} ({count}, {total} в усіх акаунтах)",
+ "title_all_accounts_only": "{name} ({total} в усіх акаунтах)"
+ },
"uxs_folders": {
"manage_accounts": "Керувати обліковими записами…",
"apply_to_existing": "{count, plural, one {Застосувати до # наявного листа в теці {folder}} few {Застосувати до # наявних листів у теці {folder}} many {Застосувати до # наявних листів у теці {folder}} other {Застосувати до # наявного листа в теці {folder}}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, one {Показати # давніший лист} few {Показати # давніші листи} many {Показати # давніших листів} other {Показати # давнішого листа}}",
"return_to_list_follows_layout": "Поки ви це не зміните, налаштування залежить від макета: вимкнено, коли список і лист розташовані поруч, в інших випадках увімкнено."
},
+ "mail_p2_conv": {
+ "show_message": "Показати лист",
+ "back_to_reply": "Повернутися до відповіді"
+ },
"identities": {
"modal_title": "Керування ідентифікаторами надсилання",
"create_new": "Створіть нову ідентичність",
@@ -4105,6 +4131,9 @@
"sending": "Надсилання…",
"sent": "Надіслано",
"view": "Переглянути",
+ "template_saved": "Шаблон збережено",
+ "schedule_anyway": "Усе одно запланувати",
+ "schedule_warnings_title": "Усе одно запланувати цей лист?",
"picker_button": "Додати з контактів",
"picker_title": "Додати одержувачів",
"tab_contacts": "Контакти",
@@ -5298,7 +5327,7 @@
"title": "Ласкаво просимо до вашої поштової скриньки",
"tip_compose": "Натисніть c, щоб створити новий електронний лист",
"tip_shortcuts": "Натисніть ? щоб побачити всі комбінації клавіш",
- "tip_sidebar": "Знайдіть Календар, Контакти та Налаштування в меню бічної панелі",
+ "tip_sidebar": "Календар, контакти й налаштування на панелі ліворуч",
"tip_settings": "Налаштуйте свій досвід у налаштуваннях",
"tip_compose_touch": "Торкніться кнопки з олівцем, щоб написати новий лист",
"tip_sidebar_touch": "Календар, Контакти й Налаштування є на нижній панелі",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "Шукати «{text}» у пошті"
},
+ "mail_p2_desk": {
+ "reader_offline": "Немає з'єднання. Щоб відкрити цей лист, потрібне з'єднання.",
+ "reader_failed": "Не вдалося завантажити цей лист.",
+ "retry": "Спробувати ще",
+ "action_offline": "Немає з'єднання. Для цього потрібне з'єднання.",
+ "offline_bar": "Немає з'єднання. Щоб читати й змінювати пошту, потрібне з'єднання.",
+ "offline_default_on": "Ваша пошта зберігається на цьому пристрої, тож її можна читати без мережі."
+ },
"desktopMenuBar": {
"always_show_label": "Завжди показувати рядок меню",
"always_show_description": "Інакше він з’являється після натискання Alt.",
diff --git a/locales/vi/common.json b/locales/vi/common.json
index 163472e33..a3471c775 100644
--- a/locales/vi/common.json
+++ b/locales/vi/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "Thư mục mới",
"new_subfolder_title": "Thư mục con mới"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {Đã lưu trữ # cuộc trò chuyện}}",
+ "conversations_to_trash": "{count, plural, other {Đã chuyển # cuộc trò chuyện vào thùng rác}}",
+ "conversations_deleted": "{count, plural, other {Đã xóa # cuộc trò chuyện}}",
+ "conversations_moved_to": "{count, plural, other {Đã chuyển # cuộc trò chuyện tới {mailbox}}}",
+ "conversations_moved": "{count, plural, other {Đã chuyển # cuộc trò chuyện}}",
+ "select_all_in_folder": "{count, plural, other {Chọn tất cả # cuộc trò chuyện trong {folder}}}",
+ "select_all_emails_in_folder": "{count, plural, other {Chọn tất cả # email trong {folder}}}",
+ "select_all_conversations": "{count, plural, other {Chọn tất cả # cuộc trò chuyện}}",
+ "select_all_emails": "{count, plural, other {Chọn tất cả # email}}",
+ "selecting_all": "Đang chọn…"
+ },
"shared_tags": {
"title": "Thẻ được chia sẻ",
"description": "Thẻ của hộp thư nhóm của bạn. Mọi người trong nhóm đều nhìn thấy tên và màu sắc giống nhau trên thư của nhóm.",
@@ -1126,7 +1138,7 @@
]
},
"schedule_send": "Lên lịch gửi",
- "schedule_send_description": "Chọn thời điểm máy chủ sẽ gửi thư này.",
+ "schedule_send_description": "Chọn thời điểm gửi.",
"schedule_preset": {
"this_morning": "Sáng nay",
"this_afternoon": "Chiều nay",
@@ -1177,6 +1189,7 @@
"edit_link": "Chỉnh sửa liên kết",
"remove_link": "Xóa liên kết",
"link_address": "Địa chỉ liên kết",
+ "link_text": "Văn bản",
"link_invalid": "Địa chỉ không được chứa dấu cách",
"table": "Bảng",
"clear_formatting": "Xóa định dạng",
@@ -3893,6 +3906,15 @@
"delete_with_contents_message": "Thư mục \"{name}\" vẫn chứa {count, plural, one {1 thư} other {# thư}}. Xóa thư mục và tất cả thư của nó? Điều này không thể hoàn tác được.",
"delete_with_contents_confirm": "Xóa thư mục và thư"
},
+ "mail_p2_folders": {
+ "all_folders": "Tất cả thư mục",
+ "empty_trash": "Dọn sạch Thùng rác",
+ "empty_spam": "Dọn sạch Thư rác",
+ "empty_trash_confirm": "Xóa vĩnh viễn {count, plural, other {tất cả # thư}} trong Thùng rác? Không thể hoàn tác.",
+ "empty_spam_confirm": "Xóa vĩnh viễn {count, plural, other {tất cả # thư}} trong Thư rác? Không thể hoàn tác.",
+ "title_all_accounts": "{name} ({count}, {total} trong mọi tài khoản)",
+ "title_all_accounts_only": "{name} ({total} trong mọi tài khoản)"
+ },
"uxs_folders": {
"manage_accounts": "Quản lý tài khoản…",
"apply_to_existing": "{count, plural, one {Áp dụng cho # thư hiện có trong {folder}} other {Áp dụng cho # thư hiện có trong {folder}}}"
@@ -3972,6 +3994,10 @@
"show_older": "{count, plural, other {Hiện # thư cũ hơn}}",
"return_to_list_follows_layout": "Cho đến khi bạn thay đổi, cài đặt này theo bố cục: tắt khi danh sách và thư nằm cạnh nhau, bật trong các trường hợp khác."
},
+ "mail_p2_conv": {
+ "show_message": "Hiện thư",
+ "back_to_reply": "Quay lại thư trả lời"
+ },
"identities": {
"modal_title": "Quản lý danh tính gửi thư",
"create_new": "Danh tính mới",
@@ -4117,6 +4143,9 @@
"sending": "Đang gửi…",
"sent": "Đã gửi",
"view": "Xem",
+ "template_saved": "Đã lưu mẫu",
+ "schedule_anyway": "Vẫn lên lịch",
+ "schedule_warnings_title": "Vẫn lên lịch gửi thư này?",
"picker_button": "Thêm từ danh bạ",
"picker_title": "Thêm người nhận",
"tab_contacts": "Danh bạ",
@@ -5572,7 +5601,7 @@
"title": "Chào mừng đến với hộp thư của bạn",
"tip_compose": "Nhấn c để soạn email mới",
"tip_shortcuts": "Nhấn ? để xem tất cả phím tắt",
- "tip_sidebar": "Tìm Lịch, Danh bạ và Cài đặt trong menu thanh bên",
+ "tip_sidebar": "Lịch, Danh bạ và Cài đặt nằm ở thanh bên trái",
"tip_settings": "Tùy chỉnh trải nghiệm của bạn trong Cài đặt",
"tip_compose_touch": "Nhấn vào nút bút chì để viết email mới",
"tip_sidebar_touch": "Tìm Lịch, Danh bạ và Cài đặt ở thanh phía dưới",
@@ -7288,6 +7317,14 @@
"desktop_mail": {
"context_search_mail": "Tìm “{text}” trong thư"
},
+ "mail_p2_desk": {
+ "reader_offline": "Bạn đang ngoại tuyến. Cần có kết nối để mở thư này.",
+ "reader_failed": "Không tải được thư này.",
+ "retry": "Thử lại",
+ "action_offline": "Bạn đang ngoại tuyến. Việc này cần có kết nối.",
+ "offline_bar": "Bạn đang ngoại tuyến. Cần có kết nối để đọc và thay đổi thư.",
+ "offline_default_on": "Thư của bạn được lưu trên thiết bị này, nên bạn có thể đọc khi ngoại tuyến."
+ },
"desktopMenuBar": {
"always_show_label": "Luôn hiện thanh menu",
"always_show_description": "Nếu tắt, thanh menu hiện khi bạn nhấn Alt.",
diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json
index 4a93a244d..17fd82254 100644
--- a/locales/zh-TW/common.json
+++ b/locales/zh-TW/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "新增信件匣",
"new_subfolder_title": "新增子信件匣"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {已封存 # 個對話串}}",
+ "conversations_to_trash": "{count, plural, other {已將 # 個對話串移至垃圾桶}}",
+ "conversations_deleted": "{count, plural, other {已刪除 # 個對話串}}",
+ "conversations_moved_to": "{count, plural, other {已將 # 個對話串移至 {mailbox}}}",
+ "conversations_moved": "{count, plural, other {已移動 # 個對話串}}",
+ "select_all_in_folder": "{count, plural, other {選取 {folder} 中的全部 # 個對話串}}",
+ "select_all_emails_in_folder": "{count, plural, other {選取 {folder} 中的全部 # 封郵件}}",
+ "select_all_conversations": "{count, plural, other {選取全部 # 個對話串}}",
+ "select_all_emails": "{count, plural, other {選取全部 # 封郵件}}",
+ "selecting_all": "正在選取…"
+ },
"shared_tags": {
"title": "共用標籤",
"description": "您的群組信箱標籤。群組中的每個人在其郵件上看到的名稱和顏色都相同。",
@@ -1112,7 +1124,7 @@
"unfinished": []
},
"schedule_send": "排程寄送",
- "schedule_send_description": "選擇伺服器要在何時送出這封郵件。",
+ "schedule_send_description": "選擇寄送時間。",
"schedule_preset": {
"this_morning": "今天早上",
"this_afternoon": "今天下午",
@@ -1163,6 +1175,7 @@
"edit_link": "編輯連結",
"remove_link": "移除連結",
"link_address": "連結網址",
+ "link_text": "文字",
"link_invalid": "網址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",
@@ -3881,6 +3894,15 @@
"delete_with_contents_message": "資料夾「{name}」中仍有 {count} 封郵件。要刪除該資料夾及其所有郵件嗎?此操作無法復原。",
"delete_with_contents_confirm": "刪除資料夾和郵件"
},
+ "mail_p2_folders": {
+ "all_folders": "所有信件匣",
+ "empty_trash": "清空垃圾桶",
+ "empty_spam": "清空垃圾郵件",
+ "empty_trash_confirm": "要永久刪除垃圾桶中的{count, plural, other {全部 # 封郵件}}嗎?此動作無法復原。",
+ "empty_spam_confirm": "要永久刪除垃圾郵件中的{count, plural, other {全部 # 封郵件}}嗎?此動作無法復原。",
+ "title_all_accounts": "{name} ({count}、所有帳號共 {total})",
+ "title_all_accounts_only": "{name} (所有帳號共 {total})"
+ },
"uxs_folders": {
"manage_accounts": "管理帳號…",
"apply_to_existing": "{count, plural, other {套用到{folder}中的 # 封既有郵件}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, other {顯示 # 封較早的郵件}}",
"return_to_list_follows_layout": "在您變更之前,此項會依版面配置而定:清單與郵件並排顯示時關閉,其他情況下開啟。"
},
+ "mail_p2_conv": {
+ "show_message": "顯示郵件",
+ "back_to_reply": "返回回覆"
+ },
"identities": {
"modal_title": "管理寄件身分",
"create_new": "建立新的寄件身分",
@@ -4105,6 +4131,9 @@
"sending": "正在傳送…",
"sent": "已傳送",
"view": "檢視",
+ "template_saved": "範本已儲存",
+ "schedule_anyway": "仍然排程",
+ "schedule_warnings_title": "仍然排程這封郵件?",
"picker_button": "從聯絡人新增",
"picker_title": "新增收件者",
"tab_contacts": "聯絡人",
@@ -5560,7 +5589,7 @@
"title": "歡迎使用您的信件匣",
"tip_compose": "按 c 撰寫新郵件",
"tip_shortcuts": "按 ? 檢視所有鍵盤快速鍵",
- "tip_sidebar": "可在側邊欄選單找到行事曆、聯絡人與設定",
+ "tip_sidebar": "行事曆、聯絡人和設定位於左側欄",
"tip_settings": "在「設定」中自訂您的使用體驗",
"tip_compose_touch": "點按鉛筆按鈕撰寫新郵件",
"tip_sidebar_touch": "行事曆、聯絡人和設定位於底部列",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "在郵件中搜尋「{text}」"
},
+ "mail_p2_desk": {
+ "reader_offline": "您已離線。開啟這封郵件需要網路連線。",
+ "reader_failed": "無法載入這封郵件。",
+ "retry": "重試",
+ "action_offline": "您已離線。此操作需要網路連線。",
+ "offline_bar": "您已離線。閱讀和變更郵件需要網路連線。",
+ "offline_default_on": "您的郵件保存在此裝置上,離線時也能閱讀。"
+ },
"desktopMenuBar": {
"always_show_label": "永遠顯示選單列",
"always_show_description": "否則按 Alt 鍵時顯示。",
diff --git a/locales/zh/common.json b/locales/zh/common.json
index 9f2248c9b..7d85a8b8e 100644
--- a/locales/zh/common.json
+++ b/locales/zh/common.json
@@ -427,6 +427,18 @@
"new_folder_title": "新建文件夹",
"new_subfolder_title": "新建子文件夹"
},
+ "mail_p2_list": {
+ "conversations_archived": "{count, plural, other {已归档 # 个会话}}",
+ "conversations_to_trash": "{count, plural, other {已将 # 个会话移至已删除}}",
+ "conversations_deleted": "{count, plural, other {已删除 # 个会话}}",
+ "conversations_moved_to": "{count, plural, other {已将 # 个会话移至 {mailbox}}}",
+ "conversations_moved": "{count, plural, other {已移动 # 个会话}}",
+ "select_all_in_folder": "{count, plural, other {选择 {folder} 中的全部 # 个会话}}",
+ "select_all_emails_in_folder": "{count, plural, other {选择 {folder} 中的全部 # 封邮件}}",
+ "select_all_conversations": "{count, plural, other {选择全部 # 个会话}}",
+ "select_all_emails": "{count, plural, other {选择全部 # 封邮件}}",
+ "selecting_all": "正在选择…"
+ },
"shared_tags": {
"title": "共享标签",
"description": "您的群组邮箱的标签。群组中的每个人在其邮件上看到的名称和颜色都相同。",
@@ -1112,7 +1124,7 @@
"link_url_prompt": "输入 URL",
"sending": "发送中...",
"schedule_send": "计划发送",
- "schedule_send_description": "选择服务器应何时发送此邮件。",
+ "schedule_send_description": "选择发送时间。",
"schedule_preset": {
"this_morning": "今天早上",
"this_afternoon": "今天下午",
@@ -1163,6 +1175,7 @@
"edit_link": "编辑链接",
"remove_link": "移除链接",
"link_address": "链接地址",
+ "link_text": "文本",
"link_invalid": "地址中不能包含空格",
"table": "表格",
"clear_formatting": "清除格式",
@@ -3881,6 +3894,15 @@
"create": "创建",
"rename_confirm": "重命名"
},
+ "mail_p2_folders": {
+ "all_folders": "所有文件夹",
+ "empty_trash": "清空已删除",
+ "empty_spam": "清空垃圾邮件",
+ "empty_trash_confirm": "要永久删除已删除中的{count, plural, other {全部 # 封邮件}}吗?此操作无法撤销。",
+ "empty_spam_confirm": "要永久删除垃圾邮件中的{count, plural, other {全部 # 封邮件}}吗?此操作无法撤销。",
+ "title_all_accounts": "{name} ({count}、所有账户共 {total})",
+ "title_all_accounts_only": "{name} (所有账户共 {total})"
+ },
"uxs_folders": {
"manage_accounts": "管理账户…",
"apply_to_existing": "{count, plural, other {应用于{folder}中的 # 封已有邮件}}"
@@ -3960,6 +3982,10 @@
"show_older": "{count, plural, other {显示 # 封较早的邮件}}",
"return_to_list_follows_layout": "在你更改之前,此项随布局而定:列表和邮件并排显示时关闭,其他情况下开启。"
},
+ "mail_p2_conv": {
+ "show_message": "显示邮件",
+ "back_to_reply": "返回回复"
+ },
"identities": {
"modal_title": "管理发送身份",
"create_new": "创建新身份",
@@ -4105,6 +4131,9 @@
"sending": "正在发送…",
"sent": "已发送",
"view": "查看",
+ "template_saved": "模板已保存",
+ "schedule_anyway": "仍然计划发送",
+ "schedule_warnings_title": "仍然计划发送这封邮件?",
"picker_button": "从联系人添加",
"picker_title": "添加收件人",
"tab_contacts": "联系人",
@@ -5298,7 +5327,7 @@
"title": "欢迎使用邮箱",
"tip_compose": "按 c 撰写新邮件",
"tip_shortcuts": "按?查看所有键盘快捷键",
- "tip_sidebar": "在侧边栏菜单中查找日历、联系人和设置",
+ "tip_sidebar": "日历、联系人和设置在左侧栏中",
"tip_settings": "在\"设置\"中自定义您的体验",
"tip_compose_touch": "点按铅笔按钮写新邮件",
"tip_sidebar_touch": "日历、联系人和设置位于底部栏",
@@ -7276,6 +7305,14 @@
"desktop_mail": {
"context_search_mail": "在邮件中搜索“{text}”"
},
+ "mail_p2_desk": {
+ "reader_offline": "您已离线。打开这封邮件需要网络连接。",
+ "reader_failed": "无法加载这封邮件。",
+ "retry": "重试",
+ "action_offline": "您已离线。此操作需要网络连接。",
+ "offline_bar": "您已离线。阅读和更改邮件需要网络连接。",
+ "offline_default_on": "您的邮件保存在此设备上,离线时也能阅读。"
+ },
"desktopMenuBar": {
"always_show_label": "始终显示菜单栏",
"always_show_description": "否则按 Alt 键时显示。",
diff --git a/stores/__tests__/email-store-held-send-counts.test.ts b/stores/__tests__/email-store-held-send-counts.test.ts
new file mode 100644
index 000000000..6b4f54aa3
--- /dev/null
+++ b/stores/__tests__/email-store-held-send-counts.test.ts
@@ -0,0 +1,42 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { useEmailStore } from '../email-store';
+import { useAuthStore } from '../auth-store';
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Mailbox } from '@/lib/jmap/types';
+
+// mail-p2-8: a send in its Undo window keeps what Sent and Scheduled counted
+// before it, so the sidebar can show those counts until the message goes.
+
+function mailbox(id: string, role: string, totalEmails: number): Mailbox {
+ return { id, name: role, role, sortOrder: 0, totalEmails, unreadEmails: 0, totalThreads: totalEmails, unreadThreads: 0, isSubscribed: true } as Mailbox;
+}
+
+describe('the counts before a held send', () => {
+ let client: IJMAPClient;
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ client = {
+ getAccountId: () => 'acc-1',
+ pruneEmailSubmissions: vi.fn().mockResolvedValue(0),
+ sendEmail: vi.fn().mockResolvedValue({ scheduled: true, emailId: 'sent-1', emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() }),
+ getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [mailbox('mb-sent', 'sent', 52)], states: { 'acc-1': 'm2' } }),
+ } as unknown as IJMAPClient;
+ useAuthStore.setState({ client });
+ useEmailStore.setState({
+ emails: [], mailboxes: [mailbox('mb-sent', 'sent', 51)], mailboxSyncStates: { 'acc-1': 'm1' },
+ selectedMailbox: 'mb-sent', pendingUndoSend: null, scheduledTotal: 2,
+ });
+ });
+
+ it('records Sent and Scheduled as they were', async () => {
+ await useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
+ expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: 51, scheduled: 2 });
+ });
+
+ it("records no Sent count for another login's send", async () => {
+ const otherLogin = { ...client } as unknown as IJMAPClient;
+ await useEmailStore.getState().sendEmail(otherLogin, ['bob@example.net'], 'Subject', 'body');
+ expect(useEmailStore.getState().pendingUndoSend?.countsBefore).toEqual({ sent: undefined, scheduled: 2 });
+ });
+});
diff --git a/stores/email-store.ts b/stores/email-store.ts
index 73676edc8..429d1dcdc 100644
--- a/stores/email-store.ts
+++ b/stores/email-store.ts
@@ -6,6 +6,7 @@ import { RescheduleRestoredError } from "@/lib/jmap/reschedule-error";
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 { HeldSendCounts } from "@/lib/held-send";
import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interface";
import { jmapErrorFailedIds } from "@/lib/jmap/jmap-error";
import type { MessagePriority } from "@/lib/message-priority";
@@ -70,6 +71,8 @@ type PendingUndoSend = {
* was sent from a shared identity (#874). Distinct from localAccountId,
* which selects *which login's client* to talk to. */
submissionAccountId?: string;
+ /** Sent's and Scheduled's counts before the send, shown during the Undo window (lib/held-send.ts). */
+ countsBefore?: HeldSendCounts;
};
// Nothing ticked stays the same object: a new empty set re-renders all that reads the selection.
@@ -4410,6 +4413,12 @@ export const useEmailStore = create((set, get) => ({
// under "Loading emails…" until the message was out.
set({ error: null });
try {
+ // What Sent and Scheduled counted before: a send in its Undo window
+ // doesn't count yet (mail-p2-8).
+ const countsBefore: HeldSendCounts = {
+ sent: useAuthStore.getState().client === client ? get().mailboxes.find((mb) => mb.role === 'sent' && !mb.isShared)?.totalEmails : undefined,
+ scheduled: get().scheduledTotal,
+ };
// Files attached while offline are uploaded now that the server answers.
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
const result = await client.sendEmail(to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, sendAttachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options);
@@ -4434,6 +4443,7 @@ export const useEmailStore = create((set, get) => ({
// Cross-account send: undo/send-now must talk to the account that
// holds the submission, not the active one (#461).
localAccountId: options?.localAccountId,
+ countsBefore,
}
: get().pendingUndoSend,
});
diff --git a/stores/quick-reply-restore-store.ts b/stores/quick-reply-restore-store.ts
new file mode 100644
index 000000000..a903fcc76
--- /dev/null
+++ b/stores/quick-reply-restore-store.ts
@@ -0,0 +1,23 @@
+import { create } from 'zustand';
+
+/**
+ * A quick reply whose send was undone, waiting for the reader of the message
+ * it answers to take its text back into the quick reply box (mail-p2-8).
+ */
+interface QuickReplyRestoreState {
+ pending: { emailId: string; text: string } | null;
+ restore: (emailId: string, text: string) => void;
+ /** The reader of `emailId` takes the text; null when none waits for it. */
+ take: (emailId: string) => string | null;
+}
+
+export const useQuickReplyRestoreStore = create()((set, get) => ({
+ pending: null,
+ restore: (emailId, text) => set({ pending: { emailId, text } }),
+ take: (emailId) => {
+ const pending = get().pending;
+ if (!pending || pending.emailId !== emailId) return null;
+ set({ pending: null });
+ return pending.text;
+ },
+}));