Merge branch 'feat/desktop-offline' into feat/do-ux-pim-fixes

# Conflicts:
#	stores/settings-store.ts
This commit is contained in:
Linus Rath
2026-11-04 11:10:15 +01:00
123 changed files with 7094 additions and 736 deletions
@@ -37,7 +37,9 @@ describe('keyboard shortcuts help per app', () => {
nav.pathname = '/';
render(<KeyboardShortcutsModal isOpen onClose={() => {}} />);
expect(screen.getAllByText('or').length).toBeGreaterThan(0);
expect(screen.getAllByText('Ctrl + K').length).toBe(1);
// The command palette, and Insert link in the composer.
expect(screen.getAllByText('Ctrl + K').length).toBe(2);
expect(screen.queryByText('⌘ + K')).toBeNull();
});
});
@@ -4,6 +4,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DirectoryPickerDialog } from '../directory-picker-dialog';
import { useContactStore, type DirectoryEntry, type DirectoryStatus } from '@/stores/contact-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import type { ContactCard } from '@/lib/jmap/types';
// next-intl is mocked globally in vitest.setup (t returns the key).
@@ -25,7 +26,11 @@ function rows() {
}
describe('DirectoryPickerDialog', () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
// No contacts of the user's own: the picker opens on the directory.
useContactStore.setState({ contacts: [] });
});
it('lists the directory before any search, one page at a time', () => {
setDirectory(people(60), 'ready');
@@ -109,3 +114,69 @@ describe('DirectoryPickerDialog', () => {
expect(screen.queryByText('refresh_failed')).not.toBeInTheDocument();
});
});
// compose-17: the picker listed only the server's directory, not the
// contacts the To field's suggestions offer.
describe('DirectoryPickerDialog with contacts', () => {
function contact(id: string, name: string, ...emails: string[]): ContactCard {
return {
id, addressBookIds: { book: true },
name: { full: name },
emails: Object.fromEntries(emails.map((address, i) => [`e${i}`, { address }])),
} as unknown as ContactCard;
}
beforeEach(() => {
vi.clearAllMocks();
setDirectory([{ name: 'Server Account', email: 'server@example.org' }], 'ready');
useContactStore.setState({
contacts: [
contact('c1', 'Zoe Fischer', 'zoe@example.com', 'zoe.f@work.example'),
contact('c2', 'Anna Becker', 'anna@example.com'),
{ id: 'g1', kind: 'group', name: { full: 'Team' } } as unknown as ContactCard,
],
});
});
it('opens on the contacts, one row per address, without groups', () => {
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
expect(screen.getByRole('tab', { name: 'tab_contacts' })).toHaveAttribute('aria-selected', 'true');
expect(rows().map((r) => r.textContent)).toEqual([
expect.stringContaining('anna@example.com'),
expect.stringContaining('zoe.f@work.example'),
expect.stringContaining('zoe@example.com'),
]);
expect(screen.queryByText('Server Account')).not.toBeInTheDocument();
});
it('searches the contacts', () => {
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'anna' } });
expect(rows()).toHaveLength(1);
fireEvent.change(screen.getByPlaceholderText('search_placeholder'), { target: { value: 'nobody' } });
expect(screen.getByText('contacts_no_match')).toBeInTheDocument();
});
it('adds what was checked on both tabs', () => {
const onAdd = vi.fn();
render(<DirectoryPickerDialog client={client} onClose={vi.fn()} onAdd={onAdd} />);
fireEvent.click(rows()[0]);
fireEvent.click(screen.getByRole('tab', { name: 'tab_directory' }));
fireEvent.click(rows()[0]);
fireEvent.click(screen.getByText('add_to'));
expect(onAdd).toHaveBeenCalledWith('to', [
expect.objectContaining({ name: 'Anna Becker', email: 'anna@example.com' }),
expect.objectContaining({ email: 'server@example.org' }),
]);
});
it('shows the contacts alone where the server has no directory', () => {
const noDirectory = { supportsPrincipals: () => false } as unknown as IJMAPClient;
render(<DirectoryPickerDialog client={noDirectory} onClose={vi.fn()} onAdd={vi.fn()} />);
expect(screen.queryByRole('tab')).not.toBeInTheDocument();
expect(rows()).toHaveLength(3);
});
});
+29 -14
View File
@@ -51,6 +51,13 @@ interface DirectoryBrowserProps {
/** Set while the list is being printed. */
printing?: boolean;
className?: string;
/**
* Browse these instead of the server's directory (the recipient picker's
* Contacts tab): nothing is fetched and there is nothing to refresh.
*/
entries?: DirectoryEntry[];
/** With `entries`: the list's name and what an empty list or search says. */
labels?: { list: string; emptyTitle: string; emptyHint?: string; noMatch: string };
}
/**
@@ -69,13 +76,17 @@ export function DirectoryBrowser({
onPrint,
printing = false,
className,
entries: ownEntries,
labels,
}: DirectoryBrowserProps) {
const t = useTranslations("contacts.directory");
const tContacts = useTranslations("contacts");
const tCommon = useTranslations("common");
const locale = useLocale();
const entries = useContactStore((s) => s.directoryPrincipals);
const status = useContactStore((s) => s.directoryStatus);
const directoryEntries = useContactStore((s) => s.directoryPrincipals);
const directoryStatus = useContactStore((s) => s.directoryStatus);
const entries = ownEntries ?? directoryEntries;
const status = ownEntries ? "ready" : directoryStatus;
const fetchDirectory = useContactStore((s) => s.fetchDirectory);
const [query, setQuery] = useState("");
const [page, setPage] = useState(0);
@@ -83,8 +94,8 @@ export function DirectoryBrowser({
// The directory loads at sign-in; fetch here only if that never ran.
useEffect(() => {
if (client && status === "idle") void fetchDirectory(client);
}, [client, status, fetchDirectory]);
if (!ownEntries && client && status === "idle") void fetchDirectory(client);
}, [ownEntries, client, status, fetchDirectory]);
useEffect(() => {
if (autoFocusSearch) searchRef.current?.focus();
@@ -117,7 +128,9 @@ export function DirectoryBrowser({
};
let placeholder: React.ReactNode = null;
if (entries.length === 0) {
if (entries.length === 0 && ownEntries) {
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={labels?.emptyTitle ?? t("empty_title")} hint={labels?.emptyHint} />;
} else if (entries.length === 0) {
if (loading) {
placeholder = (
<StatePanel icon={<Loader2 className="w-6 h-6 animate-spin" />} title={t("loading")} />
@@ -132,7 +145,7 @@ export function DirectoryBrowser({
placeholder = <StatePanel icon={<BookUser className="w-6 h-6" />} title={t("empty_title")} hint={t("empty_hint")} />;
}
} else if (filtered.length === 0) {
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={t("no_match")} />;
placeholder = <StatePanel icon={<Search className="w-6 h-6" />} title={labels?.noMatch ?? t("no_match")} />;
}
return (
@@ -163,13 +176,15 @@ export function DirectoryBrowser({
onClear={() => { setQuery(""); setPage(0); }}
clearLabel={tContacts("clear_search")}
/>
<ToolbarButton
label={t("refresh")}
onClick={refresh}
disabled={!client || status === "loading" || status === "unavailable"}
>
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
</ToolbarButton>
{!ownEntries && (
<ToolbarButton
label={t("refresh")}
onClick={refresh}
disabled={!client || status === "loading" || status === "unavailable"}
>
<RefreshCw className={cn(status === "loading" && "animate-spin [animation-direction:reverse]")} />
</ToolbarButton>
)}
{onPrint && (
<ToolbarButton label={tCommon("print")} onClick={onPrint}>
<Printer />
@@ -186,7 +201,7 @@ export function DirectoryBrowser({
)}
</div>
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={t("title")}>
<div className="flex-1 min-h-0 overflow-y-auto" role="list" aria-label={labels?.list ?? t("title")}>
{placeholder ?? pageEntries.map((entry) => {
const key = directoryKey(entry);
const checked = selected.has(key);
+98 -13
View File
@@ -1,36 +1,94 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { X } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { cn } from "@/lib/utils";
import type { IJMAPClient } from "@/lib/jmap/client-interface";
import { useContactStore, type DirectoryEntry } from "@/stores/contact-store";
import type { ContactCard } from "@/lib/jmap/types";
import { useContactStore, getContactDisplayName, type DirectoryEntry } from "@/stores/contact-store";
import { DirectoryBrowser, directoryKey } from "./directory-browser";
export type RecipientField = "to" | "cc" | "bcc";
type PickerTab = "contacts" | "directory";
interface DirectoryPickerDialogProps {
client: IJMAPClient | null;
onClose: () => void;
/** Called with the checked entries in directory order; the dialog closes afterwards. */
/** Called with the checked entries in list order; the dialog closes afterwards. */
onAdd: (field: RecipientField, entries: DirectoryEntry[]) => void;
}
/** The composer's "Select from directory" dialog. */
/** Every address of the user's own contacts, one row each; groups are left out. */
export function contactEntries(contacts: ContactCard[]): DirectoryEntry[] {
const seen = new Set<string>();
const entries: DirectoryEntry[] = [];
for (const contact of contacts) {
if (contact.kind === "group") continue;
const name = getContactDisplayName(contact);
for (const email of Object.values(contact.emails ?? {})) {
const address = email?.address?.trim();
if (!address) continue;
const entry = { id: `${contact.id}:${address}`, name: name || address, email: address };
if (seen.has(directoryKey(entry))) continue;
seen.add(directoryKey(entry));
entries.push(entry);
}
}
return entries;
}
/**
* The composer's recipient picker: the user's own contacts, and the server's
* directory (other accounts) on a tab of its own where the server has one.
* It used to list only the directory, without the contacts autocomplete offers.
*/
export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPickerDialogProps) {
const t = useTranslations("contacts.directory");
const tSend = useTranslations("compose_send");
const modalRef = useFocusTrap({ isActive: true, onEscape: onClose, restoreFocus: true });
const contacts = useContactStore((s) => s.contacts);
const ownEntries = useMemo(() => contactEntries(contacts ?? []), [contacts]);
const hasDirectory = !!client?.supportsPrincipals?.();
// Contacts first; straight to the directory for someone who has none.
const [tab, setTab] = useState<PickerTab>(() => (ownEntries.length === 0 && hasDirectory ? "directory" : "contacts"));
const [selected, setSelected] = useState<Set<string>>(() => new Set());
const add = (field: RecipientField) => {
const entries = useContactStore.getState().directoryPrincipals.filter((e) => selected.has(directoryKey(e)));
// Checked on either tab, each address once.
const pool = [...ownEntries, ...useContactStore.getState().directoryPrincipals];
const seen = new Set<string>();
const entries = pool.filter((e) => {
const key = directoryKey(e);
if (!selected.has(key) || seen.has(key)) return false;
seen.add(key);
return true;
});
if (entries.length === 0) return;
onAdd(field, entries);
onClose();
};
const tabButton = (value: PickerTab, label: string) => (
<button
type="button"
role="tab"
id={`recipient-picker-tab-${value}`}
aria-selected={tab === value}
aria-controls="recipient-picker-panel"
onClick={() => setTab(value)}
className={cn(
"px-3 py-2 text-sm font-medium border-b-2 -mb-px transition-colors",
tab === value ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{label}
</button>
);
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 animate-in fade-in duration-150">
<div
@@ -43,7 +101,7 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
>
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<h2 id="directory-picker-title" className="text-sm font-semibold text-foreground">
{t("picker_title")}
{tSend("picker_title")}
</h2>
<button
type="button"
@@ -55,13 +113,40 @@ export function DirectoryPickerDialog({ client, onClose, onAdd }: DirectoryPicke
</button>
</div>
<DirectoryBrowser
client={client}
selected={selected}
onSelectedChange={setSelected}
autoFocusSearch
className="flex-1"
/>
{hasDirectory && (
<div role="tablist" className="flex gap-1 px-3 border-b border-border">
{tabButton("contacts", tSend("tab_contacts"))}
{tabButton("directory", tSend("tab_directory"))}
</div>
)}
<div
id="recipient-picker-panel"
role={hasDirectory ? "tabpanel" : undefined}
aria-labelledby={hasDirectory ? `recipient-picker-tab-${tab}` : undefined}
className="flex-1 min-h-0 flex flex-col"
>
<DirectoryBrowser
// A fresh search and first page on each tab.
key={tab}
client={client}
selected={selected}
onSelectedChange={setSelected}
autoFocusSearch
className="flex-1"
{...(tab === "contacts"
? {
entries: ownEntries,
labels: {
list: tSend("tab_contacts"),
emptyTitle: tSend("contacts_empty_title"),
emptyHint: tSend("contacts_empty_hint"),
noMatch: tSend("contacts_no_match"),
},
}
: {})}
/>
</div>
<div className="flex flex-wrap items-center gap-2 px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground me-auto tabular-nums">
@@ -307,6 +307,46 @@ describe('recipient fields (SR-06, CV-02)', () => {
}
});
// compose-6: Backspace never leaves half an address behind.
it('selects the last chip on the first Backspace and removes it whole on the second', () => {
render(<EmailComposer initialData={{ ...DRAFT, to: 'Anna Berg <anna@example.com>, Zoe Fischer <zoe@example.com>' }} />);
const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement;
input.focus();
fireEvent.keyDown(input, { key: 'Backspace' });
let chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem');
expect(chips).toHaveLength(2);
expect(document.activeElement).toBe(chips[1]);
expect(input.value).toBe('');
fireEvent.keyDown(chips[1], { key: 'Backspace' });
chips = within(screen.getByRole('list', { name: 'to:' })).getAllByRole('listitem');
expect(chips).toHaveLength(1);
expect(chips[0]).toHaveTextContent('Anna Berg');
expect(input.value).toBe('');
expect(document.activeElement).toBe(input);
// Three presses take out two recipients, never part of one.
fireEvent.keyDown(input, { key: 'Backspace' });
fireEvent.keyDown(document.activeElement!, { key: 'Backspace' });
expect(screen.queryAllByRole('listitem')).toHaveLength(0);
expect(input.value).toBe('');
});
it('lets go of a selected chip on Escape and keeps the composer open', () => {
const onClose = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, to: 'anna@example.com' }} onClose={onClose} />);
const input = screen.getByRole('combobox', { name: 'to:' }) as HTMLInputElement;
input.focus();
fireEvent.keyDown(input, { key: 'Backspace' });
const chip = within(screen.getByRole('list', { name: 'to:' })).getByRole('listitem');
expect(document.activeElement).toBe(chip);
fireEvent.keyDown(chip, { key: 'Escape' });
expect(document.activeElement).toBe(input);
expect(screen.getAllByRole('listitem')).toHaveLength(1);
expect(onClose).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('announces a recipient added by typing', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(liveRegion()).toHaveTextContent('');
@@ -370,17 +410,17 @@ describe('message body (SR-07)', () => {
expect(screen.getByLabelText('body_label').tagName).toBe('TEXTAREA');
});
it('marks an empty body invalid and links the error text', async () => {
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', body: '' }} />);
it('asks before sending an empty body instead of marking it invalid', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT, to: 'bob@example.com', subject: 'Hi', body: '' }} onSend={onSend} />);
const editor = screen.getByTestId('rich-text-editor');
expect(editor).not.toHaveAttribute('data-invalid');
fireEvent.keyDown(screen.getByTestId('composer-subject'), { key: 'Enter', ctrlKey: true });
await act(async () => {});
expect(editor).toHaveAttribute('data-invalid', 'true');
const error = document.getElementById(editor.getAttribute('data-describedby')!);
expect(error).toHaveTextContent('validation.body_required');
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('empty_text_title');
expect(editor).not.toHaveAttribute('data-invalid');
expect(onSend).not.toHaveBeenCalled();
});
});
@@ -233,9 +233,8 @@ describe('composer across an account switch', () => {
useAuthStore.setState({ client: clientB as never, activeAccountId: 'acct-B' as never });
view.rerender(<EmailComposer {...props} />);
// Closing saves the draft; no question unless the save fails.
act(() => requestCloseRef.current!(vi.fn()));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(clientA.createDraft).toHaveBeenCalled());
expect(clientB.createDraft).not.toHaveBeenCalled();
@@ -218,21 +218,20 @@ const DRAFT = {
attachments: ATTACHMENTS,
};
/** The wrapping flex row that holds every chip and the expand button. */
/** The wrapping flex row that holds every chip. */
function chipRow() {
return screen.getByText('invoice-0.pdf').closest('[class*="flex-wrap"]') as HTMLElement;
return screen.getByTestId('composer-attachments');
}
/**
* The attachment row is a shrink-0 sibling of the body's scroll container,
* so whatever height it takes is taken from the composer and from nothing
* else. Collapsed that is three chips. Expanded it is one row per two
* attachments, and with a few dozen files it grew past the bottom of the
* composer: the send toolbar was pushed off-screen and no ancestor
* scrolled, so neither the rest of the list nor Send could be reached.
* Expanding therefore has to bound the row and scroll it itself.
* else. With a few dozen files it grew past the bottom of the composer: the
* send toolbar was pushed off-screen and no ancestor scrolled. It used to
* show three chips and "+N", which hid the fourth file's upload. Now every
* chip shows, wrapping, and the row is bounded and scrolls itself.
*/
describe('expanded attachment list', () => {
describe('attachment list', () => {
beforeEach(() => {
observers.length = 0;
vi.stubGlobal('ResizeObserver', ResizeObserverStub);
@@ -242,39 +241,27 @@ describe('expanded attachment list', () => {
vi.unstubAllGlobals();
});
it('does not scroll while collapsed', () => {
it('shows every attachment, with no counter to expand', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(chipRow().className).not.toContain('overflow-y-auto');
expect(screen.getByText('invoice-3.pdf')).toBeInTheDocument();
expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
expect(screen.queryByText(`+${ATTACHMENTS.length - 3}`)).toBeNull();
});
it('shows only the first three attachments and a counter for the rest', () => {
it('caps its height and scrolls itself', () => {
render(<EmailComposer initialData={DRAFT} />);
expect(screen.getByText('invoice-2.pdf')).toBeInTheDocument();
expect(screen.queryByText('invoice-3.pdf')).not.toBeInTheDocument();
expect(screen.getByText(`+${ATTACHMENTS.length - 3}`)).toBeInTheDocument();
});
it('caps its height and scrolls once expanded', () => {
render(<EmailComposer initialData={DRAFT} />);
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
const row = chipRow();
expect(screen.getByText('invoice-11.pdf')).toBeInTheDocument();
expect(row.className).toContain('overflow-y-auto');
// Bounded, or the row grows with the attachment count again and takes
// the send toolbar off the bottom of the composer with it.
expect(row.className).toContain('max-h-[');
});
it('stops scrolling again when collapsed back', () => {
it('says how many files and how much they weigh', () => {
render(<EmailComposer initialData={DRAFT} />);
fireEvent.click(screen.getByText(`+${ATTACHMENTS.length - 3}`));
fireEvent.click(screen.getByText('show_less'));
expect(chipRow().className).not.toContain('overflow-y-auto');
expect(screen.getByTestId('composer-attachments-summary')).toHaveTextContent('attachments_summary');
});
});
@@ -135,6 +135,9 @@ vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
const draftToast = vi.hoisted(() => ({ showDraftSavedToast: vi.fn() }));
vi.mock('@/lib/compose/draft-close-toast', () => draftToast);
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
@@ -164,6 +167,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
escapeHtml: (v: string) => v.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'),
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
@@ -213,8 +217,9 @@ describe('a message row dragged over the composer', () => {
/**
* A host that replaces the compose session - a mailto: click, say - must not
* drop text the user has not sent. It hands a follow-up to requestCloseRef and
* the composer runs it only once it has really closed, so "Save" and "Discard"
* continue into the new session while "Cancel" leaves the draft standing.
* the composer runs it only once it has really closed. Closing saves the draft
* without asking; only a failed save asks, and there "Discard" continues into
* the new session while "Cancel" leaves the draft standing.
*/
const DIRTY_DRAFT = {
@@ -253,13 +258,40 @@ function renderWithCloseRef(props: Record<string, unknown> = {}) {
beforeEach(resetAnnouncerForTests);
describe('composer close guard with a follow-up', () => {
it('asks before replacing an unsaved draft instead of dropping it', async () => {
const { requestCloseRef, afterClose } = renderWithCloseRef();
const savingClient = (createDraft = vi.fn().mockResolvedValue('draft-9')) => {
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
return createDraft;
};
afterEach(() => {
useAuthStore.setState({ client: null });
});
it('saves the draft and runs the follow-up, without asking', async () => {
const createDraft = savingClient();
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
expect(await screen.findByText('close_draft_title')).toBeInTheDocument();
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(createDraft).toHaveBeenCalledTimes(1);
expect(onClose).toHaveBeenCalledTimes(1);
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('asks only when the save fails, and keeps the text', async () => {
// No connection to save through: the save fails.
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('save_failed_close');
expect(within(dialog).getByRole('button', { name: /try_again/ })).toBeInTheDocument();
expect(afterClose).not.toHaveBeenCalled();
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument();
});
describe('in the desktop app', () => {
@@ -273,12 +305,12 @@ describe('composer close guard with a follow-up', () => {
return confirm;
};
it('asks through the OS, and shows no dialog of its own', async () => {
it('asks through the OS when the save fails, and shows no dialog of its own', async () => {
const confirm = nativeBridge(2);
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(confirm).toHaveBeenCalledTimes(1));
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', buttons: ['save', 'discard', 'cancel'], defaultId: 0 }]);
expect(confirm.mock.calls[0]).toMatchObject([{ message: 'close_draft_title', detail: 'save_failed_close', buttons: ['try_again', 'discard', 'cancel'], defaultId: 0 }]);
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
// The last button is Cancel: nothing happens.
await act(async () => {});
@@ -293,9 +325,18 @@ describe('composer close guard with a follow-up', () => {
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
});
it('saves without asking when the save works', async () => {
const confirm = nativeBridge(2);
savingClient();
const { requestCloseRef, afterClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(confirm).not.toHaveBeenCalled();
});
});
it('runs the follow-up after discarding', async () => {
it('runs the follow-up after discarding a draft that could not be saved', async () => {
const { requestCloseRef, afterClose, onClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
@@ -327,44 +368,77 @@ describe('composer close guard with a follow-up', () => {
expect(afterClose).not.toHaveBeenCalled();
});
it('closes without asking once autosave has stored every change', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
await act(async () => {});
it('closes without saving again once autosave has stored every change', async () => {
const createDraft = savingClient();
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
await act(async () => {});
act(() => requestCloseRef.current!(afterClose));
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
} finally {
useAuthStore.setState({ client: null });
}
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(createDraft).toHaveBeenCalledTimes(1);
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
});
it('still asks when there are edits since the last autosave', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
it('saves the edits made since the last autosave when it closes', async () => {
const createDraft = savingClient();
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
act(() => requestCloseRef.current!(afterClose));
act(() => requestCloseRef.current!(afterClose));
expect(await screen.findByText('close_draft_title')).toBeInTheDocument();
} finally {
useAuthStore.setState({ client: null });
}
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(createDraft).toHaveBeenCalledTimes(2);
expect(createDraft.mock.calls[1][1]).toBe('Half-written more and more');
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
});
it('says the draft was kept, with a Discard that deletes it through the host', async () => {
savingClient();
draftToast.showDraftSavedToast.mockClear();
const onDiscardDraft = vi.fn();
const { requestCloseRef, afterClose } = renderWithCloseRef({ onDiscardDraft });
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(draftToast.showDraftSavedToast).toHaveBeenCalledTimes(1);
const [discard, labels] = draftToast.showDraftSavedToast.mock.calls[0];
expect(labels).toMatchObject({ saved: 'draft_saved', discard: 'discard' });
discard();
expect(onDiscardDraft).toHaveBeenCalledWith('draft-9', expect.anything());
});
// compose-19: the keys people bring from other mail apps.
it('saves the draft on Ctrl+S, and keeps the browser from saving the page', async () => {
const createDraft = savingClient();
renderWithCloseRef();
const subject = screen.getByDisplayValue('Half-written more');
expect(fireEvent.keyDown(subject, { key: 's', ctrlKey: true })).toBe(false);
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1));
// Nothing new to save: it still says so.
await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'idle'), { timeout: 4000 });
fireEvent.keyDown(subject, { key: 's', ctrlKey: true });
await waitFor(() => expect(screen.getByTestId('composer-save-status')).toHaveAttribute('data-status', 'saved'));
expect(createDraft).toHaveBeenCalledTimes(1);
});
it('shows Cc on Ctrl+Shift+C and Bcc on Ctrl+Shift+B, and puts the cursor there', async () => {
renderWithCloseRef();
// A new message puts the cursor in To shortly after it opens; let that pass.
await act(async () => { await new Promise((r) => setTimeout(r, 250)); });
const subject = screen.getByDisplayValue('Half-written more');
expect(fireEvent.keyDown(subject, { key: 'C', ctrlKey: true, shiftKey: true })).toBe(false);
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'cc_label' })));
expect(fireEvent.keyDown(subject, { key: 'B', ctrlKey: true, shiftKey: true })).toBe(false);
await waitFor(() => expect(document.activeElement).toBe(screen.getByRole('combobox', { name: 'bcc_label' })));
});
it('closes straight through when there is nothing to lose', async () => {
draftToast.showDraftSavedToast.mockClear();
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
const afterClose = vi.fn();
render(<EmailComposer requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
@@ -373,67 +447,37 @@ describe('composer close guard with a follow-up', () => {
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(screen.queryByText('close_draft_title')).not.toBeInTheDocument();
expect(draftToast.showDraftSavedToast).not.toHaveBeenCalled();
});
it('runs the follow-up after saving the draft', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
act(() => requestCloseRef.current!(afterClose));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(afterClose).toHaveBeenCalledTimes(1));
expect(createDraft).toHaveBeenCalledTimes(1);
} finally {
useAuthStore.setState({ client: null });
}
});
it('tells screen readers the draft was saved when the user saves, not on autosave', async () => {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
it('tells screen readers the draft was saved when the user closes, not on autosave', async () => {
const createDraft = savingClient();
const polite = () => document.getElementById(LIVE_REGION_IDS.polite);
try {
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
await new Promise((r) => setTimeout(r, 200));
expect(polite()?.textContent ?? '').not.toContain('draft_saved');
const { requestCloseRef, afterClose } = renderWithCloseRef();
await waitFor(() => expect(createDraft).toHaveBeenCalledTimes(1), { timeout: 4000 });
await new Promise((r) => setTimeout(r, 200));
expect(polite()?.textContent ?? '').not.toContain('draft_saved');
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
act(() => requestCloseRef.current!(afterClose));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByText('save'));
fireEvent.change(screen.getByDisplayValue('Half-written more'), { target: { value: 'Half-written more and more' } });
act(() => requestCloseRef.current!(afterClose));
await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
} finally {
useAuthStore.setState({ client: null });
}
await waitFor(() => expect(polite()?.textContent).toContain('draft_saved'));
});
it('keeps the composer open and drops the follow-up when the save fails', async () => {
it('keeps the composer open, says why and drops the follow-up when the save fails', async () => {
// A refused save keeps the live composer recoverable. Replacing it via a
// close follow-up would bury the unsaved text. Navigation can still stash
// that text through the existing unmount path.
const createDraft = vi.fn().mockRejectedValue(new Error('server refused'));
useAuthStore.setState({
client: { createDraft, hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
const createDraft = savingClient(vi.fn().mockRejectedValue(new Error('server refused')));
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const onSaveState = vi.fn();
const { requestCloseRef, afterClose, onClose, unmount } = renderWithCloseRef({ onSaveState });
act(() => requestCloseRef.current!(afterClose));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(createDraft).toHaveBeenCalled());
await act(async () => {});
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
expect(onClose).not.toHaveBeenCalled();
expect(screen.getByDisplayValue('Half-written more')).toBeInTheDocument();
expect(afterClose).not.toHaveBeenCalled();
@@ -444,7 +488,6 @@ describe('composer close guard with a follow-up', () => {
expect.objectContaining({ subject: 'Half-written more' }),
);
} finally {
useAuthStore.setState({ client: null });
consoleError.mockRestore();
}
});
@@ -107,7 +107,7 @@ afterEach(async () => {
vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllTimers(); vi.useRealTimers();
});
type Control = 'close' | 'toolbar';
type Control = 'toolbar';
async function openComposer(strict = false) {
const closed = vi.fn();
let reopen!: () => void;
@@ -135,7 +135,7 @@ async function openComposer(strict = false) {
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await advance();
},
dialog: (control: Control) => {
fireEvent.click(screen.getByTestId(control === 'close' ? 'composer-close' : 'composer-discard'));
fireEvent.click(screen.getByTestId(control === 'toolbar' ? 'composer-discard' : 'composer-close'));
return within(screen.getByRole('alertdialog'));
},
reopen: async () => { act(() => reopen()); await advance(); },
@@ -148,7 +148,9 @@ function expectNewComposer() {
expect(screen.queryByRole('progressbar')).toBeNull();
expect(screen.queryByText('A.txt')).toBeNull(); expect(screen.queryByText('C.txt')).toBeNull();
}
const modes = (['close', 'toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict })));
// The X no longer asks Save or Discard: it saves (composer-save-pending-attachments).
// Discard is the toolbar's.
const modes = (['toolbar'] as const).flatMap(control => [false, true].map(strict => ({ control, strict })));
describe('explicit Discard with pending attachment uploads', () => {
describe.each(modes)('$control control, Strict Mode=$strict', ({ control, strict }) => {
@@ -232,7 +234,7 @@ describe('explicit Discard with pending attachment uploads', () => {
await c.add(file('A.txt'), file('C.txt'));
if (method === 'deleteFile') await complete(0);
expect(entered).toHaveBeenCalledOnce();
fireEvent.click(c.dialog('close').getByRole('button', { name: /^Discard$/ })); await advance();
fireEvent.click(c.dialog('toolbar').getByRole('button', { name: /^Discard$/ })); await advance();
await c.reopen();
await act(async () => { gate.resolve(); }); await advance();
await c.add(file('B.txt')); await complete(UploadXHR.instances.findIndex(xhr => xhr.body?.name === 'B.txt'));
@@ -376,7 +376,8 @@ describe('a re-opened draft that lives in a group account', () => {
render(<EmailComposer initialData={GROUP_DRAFT} onDiscardDraft={onDiscardDraft} requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('Quarterly numbers'), { target: { value: 'Quarterly numbers v2' } });
act(() => requestCloseRef.current!());
// Closing keeps the draft now; Discard is the way to delete it.
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard'));
expect(onDiscardDraft).toHaveBeenCalledWith('eaaaaab', { accountId: 'c', localAccountId: undefined });
@@ -393,7 +394,8 @@ describe('a re-opened draft that lives in a group account', () => {
expect(createDraft.mock.calls[0][13]?.previousDraftAccountId).toBeUndefined();
fireEvent.change(screen.getByDisplayValue('Quarterly numbers v2'), { target: { value: 'Quarterly numbers v3' } });
act(() => requestCloseRef.current!());
// Closing keeps the draft now; Discard is the way to delete it.
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
fireEvent.click(within(screen.getByRole('alertdialog')).getByText('discard'));
expect(onDiscardDraft).toHaveBeenCalledWith('draft-v2', { accountId: undefined, localAccountId: undefined });
});
@@ -0,0 +1,280 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { emailHooks } from '@/lib/plugin-hooks';
// "Require encrypted delivery" read like encryption but only asks the servers
// for TLS on the way. It is now named for what it does and is an advanced
// option, and without a crypto plugin an "Encrypt…" item says where to get one.
// ─── Heavy component mocks (mirrors composer-close-guard.test.tsx) ──────────
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
const push = vi.fn();
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push, back: vi.fn(), replace: vi.fn() }),
usePathname: () => '/en',
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
showAdvancedSettings: false,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { size: 0, takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const DRAFT = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Plans',
body: '<p>Hello</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
const openOptions = () => fireEvent.click(screen.getByTestId('composer-options-menu'));
const setComposeSendHandlers = (size: number) => {
(emailHooks.onComposeSend as unknown as { size: number }).size = size;
};
describe('composer encryption options', () => {
beforeEach(() => {
useAuthStore.setState({
client: { supportsSubmissionExtension: (name: string) => name === 'REQUIRETLS', hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: true } } as never);
setComposeSendHandlers(0);
});
afterEach(() => {
useAuthStore.setState({ client: null });
useSettingsStore.setState({ showAdvancedSettings: false } as never);
usePolicyStore.setState({ demoFeatureOverrides: {} } as never);
vi.clearAllMocks();
});
it('names the TLS option for what it does and keeps it to advanced settings', () => {
const { unmount } = render(<EmailComposer initialData={DRAFT} />);
openOptions();
expect(screen.queryByTestId('composer-require-tls-toggle')).toBeNull();
unmount();
useSettingsStore.setState({ showAdvancedSettings: true } as never);
render(<EmailComposer initialData={DRAFT} />);
openOptions();
const item = screen.getByTestId('composer-require-tls-toggle');
expect(item).toHaveTextContent('require_tls');
expect(item).toHaveTextContent('require_tls_hint');
expect(item).not.toHaveTextContent('option_require_tls');
});
it('keeps a ticked TLS option visible so it can be unticked', () => {
render(<EmailComposer initialData={{ ...DRAFT, requireTls: true }} />);
openOptions();
expect(screen.getByTestId('composer-require-tls-toggle')).toHaveAttribute('aria-checked', 'true');
});
it('offers "Encrypt…" without a crypto plugin, opening the add-ons', () => {
render(<EmailComposer initialData={DRAFT} />);
openOptions();
const item = screen.getByTestId('composer-encrypt-setup');
expect(item).toHaveTextContent('encrypt');
expect(item).toHaveTextContent('encrypt_hint');
fireEvent.click(item);
expect(push).toHaveBeenCalledWith('/settings/addons');
});
it('leaves "Encrypt…" out once a crypto plugin takes over sending', () => {
setComposeSendHandlers(1);
render(<EmailComposer initialData={DRAFT} />);
openOptions();
expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull();
});
it('leaves "Encrypt…" out where add-ons are switched off', () => {
usePolicyStore.setState({ demoFeatureOverrides: { pluginsEnabled: false } } as never);
render(<EmailComposer initialData={DRAFT} />);
openOptions();
expect(screen.queryByTestId('composer-encrypt-setup')).toBeNull();
});
});
@@ -11,7 +11,10 @@ vi.mock('@/components/email/rich-text-editor', () => ({
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({
SubAddressHelper: ({ open, hideTrigger }: { open?: boolean; hideTrigger?: boolean }) =>
open ? <div role="dialog">sub-address popover</div> : hideTrigger ? null : <button type="button">sub-address button</button>,
}));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
@@ -268,17 +271,46 @@ describe('composer From override envelope (#1009)', () => {
expect(sent.envelopeMailFrom).toBeUndefined();
});
it('offers no Custom button', () => {
it('offers no Edit sender', () => {
render(<EmailComposer />);
expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument();
expect(screen.queryByRole('option', { name: 'from_edit_sender' })).not.toBeInTheDocument();
});
// Control: the button is there with the gate on, so the test above is not
// Control: the entry is there with the gate on, so the test above is not
// passing on a renamed label.
it('offers the Custom button with the gate on', () => {
it('offers Edit sender with the gate on', () => {
setCustomFrom(true);
render(<EmailComposer />);
expect(screen.getByText('from_override.toggle_off')).toBeInTheDocument();
expect(screen.getByRole('option', { name: 'from_edit_sender' })).toBeInTheDocument();
});
});
});
// compose-20: the rare sender options live at the end of the From list, not
// as buttons on every message.
describe('the From list', () => {
const from = () => screen.getByTestId('composer-from') as HTMLSelectElement;
it('has no sub-address or Override button in the row', () => {
render(<EmailComposer />);
expect(screen.queryByText('sub-address button')).not.toBeInTheDocument();
expect(screen.queryByText('from_override.toggle_off')).not.toBeInTheDocument();
expect(screen.getByRole('option', { name: 'from_sub_address' })).toBeInTheDocument();
});
it('opens the sub-address popover from the list and keeps the identity', () => {
render(<EmailComposer />);
const before = from().value;
fireEvent.change(from(), { target: { value: '__sub_address' } });
expect(screen.getByRole('dialog')).toHaveTextContent('sub-address popover');
expect(from().value).toBe(before);
});
it('turns From into fields to edit from the list, with a way back', () => {
render(<EmailComposer />);
fireEvent.change(from(), { target: { value: '__edit_sender' } });
expect(screen.getByLabelText('from_override.email_label')).toHaveValue('me@example.com');
fireEvent.click(screen.getByText('from_override.toggle_on'));
expect(screen.queryByLabelText('from_override.email_label')).not.toBeInTheDocument();
});
});
@@ -3,6 +3,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { useAuthStore } from '@/stores/auth-store';
import { useContactStore } from '@/stores/contact-store';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
@@ -301,7 +302,7 @@ describe('composer with the @ recipient list', () => {
}
});
it('lets Escape close the open list, not the composer; the next Escape asks to close', async () => {
it('lets Escape close the open list, not the composer; the next Escape closes that', async () => {
const { editor, onClose } = await renderComposer();
await typeInto(editor, ' @');
await screen.findByRole('listbox', { name: 'mention_recipients' });
@@ -311,8 +312,10 @@ describe('composer with the @ recipient list', () => {
expect(screen.queryByRole('alertdialog')).toBeNull();
expect(onClose).not.toHaveBeenCalled();
// The composer's close runs: with no connection here its save fails,
// which is when it asks.
press(editor, 'Escape');
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
});
it('keeps Ctrl+Enter as the send shortcut while the list is open', async () => {
@@ -357,7 +360,7 @@ describe('composer with the @ recipient list', () => {
await waitFor(() => expect(mentionList()).toBeNull());
fireEvent.keyDown(subject, { key: 'Escape' });
expect(await screen.findByRole('alertdialog')).toHaveTextContent('close_draft_title');
expect(await screen.findByRole('alertdialog')).toHaveTextContent('save_failed_close');
});
it('closes the list on a click elsewhere, but not on a click on the list itself', async () => {
@@ -538,12 +541,30 @@ describe('composer with the @ recipient list', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('pulls the last recipient back whole on Backspace, without the key deleting its last character', async () => {
// The first Backspace selects the last recipient, it doesn't turn it into text.
it('selects the last recipient on Backspace, without the key deleting anything', async () => {
await renderComposer({ to: 'Sarah Kim <sarah@example.com>' });
const to = within(screen.getByTestId('composer-to')).getByRole('combobox') as HTMLInputElement;
// fireEvent returns false when the handler stopped the key's default action.
expect(fireEvent.keyDown(to, { key: 'Backspace' })).toBe(false);
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
expect(to.value).toBe('');
expect(document.activeElement).toHaveAttribute('data-recipient-chip');
});
// compose-17: the button was there only with a server directory, so
// contacts alone were never offered.
it('offers the recipient picker for the contacts when the server has no directory', async () => {
const contactState = (useContactStore as unknown as { getState: () => { contacts: unknown[] } }).getState();
contactState.contacts = [{ id: 'c1', name: { full: 'Zoe Fischer' }, emails: { e: { address: 'zoe@example.com' } } }];
try {
await renderComposer();
const button = screen.getByTestId('composer-directory-picker');
expect(button).toHaveAccessibleName('picker_button');
fireEvent.click(button);
expect(screen.getByRole('dialog', { name: 'directory' })).toBeInTheDocument();
} finally {
contactState.contacts = [];
}
});
// The directory picker is drawn inside the composer itself, not portaled.
@@ -0,0 +1,289 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// Writing offline. The footer says "Offline" next to Send. With "Keep mail on
// this device" on, Send puts the message in the outbox as before. Without it
// nothing can keep the message safely for later: Send says so, keeps the
// text, and points at the setting. It used to fail with "Failed to send".
const offline = vi.hoisted(() => ({ active: false, allowed: true }));
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
...(await importOriginal<object>()),
offlineActive: () => offline.active,
offlineAllowed: () => offline.allowed,
}));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) =>
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
const DATA = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Lunch',
body: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
let online = true;
beforeEach(() => {
online = true;
offline.active = false;
offline.allowed = true;
Object.defineProperty(navigator, 'onLine', { configurable: true, get: () => online });
});
afterEach(() => {
online = true;
});
function goOffline() {
online = false;
act(() => { window.dispatchEvent(new Event('offline')); });
}
function goOnline() {
online = true;
act(() => { window.dispatchEvent(new Event('online')); });
}
const footer = () => document.querySelector<HTMLElement>('[data-composer-footer]')!;
describe('the composer while offline', () => {
it('says "Offline" next to Send, and stops once the network is back', () => {
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
expect(screen.queryByTestId('composer-offline')).toBeNull();
goOffline();
const label = screen.getByTestId('composer-offline');
expect(footer()).toContainElement(label);
expect(label.textContent).toContain('offline');
expect(label.textContent).toContain('offline_hint');
goOnline();
expect(screen.queryByTestId('composer-offline')).toBeNull();
});
it('without offline mode, Send keeps the message and says why, with the way to the setting', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
goOffline();
fireEvent.click(screen.getByTestId('composer-send'));
const notice = await screen.findByTestId('composer-offline-notice');
expect(notice.textContent).toContain('offline_send_unsaved');
const link = screen.getByRole('link', { name: 'settings.keep_label' });
expect(link.getAttribute('href')).toMatch(/\/settings\/backup\/offline$/);
expect(link).toHaveAttribute('target', '_blank');
expect(onSend).not.toHaveBeenCalled();
expect(screen.getByTestId('email-composer')).toBeInTheDocument();
goOnline();
expect(screen.queryByTestId('composer-offline-notice')).toBeNull();
});
it('says the message is in Drafts only when the server has it', async () => {
render(<EmailComposer initialData={{ ...DATA, draftId: 'd1' }} ownWindow={{}} onSend={vi.fn()} />);
goOffline();
fireEvent.click(screen.getByTestId('composer-send'));
expect((await screen.findByTestId('composer-offline-notice')).textContent).toContain('offline_send_saved');
});
it('Ctrl+Enter takes the same way', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
goOffline();
fireEvent.keyDown(screen.getByTestId('composer-send'), { key: 'Enter', ctrlKey: true });
await screen.findByTestId('composer-offline-notice');
expect(onSend).not.toHaveBeenCalled();
});
it('leaves out the link where this device may not keep mail', async () => {
offline.allowed = false;
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={vi.fn()} />);
goOffline();
fireEvent.click(screen.getByTestId('composer-send'));
await screen.findByTestId('composer-offline-notice');
expect(screen.queryByRole('link', { name: 'settings.keep_label' })).toBeNull();
});
it('with offline mode on, Send goes ahead: the outbox keeps it until the network is back', async () => {
offline.active = true;
const onSend = vi.fn();
render(<EmailComposer initialData={DATA} ownWindow={{}} onSend={onSend} />);
goOffline();
expect(screen.getByTestId('composer-offline').textContent).toContain('offline_hint_outbox');
fireEvent.click(screen.getByTestId('composer-send'));
await waitFor(() => expect(onSend).toHaveBeenCalled());
expect(screen.queryByTestId('composer-offline-notice')).toBeNull();
});
});
@@ -1,5 +1,5 @@
import React from 'react';
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextIntlClientProvider } from 'next-intl';
import { EmailComposer, type ComposerDraftData, type ComposerPopOut } from '../email-composer';
@@ -325,6 +325,20 @@ describe('a composer that is all its window shows', () => {
}
});
// Escape in a pop-out or quick compose window: the same as anywhere,
// saved and kept, and the main window says so (this one is gone).
it('saves on Escape, closes, and hands "Draft saved" to the main window', async () => {
const onDraftKept = vi.fn();
const onClose = vi.fn();
mount({ ownWindow: { onDraftKept }, onClose });
fireEvent.change(subjectField(), { target: { value: 'Kept on Escape' } });
fireEvent.keyDown(subjectField(), { key: 'Escape' });
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(creations.map((draft) => draft.subject)).toEqual(['Kept on Escape']);
expect(onDraftKept).toHaveBeenCalledWith('saved-1', expect.objectContaining({ localAccountId: expect.anything() }));
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('lets an untouched composer go without a word', () => {
mount({ ownWindow: {} });
expect(unload().defaultPrevented).toBe(false);
@@ -364,3 +378,22 @@ describe('a composer that is all its window shows', () => {
expect(creations).toHaveLength(saved);
});
});
describe('back online', () => {
it('saves what could not be saved while offline, so "saved in Drafts once you are back online" holds', async () => {
let onLine = true;
vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => onLine);
rejectSave = true;
mount({});
onLine = false;
act(() => { window.dispatchEvent(new Event('offline')); });
fireEvent.change(subjectField(), { target: { value: 'Written on a train' } });
await waitFor(() => expect(creations).toHaveLength(1), { timeout: 4000 });
rejectSave = false;
onLine = true;
act(() => { window.dispatchEvent(new Event('online')); });
await waitFor(() => expect(creations).toHaveLength(2));
expect(creations[1].subject).toBe('Written on a train');
});
});
@@ -331,6 +331,21 @@ describe('composer-send-menu', () => {
expect(schedule).toHaveTextContent('Ctrl + Shift + Enter');
});
// compose-23: a menu with "Schedule send" as its only item.
it('opens the schedule dialog straight from the arrow when it is the only entry', () => {
useAuthStore.setState({
client: { hasDelayedSend: () => true, getMaxDelayedSend: () => 30 * 86400, createDraft: vi.fn() } as never,
});
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
const arrow = screen.getAllByRole('button', { name: 'schedule_send' })[0];
expect(arrow).toHaveAttribute('aria-haspopup', 'dialog');
fireEvent.click(arrow);
expect(screen.queryByRole('menuitem', { name: /schedule_send/ })).toBeNull();
expect(screen.getByRole('dialog')).toHaveTextContent('schedule_send_description');
});
it('keeps a plain Send button without plugin entries or scheduled send', () => {
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
expect(screen.queryByRole('button', { name: 'more_actions' })).toBeNull();
@@ -232,9 +232,8 @@ function mockClient() {
}
async function saveViaCloseDialog(requestCloseRef: React.MutableRefObject<((afterClose?: () => void) => void) | null>) {
// Closing saves the draft; no question unless the save fails.
act(() => requestCloseRef.current!(vi.fn()));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
}
describe('composer message priority (#970)', () => {
@@ -1,4 +1,4 @@
import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
@@ -226,9 +226,8 @@ describe('re-opened reply draft', () => {
render(<EmailComposer initialData={REOPENED_REPLY} requestCloseRef={requestCloseRef} onClose={vi.fn()} />);
fireEvent.change(screen.getByDisplayValue('Re: Plans'), { target: { value: 'Re: Plans, edited' } });
// Closing saves the draft; no question unless the save fails.
act(() => requestCloseRef.current!(vi.fn()));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(createDraft).toHaveBeenCalled());
const args = createDraft.mock.calls[0];
@@ -379,3 +379,39 @@ describe('reply quote attribution markers', () => {
expect(html).toContain('<p data-reply-quote-start="true"><b>From:</b> Bob</p>');
});
});
// The forwarded header had From, Date and Subject only; Gmail and Outlook
// also say who the mail went to.
describe('forwarded header recipients', () => {
afterEach(() => {
useSettingsStore.setState({ plainTextMode: false, signaturePosition: 'below_quote' });
setIdentities([ME]);
});
const FORWARDED = {
...REPLY_TO,
to: [{ name: 'Anna Becker', email: 'anna@example.com' }, { email: 'team@example.com' }],
cc: [{ name: 'Carol', email: 'carol@example.com' }],
};
it('lists To and Cc in a plain text forward', async () => {
useSettingsStore.setState({ plainTextMode: true });
const onSend = vi.fn();
render(<EmailComposer mode="forward" replyTo={FORWARDED} prefill={{ to: ['dave@example.com'] }} onSend={onSend} />);
const sent = await send(onSend);
expect(sent.body).toContain('subject_label: Question\nforward_to_label: Anna Becker <anna@example.com>, team@example.com\nforward_cc_label: Carol <carol@example.com>\n\nOriginal text');
});
// escapeHtml is mocked to pass values through in this file.
it('lists To and Cc in an HTML forward', () => {
render(<EmailComposer mode="forward" replyTo={FORWARDED} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html).toContain('subject_label: Question<br>forward_to_label: Anna Becker <anna@example.com>, team@example.com<br>forward_cc_label: Carol <carol@example.com><br><br>');
});
it('leaves out a Cc line when nobody was copied', () => {
render(<EmailComposer mode="forward" replyTo={{ ...FORWARDED, cc: [] }} />);
const html = screen.getByTestId('rich-text-editor').getAttribute('data-html')!;
expect(html).toContain('forward_to_label: ');
expect(html).not.toContain('forward_cc_label');
});
});
@@ -164,9 +164,9 @@ async function openComposer() {
add: async (...files: File[]) => {
fireEvent.change(document.querySelector('input[type="file"]')!, { target: { files } }); await flush();
},
// Closing saves the draft without asking (it asks only when that fails).
chooseSave: async () => {
fireEvent.click(screen.getByTestId('composer-close'));
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Save$/ }));
await flush();
},
reopen: async () => { act(() => reopen()); await flush(); },
@@ -229,7 +229,8 @@ describe('Save and close with attachment uploads', () => {
await c.chooseSave(); await expectSaved(['A.txt']); await waitFor(() => expect(c.closed).toHaveBeenCalledOnce());
});
it('can discard during the wait without a later save or close follow-up', async () => {
const c = await openComposer(); await c.add(file()); await c.chooseSave(); c.closeAgain();
const c = await openComposer(); await c.add(file()); await c.chooseSave();
fireEvent.click(screen.getAllByTestId('composer-discard')[0]);
fireEvent.click(within(await screen.findByRole('alertdialog')).getByRole('button', { name: /^Discard$/ }));
await complete(0); await new Promise(resolve => setTimeout(resolve, 200));
expect(creations).toEqual([]); expect(c.closed).toHaveBeenCalledOnce();
@@ -0,0 +1,238 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
const editorKeyDown = vi.hoisted(() => vi.fn());
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: () => React.createElement('div', {
'data-testid': 'rich-text-editor',
onKeyDown: (event: React.KeyboardEvent) => {
if (!event.defaultPrevented) editorKeyDown();
},
}),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
const updateSetting = vi.fn();
// compose-24: the host closes the composer as soon as the send starts. A send
// that fails must still hand the message back, or it would vanish.
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: (...args: unknown[]) => updateSetting(...args),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
const READY = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Quarterly numbers',
body: '<p>Here they are.</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: 'draft-3',
};
const sendButton = () => screen.getAllByTestId('composer-send')[0] as HTMLButtonElement;
describe('a send that fails after the host closed the composer', () => {
it('hands the message back to the host', async () => {
const onSend = vi.fn().mockRejectedValue(new Error('refused'));
const onSendFailed = vi.fn();
render(<EmailComposer initialData={READY} onSend={onSend} onSendFailed={onSendFailed} />);
fireEvent.click(sendButton());
await waitFor(() => expect(onSendFailed).toHaveBeenCalledTimes(1));
expect(onSendFailed.mock.calls[0][0]).toMatchObject({
to: 'bob@example.com',
subject: 'Quarterly numbers',
body: '<p>Here they are.</p>',
draftId: 'draft-3',
});
});
it('says nothing to the host when the message went out', async () => {
const onSend = vi.fn().mockResolvedValue(undefined);
const onSendFailed = vi.fn();
render(<EmailComposer initialData={READY} onSend={onSend} onSendFailed={onSendFailed} />);
fireEvent.click(sendButton());
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSendFailed).not.toHaveBeenCalled();
});
});
@@ -252,7 +252,7 @@ describe('composer send failures', () => {
// window.confirm, before the message goes out at once.
it('asks in a dialog before sending without the delay the account lacks', async () => {
const nativeConfirm = vi.spyOn(window, 'confirm');
useSettingsStore.setState({ sendDelaySeconds: 10 } as never);
useSettingsStore.setState({ sendDelaySeconds: 10, explicitSettings: ['sendDelaySeconds'] } as never);
try {
const onSend = vi.fn().mockResolvedValue(undefined);
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
@@ -262,6 +262,21 @@ describe('composer send failures', () => {
expect(onSend).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByRole('button', { name: 'send' }));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
} finally {
useSettingsStore.setState({ sendDelaySeconds: 0, explicitSettings: [] } as never);
}
});
// Undo send is on by default: an account that can't delay just sends.
it('sends at once, without asking, when the delay is only the default', async () => {
useSettingsStore.setState({ sendDelaySeconds: 5, explicitSettings: [] } as never);
try {
const onSend = vi.fn().mockResolvedValue(undefined);
render(<EmailComposer initialData={DRAFT} onSend={onSend} />);
fireEvent.click(screen.getAllByTestId('composer-send')[0]);
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0].delayedUntil).toBeUndefined();
expect(screen.queryByRole('alertdialog', { name: 'send_delay_unsupported_title' })).toBeNull();
} finally {
useSettingsStore.setState({ sendDelaySeconds: 0 } as never);
}
@@ -246,15 +246,18 @@ describe('composer flush before an automatic sign-out', () => {
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
});
it('asks before closing a composer opened with stashed edits', async () => {
// Closing keeps the draft without asking: the stashed edits are saved first.
it('saves a composer opened with stashed edits before it closes', async () => {
const onClose = vi.fn();
const requestCloseRef: { current: ((afterClose?: () => void) => void) | null } = { current: null };
render(<EmailComposer initialData={{ ...DRAFT, unsaved: true }} onClose={onClose} requestCloseRef={requestCloseRef} />);
act(() => requestCloseRef.current?.());
expect(await screen.findByRole('alertdialog')).toBeTruthy();
expect(onClose).not.toHaveBeenCalled();
await waitFor(() => expect(onClose).toHaveBeenCalled());
expect(createDraft).toHaveBeenCalledTimes(1);
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('closes a composer opened with text the server already has without asking', () => {
@@ -1,9 +1,10 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
import { useTemplateStore } from '@/stores/template-store';
// The real composer around the real editor: applying a template to a reply
// the user has already started (#911, follow-up to #540).
@@ -30,9 +31,9 @@ vi.mock('@/components/templates/template-picker', () => ({
React.createElement('button', { 'data-testid': 'pick-template', onClick: () => onSelect(TEMPLATE, {}) }, 'pick'),
}));
// Save as template: what the composer hands the form.
const savedForm = vi.hoisted(() => ({ initialData: null as null | { body?: string; isHTML?: boolean } }));
const savedForm = vi.hoisted(() => ({ initialData: null as null | { name?: string; body?: string; isHTML?: boolean } }));
vi.mock('@/components/templates/template-form', () => ({
TemplateForm: (props: { initialData?: { body?: string; isHTML?: boolean } }) => {
TemplateForm: (props: { initialData?: { name?: string; body?: string; isHTML?: boolean } }) => {
savedForm.initialData = props.initialData ?? null;
return null;
},
@@ -158,6 +159,7 @@ vi.mock('@/lib/plugin-hooks', () => ({
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
onTemplateCreate: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
@@ -308,13 +310,46 @@ describe('applying a template to a reply (#911)', () => {
savedForm.initialData = null;
});
async function openSaveAsTemplate() {
async function openNameDialog() {
fireEvent.click(await screen.findByTestId('composer-templates-menu'));
fireEvent.click(await screen.findByRole('menuitem', { name: 'save_as_template' }));
return screen.findByTestId('quick-save-template');
}
// The full editor is behind "Edit details…" (compose-18).
async function openSaveAsTemplate() {
fireEvent.click(within(await openNameDialog()).getByText('template_edit_details'));
await waitFor(() => expect(savedForm.initialData).not.toBeNull());
return savedForm.initialData!;
}
// compose-18: Save as template opened an editor with nine fields.
it('asks for a name only, starting from the subject, and saves the message', async () => {
useTemplateStore.setState({ templates: [] });
await openReply();
const subject = (screen.getByTestId('composer-subject') as HTMLInputElement).value;
const dialog = await openNameDialog();
const name = within(dialog).getByLabelText('template_name') as HTMLInputElement;
expect(name.value).toBe(subject);
expect(savedForm.initialData).toBeNull();
fireEvent.change(name, { target: { value: 'Quick answer' } });
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(useTemplateStore.getState().templates).toHaveLength(1));
expect(useTemplateStore.getState().templates[0]).toMatchObject({ name: 'Quick answer', subject, isHTML: true });
expect(screen.queryByTestId('quick-save-template')).not.toBeInTheDocument();
});
it('carries the name over into the full editor', async () => {
await openReply();
const dialog = await openNameDialog();
fireEvent.change(within(dialog).getByLabelText('template_name'), { target: { value: 'Quick answer' } });
fireEvent.click(within(dialog).getByText('template_edit_details'));
await waitFor(() => expect(savedForm.initialData).toMatchObject({ name: 'Quick answer' }));
});
it('marks a rich text draft as HTML', async () => {
await openReply();
const data = await openSaveAsTemplate();
@@ -0,0 +1,314 @@
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
import { useSettingsStore } from '@/stores/settings-store';
import { useIdentityStore } from '@/stores/identity-store';
import type { Identity } from '@/lib/jmap/types';
import { useAuthStore } from '@/stores/auth-store';
// ─── Heavy component mocks (as composer-reply-signature.test.tsx) ─
// A stand-in for the TipTap editor: it hands the composer an editor object
// whose content is whatever the composer last rendered into it, the way the
// real editor reports itself ready from an effect after mounting.
const editorState = vi.hoisted(() => ({ html: '' }));
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ content, onChange, onEditorReady }: {
content: string;
onChange: (html: string) => void;
onEditorReady?: (editor: unknown) => void;
}) => {
editorState.html = content;
const onChangeRef = React.useRef(onChange);
onChangeRef.current = onChange;
React.useEffect(() => {
onEditorReady?.({
isDestroyed: false,
commands: {
focus: () => {},
setContent: (html: string) => {
editorState.html = html;
onChangeRef.current(html);
},
},
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-html': content });
},
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
// ─── Store mocks ──────────────────────────────────────────────────────────────
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [
{ id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' },
{ id: 'id-team', email: 'team@example.com', name: 'Team', htmlSignature: '', textSignature: '' },
] as Array<Record<string, unknown>>,
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook, notifyIdentitySelected: () => {} };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
// The default position: replies/forwards get the signature appended at
// send time rather than embedded above the quote.
signaturePosition: 'below_quote',
signatureNewMailEnabled: true,
signatureReplyForwardEnabled: true,
signatureSeparatorEnabled: true,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
externalContentPolicy: 'block',
isSenderTrusted: () => false,
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
// ─── Misc dependency mocks ────────────────────────────────────────────────────
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeSignatureHtmlForDisplay: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
sanitizePluginBodyHtml: (v: string) => v,
escapeHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: (html: string) => `<div data-quoted-html="">${html}</div>`,
serializeEditorContent: () => editorState.html,
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
// ─── Tests ────────────────────────────────────────────────────────────────────
/**
* A Reply or Forward opened and closed again, with nothing typed or changed,
* leaves no draft: the quote and the signature it starts with are no edit,
* nor is a signature that follows the identity the reply resolves to later.
*/
const ME = { id: 'id-me', email: 'me@example.com', name: 'Me', htmlSignature: '<b>Alice</b>', textSignature: 'Alice' };
const SALES = { id: 'id-sales', email: 'sales@example.com', name: 'Sales', htmlSignature: '<i>Sales team</i>', textSignature: 'Sales team' };
const REPLY_TO = {
from: [{ name: 'Bob', email: 'bob@example.com' }],
to: [{ email: 'sales@example.com' }],
subject: 'Question',
body: 'Original text',
htmlBody: '<p>Original text</p>',
receivedAt: '2026-08-01T10:00:00Z',
};
function savingClient() {
const createDraft = vi.fn().mockResolvedValue('draft-9');
useAuthStore.setState({
client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
});
return createDraft;
}
function renderReply(mode: 'reply' | 'replyAll' | 'forward') {
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
const onClose = vi.fn();
render(<EmailComposer mode={mode} replyTo={REPLY_TO} requestCloseRef={requestCloseRef} onClose={onClose} />);
return { requestCloseRef, onClose };
}
const pastAutosave = () => act(async () => { await new Promise((r) => setTimeout(r, 2600)); });
describe('an untouched reply or forward', () => {
afterEach(() => {
useAuthStore.setState({ client: null });
useSettingsStore.setState({ signaturePosition: 'below_quote' });
useIdentityStore.setState({ identities: [ME] as Identity[] });
});
for (const mode of ['reply', 'replyAll', 'forward'] as const) {
it(`saves nothing, also not by autosave, and closes without asking (${mode})`, async () => {
const createDraft = savingClient();
const { requestCloseRef, onClose } = renderReply(mode);
await pastAutosave();
expect(createDraft).not.toHaveBeenCalled();
act(() => requestCloseRef.current!());
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(createDraft).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
}
// The host builds the quote header after the composer opened (a plugin
// may rewrite it), so the body changes once by itself.
it('stays untouched when the quote header arrives after it opened', async () => {
const createDraft = savingClient();
const requestCloseRef = { current: null } as React.MutableRefObject<((afterClose?: () => void) => void) | null>;
const onClose = vi.fn();
const props = { mode: 'reply' as const, requestCloseRef, onClose };
const view = render(<EmailComposer {...props} replyTo={REPLY_TO} />);
view.rerender(<EmailComposer {...props} replyTo={{ ...REPLY_TO, quoteHeaderHtml: '<p>Bob wrote:</p>', quoteHeaderText: 'Bob wrote:' }} />);
await pastAutosave();
expect(createDraft).not.toHaveBeenCalled();
act(() => requestCloseRef.current!());
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(createDraft).not.toHaveBeenCalled();
});
it('stays untouched when its signature follows the identity it resolves to', async () => {
useSettingsStore.setState({ signaturePosition: 'above_quote' });
useIdentityStore.setState({ identities: [ME, SALES] as Identity[] });
const createDraft = savingClient();
const { requestCloseRef, onClose } = renderReply('reply');
await pastAutosave();
act(() => requestCloseRef.current!());
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(createDraft).not.toHaveBeenCalled();
});
it('saves once the user changes something', async () => {
const createDraft = savingClient();
const { requestCloseRef, onClose } = renderReply('reply');
fireEvent.change(screen.getByDisplayValue(/Question/), { target: { value: 'Question, answered' } });
act(() => requestCloseRef.current!());
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
expect(createDraft).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,275 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer } from '../email-composer';
// One layout in every composer: Send at the bottom end, Discard as a bin left
// of it, the formatting toolbar showing. A pop-out or quick compose window is
// narrower than a tablet, and used to get the phone's layout: Send in the
// header, Discard where Send was, the toolbar behind a button.
vi.mock('@/components/email/rich-text-editor', () => ({
RichTextEditor: ({ collapseToolbarOnMobile }: { collapseToolbarOnMobile?: boolean }) =>
React.createElement('div', { 'data-testid': 'rich-text-editor', 'data-collapsed': String(!!collapseToolbarOnMobile) }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/components/ui/avatar', () => ({ Avatar: () => null }));
vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
vi.mock('@/hooks/use-focus-trap', () => ({
useFocusTrap: () => ({ current: null }),
}));
vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
}));
vi.mock('@/stores/auth-store', () => {
const state = {
client: null,
identities: [],
primaryIdentity: null,
isAuthenticated: false,
isDemoMode: false,
activeAccountId: null,
connectionLost: false,
getClientForAccount: () => undefined,
getAllConnectedClients: () => new Map(),
syncIdentities: () => {},
refreshIdentities: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAuthStore: hook };
});
vi.mock('@/stores/identity-store', () => {
const state = {
identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
defaultIdentityId: 'id-me',
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useIdentityStore: hook };
});
vi.mock('@/stores/account-store', () => {
const state = { accounts: [], getAccountById: () => undefined };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useAccountStore: hook };
});
vi.mock('@/stores/email-store', () => {
const state = {
draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useEmailStore: hook };
});
vi.mock('@/stores/settings-store', () => {
const state = {
timeFormat: '24h',
plainTextMode: false,
subAddressDelimiter: '+',
autoSelectReplyIdentity: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
emptySubjectWarningEnabled: true,
sendDelaySeconds: 0,
signaturePosition: 'above_quote',
signatureSeparatorEnabled: false,
requestReadReceiptDefault: false,
addTrustedSender: () => {},
trustedSendersAddressBook: null,
updateSetting: () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useSettingsStore: hook };
});
vi.mock('@/stores/contact-store', () => {
const state = {
contacts: [],
getAutocomplete: async () => [],
addToTrustedSendersBook: async () => {},
};
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useContactStore: hook };
});
vi.mock('@/stores/template-store', () => {
const state = { templates: [], addTemplate: async () => {} };
const hook = (sel?: (s: typeof state) => unknown) =>
typeof sel === 'function' ? sel(state) : state;
hook.getState = () => state;
hook.setState = (p: Partial<typeof state>) => Object.assign(state, p);
return { useTemplateStore: hook };
});
vi.mock('@/stores/toast-store', () => ({
toast: { info: () => {}, error: () => {}, success: () => {}, warning: () => {} },
}));
vi.mock('@/lib/plugin-hooks', () => ({
identityHooks: {
onSignatureRender: { size: 0, transform: async (value: unknown) => value },
onIdentitySelect: { size: 0, emit: async () => {} },
},
templateHooks: {
onTemplateRender: { size: 0, transform: async (value: unknown) => value },
onTemplateApply: { size: 0, emit: async () => {} },
},
emailHooks: {
onComposerOpen: { call: async () => [] },
onRecipientChange: { call: async () => [] },
getRecipientSuggestions: { call: async () => [] },
onRecipientChipsChange: { transform: async (chips: unknown) => chips },
onDraftChange: { emit: () => {} },
onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
onComposeSend: { takeOver: async () => 'default' },
onTransformOutgoingEmail: { transform: async (email: unknown) => email },
},
contactHooks: {
search: { call: async () => [] },
onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
},
}));
vi.mock('@/lib/email-sanitization', () => ({
sanitizeSignatureHtml: (v: string) => v,
sanitizeEmailHtml: (v: string) => v,
parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
}));
vi.mock('@/lib/email-threading', () => ({
computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
}));
vi.mock('@/lib/signature-utils', () => ({
appendPlainTextSignature: (body: string) => body,
getPlainTextSignature: () => '',
}));
vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
vi.mock('@/components/email/quoted-html', () => ({
buildQuotedHtmlBlock: () => '',
serializeEditorContent: () => '',
}));
vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
const DATA = {
to: 'bob@example.com',
cc: '',
bcc: '',
subject: 'Lunch',
body: '<p>Hello there</p>',
showCc: false,
showBcc: false,
selectedIdentityId: 'id-me',
subAddressTag: '',
mode: 'compose' as const,
draftId: null,
};
const footer = () => document.querySelector<HTMLElement>('[data-composer-footer]')!;
const header = () => document.querySelector<HTMLElement>('[data-composer-header]')!;
describe('a composer in a window of its own', () => {
it('has one Send, at the bottom end, with Discard as a bin right before it', () => {
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
const sends = screen.getAllByTestId('composer-send');
expect(sends).toHaveLength(1);
expect(footer()).toContainElement(sends[0]);
expect(sends[0].className).not.toMatch(/(^|\s)hidden(\s|$)/);
expect(within(header()).queryByTestId('composer-send')).toBeNull();
const discard = screen.getByTestId('composer-discard');
expect(discard.querySelector('svg')).not.toBeNull();
// Right before Send, in the same group.
expect(discard.nextElementSibling).toBe(sends[0]);
});
it('shows the formatting toolbar, with no button to fold it away', () => {
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'false');
expect(screen.queryByRole('button', { name: 'formatting' })).toBeNull();
});
it('keeps the close button where the main window has it', () => {
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
expect(screen.getByTestId('composer-close').className).not.toContain('order-first');
});
});
describe('full screen', () => {
it('puts the composer over the whole window, and the same button puts it back', () => {
render(<EmailComposer initialData={DATA} />);
const root = screen.getByTestId('email-composer');
expect(root).not.toHaveAttribute('data-maximized');
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
expect(root).toHaveAttribute('data-maximized');
expect(root.className).toMatch(/(^|\s)fixed(\s|$)/);
expect(root.className).toContain('inset-0');
fireEvent.click(screen.getByRole('button', { name: 'restore' }));
expect(root).not.toHaveAttribute('data-maximized');
expect(root.className).not.toMatch(/(^|\s)fixed(\s|$)/);
});
it('goes back with Escape, without closing the composer', () => {
const onClose = vi.fn();
render(<EmailComposer initialData={DATA} onClose={onClose} />);
fireEvent.click(screen.getByRole('button', { name: 'maximize' }));
const root = screen.getByTestId('email-composer');
fireEvent.keyDown(screen.getByRole('button', { name: 'restore' }), { key: 'Escape' });
expect(root).not.toHaveAttribute('data-maximized');
expect(onClose).not.toHaveBeenCalled();
expect(screen.queryByRole('alertdialog')).toBeNull();
});
it('is not offered in a window of its own, which is all composer already', () => {
render(<EmailComposer initialData={DATA} ownWindow={{}} />);
expect(screen.queryByRole('button', { name: 'maximize' })).toBeNull();
});
});
describe('a composer in the main window', () => {
it('keeps the phone layout for a phone: Send in the header there, toolbar folded', () => {
render(<EmailComposer initialData={DATA} />);
expect(within(header()).getByTestId('composer-send')).toBeInTheDocument();
expect(screen.getByTestId('rich-text-editor')).toHaveAttribute('data-collapsed', 'true');
expect(screen.getByRole('button', { name: 'formatting' })).toBeInTheDocument();
});
it('shows Discard as a bin too, with its name for screen readers', () => {
render(<EmailComposer initialData={DATA} />);
const discard = screen.getByTestId('composer-discard');
expect(discard).toHaveAccessibleName('discard');
expect(discard.querySelector('svg')).not.toBeNull();
expect(within(discard).getByText('discard').className).toContain('sr-only');
});
});
@@ -263,27 +263,76 @@ describe('composer empty subject warning', () => {
);
});
it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
// compose-27: the subject and the attachment used to be two dialogs in a row.
it('asks about a missing subject and a missing attachment in one dialog', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
try {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
fireEvent.click(sendButton());
fireEvent.click(await screen.findByText('empty_subject.send_anyway'));
// The body says "Hello", so the attachment reminder stops the send.
fireEvent.click(await screen.findByText('forgot_attachment.back'));
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('warnings_title');
expect(dialog).toHaveTextContent('warning_subject');
expect(dialog).toHaveTextContent('warning_attachment');
// Several warnings: no "don't ask again", which would be unclear.
expect(within(dialog).queryByRole('checkbox')).not.toBeInTheDocument();
expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
expect(screen.queryByText('empty_subject.title')).not.toBeInTheDocument();
fireEvent.click(within(dialog).getByText('send_anyway'));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument();
} finally {
useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
}
});
it('goes back to editing from the combined dialog without sending', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['hello'] } as never);
try {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
fireEvent.click(sendButton());
fireEvent.click(await screen.findByText('back'));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
expect(onSend).not.toHaveBeenCalled();
} finally {
useSettingsStore.setState({ attachmentReminderEnabled: false, attachmentReminderKeywords: [] } as never);
}
});
// compose-28: Send works with an empty body and asks first.
it('keeps Send enabled with an empty body and asks "Send without text?"', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT_WITHOUT_SUBJECT, subject: 'Lunch?', body: '' }} onSend={onSend} />);
expect(sendButton()).not.toBeDisabled();
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('empty_text_title');
expect(onSend).not.toHaveBeenCalled();
fireEvent.click(within(dialog).getByText('send_anyway'));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(onSend.mock.calls[0][0]).toMatchObject({ subject: 'Lunch?' });
});
it('lists no subject and no text together', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={{ ...DRAFT_WITHOUT_SUBJECT, body: '' }} onSend={onSend} />);
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('warning_subject');
expect(dialog).toHaveTextContent('warning_text');
expect(onSend).not.toHaveBeenCalled();
});
it('goes back to editing without sending', async () => {
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITHOUT_SUBJECT} onSend={onSend} />);
@@ -329,9 +378,8 @@ describe('composer empty subject warning', () => {
);
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: '' } });
// Closing saves the draft; no question unless the save fails.
act(() => requestCloseRef.current!(vi.fn()));
const dialog = await screen.findByRole('alertdialog');
fireEvent.click(within(dialog).getByText('save'));
await waitFor(() => expect(createDraft).toHaveBeenCalled());
expect(createDraft.mock.calls[0][1]).toBe('');
@@ -235,6 +235,7 @@ describe('composer placeholder warning', () => {
placeholderWarningEnabled: true,
attachmentReminderEnabled: false,
attachmentReminderKeywords: [],
sendConfirmation: false,
} as never);
});
@@ -293,33 +294,36 @@ describe('composer placeholder warning', () => {
expect(onSend).not.toHaveBeenCalled();
});
it('does not ask again after Send anyway and a trip back to add an attachment', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
// Send anyway, then "Send this message?" answered with Cancel: the
// placeholders were confirmed, the message is still here.
const confirmThenCancel = async () => {
fireEvent.click(sendButton());
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
// The body says "See you", so the attachment reminder stops the send.
fireEvent.click(await screen.findByText('forgot_attachment.back'));
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
const confirm = await screen.findByRole('alertdialog');
expect(confirm).toHaveTextContent('send_confirm.title');
fireEvent.click(within(confirm).getByRole('button', { name: 'cancel' }));
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument());
};
it('does not ask again after Send anyway and a cancelled send', async () => {
useSettingsStore.setState({ sendConfirmation: true } as never);
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
await confirmThenCancel();
fireEvent.click(sendButton());
expect(await screen.findByText('forgot_attachment.title')).toBeInTheDocument();
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('send_confirm.title');
expect(screen.queryByText('placeholder_warning.title')).not.toBeInTheDocument();
expect(onSend).not.toHaveBeenCalled();
});
it('asks again when a new placeholder shows up', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
useSettingsStore.setState({ sendConfirmation: true } as never);
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
fireEvent.click(sendButton());
fireEvent.click(await screen.findByText('placeholder_warning.send_anyway'));
fireEvent.click(await screen.findByText('forgot_attachment.back'));
await waitFor(() => expect(screen.queryByText('forgot_attachment.title')).not.toBeInTheDocument());
await confirmThenCancel();
fireEvent.change(screen.getByDisplayValue('Lunch'), { target: { value: 'Lunch with {{name}}' } });
fireEvent.click(sendButton());
@@ -330,6 +334,26 @@ describe('composer placeholder warning', () => {
expect(onSend).not.toHaveBeenCalled();
});
// compose-27: one question for everything the checks found.
it('lists the placeholders and the missing attachment in one dialog', async () => {
useSettingsStore.setState({ attachmentReminderEnabled: true, attachmentReminderKeywords: ['see you'] } as never);
const onSend = vi.fn();
render(<EmailComposer initialData={DRAFT_WITH_PLACEHOLDER} onSend={onSend} />);
fireEvent.click(sendButton());
const dialog = await screen.findByRole('alertdialog');
expect(dialog).toHaveTextContent('warnings_title');
expect(dialog).toHaveTextContent('warning_placeholders');
expect(dialog).toHaveTextContent('warning_attachment');
expect(within(dialog).getByText('[TIME]')).toBeInTheDocument();
fireEvent.click(within(dialog).getByText('send_anyway'));
await waitFor(() => expect(onSend).toHaveBeenCalledTimes(1));
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
});
it('ignores placeholders in quoted text and in the signature', async () => {
const onSend = vi.fn();
const body = '<p>Thanks!</p>'
@@ -243,7 +243,7 @@ describe('recipient autocomplete', () => {
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByText('User B (userb@example.org)')).toBeInTheDocument();
expect(screen.getByTitle('User B <userb@example.org>')).toBeInTheDocument();
expect(screen.queryByText('user')).not.toBeInTheDocument();
});
@@ -255,7 +255,7 @@ describe('recipient autocomplete', () => {
fireEvent.keyDown(input, { key: 'Tab' });
expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument();
expect(screen.getByTitle('User C <userc@example.org>')).toBeInTheDocument();
});
it('keeps a complete typed address that no suggestion matches exactly', async () => {
@@ -268,7 +268,7 @@ describe('recipient autocomplete', () => {
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByText('userb@example.or')).toBeInTheDocument();
expect(screen.queryByText('User B (userb@example.org)')).not.toBeInTheDocument();
expect(screen.queryByTitle('User B <userb@example.org>')).not.toBeInTheDocument();
});
it('starts the arrow keys from the highlighted suggestion', async () => {
@@ -280,6 +280,6 @@ describe('recipient autocomplete', () => {
fireEvent.keyDown(input, { key: 'ArrowDown' });
fireEvent.keyDown(input, { key: 'Enter' });
expect(screen.getByText('User C (userc@example.org)')).toBeInTheDocument();
expect(screen.getByTitle('User C <userc@example.org>')).toBeInTheDocument();
});
});
@@ -249,8 +249,8 @@ describe('RecipientChipInput drag and drop', () => {
it('keeps a display name with a comma in a single chip (array model)', async () => {
render(<EmailComposer initialData={{ ...BASE_DATA, to: '"Doo, John" <john@doo.org>, ' }} />);
// One chip, displayed as "Doo, John (john@doo.org)" — not split on the comma.
const chip = await screen.findByText('Doo, John (john@doo.org)');
// One chip, "Doo, John" with the address in its tooltip — not split on the comma.
const chip = await screen.findByTitle('Doo, John <john@doo.org>');
const chipSpan = chip.closest('[draggable]') as HTMLElement;
expect(chipSpan).not.toBeNull();
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import { fireEvent } from '@testing-library/dom';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import React from 'react';
@@ -239,18 +239,31 @@ describe('RecipientChipInput paste', () => {
const input = toInput();
paste(input, '"Alice Smith <alice@x.com>", "Alex Smith <alex@x.com>"');
// Chips render as "Name (email)" when a display name is present.
expect(screen.getByText('Alice Smith (alice@x.com)')).toBeInTheDocument();
expect(screen.getByText('Alex Smith (alex@x.com)')).toBeInTheDocument();
// A named chip has its address in the tooltip.
expect(screen.getByTitle('Alice Smith <alice@x.com>')).toBeInTheDocument();
expect(screen.getByTitle('Alex Smith <alex@x.com>')).toBeInTheDocument();
expect(input.value).toBe('');
});
// "Maximilian Alexander von Hoh…": the chip showed "Name (address)" and
// cut both. It shows the name; the address is in the tooltip, and screen
// readers still hear it.
it('shows the name only on a named chip', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
paste(toInput(), 'Maximilian Alexander von Hohenberg <max.hohenberg@example.org>');
const label = screen.getByTitle('Maximilian Alexander von Hohenberg <max.hohenberg@example.org>');
expect(within(label).getByText('Maximilian Alexander von Hohenberg')).toHaveClass('truncate');
expect(within(label).queryByText(/max\.hohenberg@example\.org/, { ignore: '.sr-only' })).toBeNull();
expect(label.closest('[draggable]')?.textContent).toBe('Maximilian Alexander von Hohenberg (max.hohenberg@example.org)');
});
it('keeps a `Name <email>` pair as a single named chip', () => {
render(<EmailComposer initialData={EMPTY_DATA} />);
const input = toInput();
paste(input, 'John Doe <j@x.com>, jane@y.com');
expect(screen.getByText('John Doe (j@x.com)')).toBeInTheDocument();
expect(screen.getByTitle('John Doe <j@x.com>')).toBeInTheDocument();
expect(screen.getByText('jane@y.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
@@ -260,7 +273,7 @@ describe('RecipientChipInput paste', () => {
const input = toInput();
paste(input, '"Doe, John" <j@x.com>; bob@z.com');
expect(screen.getByText('Doe, John (j@x.com)')).toBeInTheDocument();
expect(screen.getByTitle('Doe, John <j@x.com>')).toBeInTheDocument();
expect(screen.getByText('bob@z.com')).toBeInTheDocument();
expect(input.value).toBe('');
});
@@ -295,7 +308,7 @@ describe('RecipientChipInput paste', () => {
const input = toInput();
paste(input, '"VIP" <vip@x.com>, not-an-email, b@y.com');
expect(screen.getByText('VIP (vip@x.com)')).toBeInTheDocument();
expect(screen.getByTitle('VIP <vip@x.com>')).toBeInTheDocument();
expect(screen.getByText('b@y.com')).toBeInTheDocument();
expect(input.value).toBe('not-an-email');
});
@@ -164,8 +164,11 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
const altDialog = () => screen.getByRole('dialog', { name: 'title' });
const html = (editor: Editor) => editor.getHTML();
it('asks for a description when an image is dropped in, and never uses the file name', async () => {
it('offers a description for a dropped image, and never uses the file name', async () => {
const { editor } = await dropImage();
// Nothing asks on the drop; the chip on the image does, when clicked.
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
fireEvent.click(await screen.findByTestId('image-alt-add'));
const dialog = await waitFor(() => altDialog());
expect(html(editor)).not.toContain('IMG_2041');
expect(html(editor)).not.toMatch(/alt=/);
@@ -178,6 +181,9 @@ describe('RichTextEditor image descriptions (MA-02)', () => {
it('marks an image decorative with an empty alt', async () => {
const { editor } = await dropImage();
// Nothing asks on the drop; the chip on the image does, when clicked.
expect(screen.queryByRole('dialog', { name: 'title' })).toBeNull();
fireEvent.click(await screen.findByTestId('image-alt-add'));
const dialog = await waitFor(() => altDialog());
fireEvent.click(within(dialog).getByLabelText('decorative'));
expect(within(dialog).getByLabelText('label')).toBeDisabled();
@@ -1,4 +1,4 @@
import { act, render, waitFor } from '@testing-library/react';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import React from 'react';
import type { Editor } from '@tiptap/core';
@@ -72,4 +72,33 @@ describe('RichTextEditor file drop', () => {
expect(editor.getText()).toBe('Hello');
expect(onDrop).not.toHaveBeenCalled();
});
// A pasted or dropped image asks nothing: an optional chip on it offers a description.
it('opens no dialog for a dropped image, and offers "Add description" on it', async () => {
const { editor } = await setup(async () => ({ src: 'data:image/png;base64,AAAA' }));
const file = new File(['png'], 'photo.png', { type: 'image/png' });
act(() => { editor.view.dom.dispatchEvent(fileDrop(file)); });
const chip = await screen.findByTestId('image-alt-add');
expect(chip).toHaveTextContent('add_description');
expect(screen.queryByRole('dialog')).toBeNull();
fireEvent.click(chip);
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
it('shows no chip on an image marked decorative', async () => {
let editor: Editor | null = null;
render(
<RichTextEditor
content={'<p>Hi <img src="data:image/png;base64,AAAA" alt=""></p>'}
onChange={() => {}}
onImageUpload={async () => null}
onEditorReady={(ed) => { editor = ed as unknown as Editor; }}
/>
);
await waitFor(() => expect(editor).not.toBeNull());
expect(screen.queryByTestId('image-alt-add')).toBeNull();
});
});
@@ -36,6 +36,17 @@ describe('Insert link', () => {
expect(editor.getHTML()).toContain('href="https://example.com/docs"');
});
it('opens on Ctrl+K in the text, as in Gmail, Outlook and Word', async () => {
const editor = await setup('<p>docs here</p>');
act(() => { editor.commands.setTextSelection({ from: 1, to: 5 }); });
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true, bubbles: true, cancelable: true });
act(() => { editor.view.dom.dispatchEvent(event); });
expect(event.defaultPrevented).toBe(true);
expect(screen.getByRole('dialog', { name: 'insert_link' })).toBeInTheDocument();
});
it('removes a link from the dialog', async () => {
const editor = await setup('<p><a href="https://example.com">docs</a> here</p>');
act(() => { editor.commands.setTextSelection(2); });
@@ -0,0 +1,114 @@
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import React from 'react';
import { RichTextEditor, formattingFoldCount } from '../rich-text-editor';
// The formatting toolbar keeps one row: in a narrow composer (585 px at
// 1280x720) it wrapped to two and took the body's room. What doesn't fit goes
// behind "More formatting", the least used first.
let toolbarWidth = 1200;
const observers = new Set<ResizeObserverCallback>();
class ReportingResizeObserver {
constructor(private cb: ResizeObserverCallback) {}
observe() { observers.add(this.cb); }
unobserve() {}
disconnect() { observers.delete(this.cb); }
}
vi.stubGlobal('ResizeObserver', ReportingResizeObserver);
const realClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')!;
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
get(this: HTMLElement) {
return this.getAttribute('role') === 'toolbar' ? toolbarWidth : realClientWidth.get!.call(this);
},
});
afterEach(() => {
toolbarWidth = 1200;
});
async function setup() {
let ready = false;
render(<RichTextEditor content="<p>Hello</p>" onChange={() => {}} onEditorReady={() => { ready = true; }} />);
await waitFor(() => expect(ready).toBe(true));
}
async function resize(width: number) {
toolbarWidth = width;
await act(async () => {
for (const cb of observers) cb([], {} as ResizeObserver);
});
}
const toolbar = () => screen.getByRole('toolbar', { name: 'formatting' });
const rowItem = (name: string) => toolbar().querySelector(`[data-toolbar-item="${name}"]`);
describe('formatting toolbar in a narrow composer', () => {
it('shows every button in one row when there is room, without a More button', async () => {
await setup();
for (const name of ['heading-1', 'quote', 'align-left', 'table', 'undo']) expect(rowItem(name)).not.toBeNull();
expect(rowItem('more-formatting')).toBeNull();
});
it('moves what does not fit behind "More formatting", and shows it there', async () => {
await setup();
await resize(561);
// The least used go first: undo, redo, clear formatting, then alignment.
for (const name of ['undo', 'redo', 'clear-formatting', 'align-left', 'align-right']) expect(rowItem(name)).toBeNull();
for (const name of ['bold', 'bullet-list', 'link', 'heading-1', 'table']) expect(rowItem(name)).not.toBeNull();
const more = rowItem('more-formatting') as HTMLButtonElement;
expect(more).toHaveAccessibleName('more_formatting');
expect(more).toHaveAttribute('aria-expanded', 'false');
fireEvent.click(more);
const panel = screen.getByRole('dialog', { name: 'more_formatting' });
expect(more).toHaveAttribute('aria-expanded', 'true');
for (const name of ['align-left', 'align-center', 'align-right', 'clear-formatting', 'undo', 'redo']) {
expect(within(panel).getByRole('button', { name: new RegExp(`^${name.replace('-', '_')}`) })).toBeInTheDocument();
}
});
it('keeps the arrows in the row: the panel buttons are no Tab stops of the toolbar', async () => {
await setup();
await resize(561);
fireEvent.click(rowItem('more-formatting')!);
const panel = screen.getByRole('dialog', { name: 'more_formatting' });
const rowStops = Array.from(toolbar().querySelectorAll<HTMLElement>('[data-toolbar-item]'))
.filter((el) => !panel.contains(el) && el.tabIndex === 0);
expect(rowStops).toHaveLength(1);
});
it('brings everything back into the row when the composer gets wider', async () => {
await setup();
await resize(400);
expect(rowItem('heading-1')).toBeNull();
await resize(1000);
expect(rowItem('heading-1')).not.toBeNull();
expect(rowItem('undo')).not.toBeNull();
expect(rowItem('more-formatting')).toBeNull();
});
});
describe('formattingFoldCount', () => {
const sizes = { button: 28, gap: 2, separator: 5 };
it('folds nothing for a toolbar that is not laid out', () => {
expect(formattingFoldCount(0, sizes)).toBe(0);
});
it('folds more groups the narrower the row', () => {
const counts = [900, 700, 561, 480, 400, 300].map((width) => formattingFoldCount(width, sizes));
expect(counts[0]).toBe(0);
for (let i = 1; i < counts.length; i++) expect(counts[i]).toBeGreaterThanOrEqual(counts[i - 1]);
expect(counts[counts.length - 1]).toBe(5);
});
it('makes room for the text direction button', () => {
const width = 700;
expect(formattingFoldCount(width, { ...sizes, directionButton: true })).toBeGreaterThanOrEqual(formattingFoldCount(width, sizes));
});
});
@@ -42,7 +42,7 @@ beforeEach(() => clearAllHooks());
async function typeAndSend(onQuickReply: (body: string) => Promise<boolean | void>) {
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
fireEvent.focus(box);
fireEvent.change(box, { target: { value: 'My answer' } });
fireEvent.click(screen.getByRole('button', { name: 'send' }));
@@ -77,7 +77,7 @@ describe('automatic sign-out while the external recipient question is open', ()
render(<EmailViewer email={message} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox"
onQuickReply={onQuickReply} onSaveQuickReplyDraft={onSaveQuickReplyDraft} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
const box = screen.getByPlaceholderText('quick_reply_placeholder') as HTMLTextAreaElement;
const box = screen.getByPlaceholderText('quick_reply_to') as HTMLTextAreaElement;
fireEvent.focus(box);
fireEvent.change(box, { target: { value: 'My answer' } });
fireEvent.click(screen.getByRole('button', { name: 'send' }));
@@ -0,0 +1,87 @@
import { render, act, fireEvent, screen } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { clearAllHooks } from '@/lib/plugin-hooks';
import { useAuthStore } from '@/stores/auth-store';
// The quick reply box counted characters, hid the full editor behind "More
// options" and didn't say who the reply goes to. It now names the
// recipients in its placeholder and offers "Open full editor".
vi.mock('next-intl', () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
values ? `${key}:${JSON.stringify(values)}` : key,
useLocale: () => 'en',
useFormatter: () => ({
dateTime: (d: Date | string) => String(d),
relativeTime: (d: Date | string) => String(d),
number: (n: number) => String(n),
}),
}));
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { EmailViewer } = await import('../email-viewer');
const mailboxes = [
{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } },
] as unknown as Mailbox[];
const message = {
id: 'm1', blobId: 'b1', threadId: 't1', mailboxIds: { inbox: true }, keywords: { $seen: true },
from: [{ name: 'Ben Carter', email: 'ben.carter@example.com' }], to: [{ name: 'Me', email: 'me@example.org' }],
subject: 'Hello', receivedAt: '2026-01-01T00:00:00Z', preview: 'Hello', hasAttachment: false, size: 100,
textBody: [{ partId: '1', type: 'text/plain' }], htmlBody: [{ partId: '1', type: 'text/plain' }], attachments: [],
bodyValues: { '1': { value: 'Plain body' } },
} as unknown as Email;
beforeEach(() => {
clearAllHooks();
useAuthStore.setState({ identities: [{ id: 'me', email: 'me@example.org', name: 'Me' }] } as never);
});
async function renderViewer(email: Email, onReply = vi.fn()) {
render(<EmailViewer email={email} isLoading={false} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} onReply={onReply} />);
await act(async () => { await new Promise((r) => setTimeout(r, 10)); });
}
describe('quick reply wording', () => {
it('says who the reply goes to', async () => {
await renderViewer(message);
expect(screen.getByPlaceholderText('quick_reply_to:{"names":"Ben Carter"}')).toBeTruthy();
});
it('names the Reply-To address over the sender, by address when it has no name', async () => {
await renderViewer({ ...message, replyTo: [{ email: 'team@example.com' }] } as unknown as Email);
expect(screen.getByPlaceholderText('quick_reply_to:{"names":"team@example.com"}')).toBeTruthy();
});
it('drops the character count and opens the full editor with the typed text', async () => {
const onReply = vi.fn();
await renderViewer(message, onReply);
const box = screen.getByPlaceholderText(/^quick_reply_to/);
fireEvent.focus(box);
fireEvent.change(box, { target: { value: 'Thanks, will do' } });
expect(screen.queryByText(/characters_count/)).toBeNull();
expect(screen.queryByText('more_options')).toBeNull();
const full = screen.getByTestId('quick-reply-full-editor');
expect(full).toHaveTextContent('open_full_editor');
fireEvent.click(full);
expect(onReply).toHaveBeenCalledWith('Thanks, will do');
});
});
@@ -92,9 +92,9 @@ describe('viewer send failures', () => {
useSettingsStore.setState({ quickReplyEnabled: false });
render(<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" />);
expect(screen.queryByPlaceholderText('quick_reply_placeholder')).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText('quick_reply_to')).not.toBeInTheDocument();
act(() => useSettingsStore.getState().updateSetting('quickReplyEnabled', true));
expect(screen.getByPlaceholderText('quick_reply_placeholder')).toBeInTheDocument();
expect(screen.getByPlaceholderText('quick_reply_to')).toBeInTheDocument();
});
it('reports a failed quick reply and keeps the text on Stalwart 1.0', async () => {
@@ -103,7 +103,7 @@ describe('viewer send failures', () => {
<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={onQuickReply} />,
);
const box = screen.getByPlaceholderText('quick_reply_placeholder');
const box = screen.getByPlaceholderText('quick_reply_to');
fireEvent.change(box, { target: { value: 'Thanks!' } });
fireEvent.keyDown(box, { key: 'Enter', ctrlKey: true });
@@ -122,7 +122,7 @@ describe('viewer send failures', () => {
await act(async () => { await flushComposers(1000); });
expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
fireEvent.change(screen.getByPlaceholderText('quick_reply_to'), { target: { value: 'On it.' } });
await act(async () => { await flushComposers(1000); });
expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
});
@@ -133,7 +133,7 @@ describe('viewer send failures', () => {
render(<EmailViewer email={makeEmail()} mailboxes={mailboxes} selectedMailbox="inbox" currentMailboxRole="inbox" onQuickReply={vi.fn()} />);
expect(useUIStore.getState().composingCount).toBe(0);
fireEvent.focus(screen.getByPlaceholderText('quick_reply_placeholder'));
fireEvent.focus(screen.getByPlaceholderText('quick_reply_to'));
expect(useUIStore.getState().composingCount).toBe(1);
fireEvent.click(screen.getByText('cancel'));
+11 -4
View File
@@ -67,21 +67,28 @@ export function ComposerToolbarMenu({ icon: Icon, label, activeCount = 0, testId
interface ItemProps {
icon?: AppIcon;
onSelect: () => void;
/** A line under the label, as on the check items. */
description?: string;
/** Greyed out: the item can't do its job here yet, and says where to set it up. */
muted?: boolean;
testId?: string;
children: React.ReactNode;
}
export function ComposerToolbarMenuItem({ icon: Icon, onSelect, testId, children }: ItemProps) {
export function ComposerToolbarMenuItem({ icon: Icon, onSelect, description, muted, testId, children }: ItemProps) {
return (
<button
type="button"
role="menuitem"
onClick={onSelect}
data-testid={testId}
className={CONTEXT_MENU_ITEM_CLASS}
className={cn(CONTEXT_MENU_ITEM_CLASS, description && "items-start", muted && "text-muted-foreground")}
>
{Icon ? <Icon className="w-4 h-4 shrink-0" /> : null}
<span className="flex-1">{children}</span>
{Icon ? <Icon className={cn("w-4 h-4 shrink-0", description && "mt-0.5")} /> : null}
<span className="flex-1">
<span className="block">{children}</span>
{description && <span className="block text-xs text-muted-foreground">{description}</span>}
</span>
</button>
);
}
File diff suppressed because it is too large Load Diff
+19 -10
View File
@@ -49,8 +49,8 @@ import { onPanelRequest } from "@/lib/plugin-sandbox/panel-requests";
import { PluginPanelToggle, panelLabelOf, panelShortcutOf } from "@/components/plugins/app-side-panel";
import type { ComposerMetadata } from "@/lib/plugin-sandbox/composer-registry";
import { QuickReplyPluginActions } from "./quick-reply-plugin-actions";
import { quickReplyEnvelope } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, generateUUID, cleanPreview, type MailboxNode } from "@/lib/utils";
import { quickReplyEnvelope, quickReplyRecipients } from "@/lib/outgoing/quick-reply-envelope";
import { formatFileSize, cn, formatDate, formatDateTime, formatRelativeAgo, cleanPreview } from "@/lib/utils";
import { getEffectiveTimeZone, getWallClock } from "@/lib/timezone";
import { itemsToHide, toolbarHideOrder } from "@/lib/toolbar-overflow";
import { formatScheduledSendAt } from "@/lib/scheduled-send-label";
@@ -302,6 +302,8 @@ interface EmailViewerProps {
onReplyAllConversationEmail?: (email: Email) => void;
/** Mountain View: a reply being written in place, shown under the conversation instead of the reply buttons. */
mvInlineComposer?: React.ReactNode;
/** A reply or forward to this message is open under it: no quick reply box as well. */
replyComposerOpen?: boolean;
onEditDraft?: () => void;
onCancelScheduledForEdit?: () => void;
/** Stop a scheduled message from going out; it goes back to Drafts. */
@@ -1373,6 +1375,7 @@ export function EmailViewer({
onReplyConversationEmail,
onReplyAllConversationEmail,
mvInlineComposer,
replyComposerOpen,
onEditDraft,
onCancelScheduledForEdit,
onCancelScheduledSend,
@@ -1396,6 +1399,7 @@ export function EmailViewer({
const archiveActionEnabled = useArchiveActionEnabled();
const tWindows = useTranslations('pwaWindows');
const tComposer = useTranslations('email_composer');
const tWords = useTranslations('compose_words');
const tNotifications = useTranslations('notifications');
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
@@ -1689,6 +1693,13 @@ export function EmailViewer({
if (isQuickReplyFocused) quickReplyBoxRef.current?.scrollIntoView?.({ block: 'nearest' });
}, [isQuickReplyFocused]);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
// "Reply to Ben Carter…": who the quick reply goes to, by name.
const quickReplyNames = email
? quickReplyRecipients(email, identities).map((r) => r.name || r.email).join(', ')
: '';
const quickReplyPlaceholder = quickReplyNames
? tWords('quick_reply_to', { names: quickReplyNames })
: t('quick_reply_placeholder');
/** The recipients and identity the quick reply would be sent with. */
const quickMetadata = (): ComposerMetadata => {
if (!email) return { subject: '', to: [], attachments: [] };
@@ -3503,7 +3514,7 @@ export function EmailViewer({
const latestSrcDocRef = useRef<string | null>(null);
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// Not on spam: a reply confirms the address to the sender.
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
const showQuickReply = !!email && quickReplyEnabled && !mvReplyRow && !replyComposerOpen && !isDraft && !isScheduled && !isBodyLoading && !isInJunkFolder
&& (!effectiveEmailContent.isHtml || iframeReady);
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
@@ -6204,7 +6215,7 @@ ${printWatermark ? printWatermark.html : ''}
void handleSendQuickReply();
}
}}
placeholder={t('quick_reply_placeholder')}
placeholder={quickReplyPlaceholder}
className={cn(
"w-full px-3 py-2 text-sm border border-border bg-background text-foreground rounded-lg",
"hover:border-accent focus:border-primary focus:outline-hidden transition-all",
@@ -6216,10 +6227,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Action buttons - show when focused or has text */}
{(isQuickReplyFocused || quickReplyText) && (
<div className="flex flex-wrap items-center justify-between gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="text-xs text-muted-foreground">
{t('characters_count', { count: quickReplyText.length })}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 animate-in fade-in slide-in-from-top-1 duration-200">
<div className="flex flex-wrap items-center gap-2 min-w-0">
<Button
variant="ghost"
@@ -6242,9 +6250,10 @@ ${printWatermark ? printWatermark.html : ''}
}}
disabled={isSendingQuickReply}
className="text-muted-foreground"
data-testid="quick-reply-full-editor"
>
<MoreVertical className="w-4 h-4 me-1" />
{t('more_options')}
<Maximize2 className="w-4 h-4 me-1" />
{tWords('open_full_editor')}
</Button>
<QuickReplyPluginActions
key={JSON.stringify([email.id, activeAccountId, email.sourceClientAccountId, email.sourceAccountId])}
+23 -2
View File
@@ -12,10 +12,14 @@ export interface ResizableImageOptions {
onEditAlt?: (pos: number) => void;
/** The name of the button that does it ("Image description"). */
editAltLabel?: string;
/** The chip on an image that has no description yet ("Add description"). */
addAltLabel?: string;
}
function ResizableImageView({ node, updateAttributes, selected, extension, getPos }: NodeViewProps) {
const { onEditAlt, editAltLabel } = extension.options as ResizableImageOptions;
const { onEditAlt, editAltLabel, addAltLabel } = extension.options as ResizableImageOptions;
// No description and not marked decorative (""): an optional chip asks for one.
const undescribed = node.attrs.alt === null || node.attrs.alt === undefined;
const imgRef = useRef<HTMLImageElement>(null);
const [resizing, setResizing] = useState(false);
// A remote image (a draft from another client, pasted web content) stays
@@ -93,6 +97,23 @@ function ResizableImageView({ node, updateAttributes, selected, extension, getPo
style={{ width: "100%", height: "auto", display: "block" }}
draggable={false}
/>
{undescribed && onEditAlt && addAltLabel && !selected && (
// Optional, never in the way: pasting or dropping an image asks nothing.
<button
type="button"
contentEditable={false}
data-testid="image-alt-add"
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
const pos = typeof getPos === "function" ? getPos() : undefined;
if (typeof pos === "number") onEditAlt(pos);
}}
className="absolute bottom-1 start-1 rounded-full bg-background/90 px-2 py-0.5 text-xs font-medium text-foreground shadow border border-border hover:bg-background"
>
{addAltLabel}
</button>
)}
{selected && onEditAlt && (
// Edit what the image says to screen readers. Reachable with Tab
// while the image is selected, and from the toolbar.
@@ -140,7 +161,7 @@ export const ResizableImage = Node.create<ResizableImageOptions>({
name: "image",
addOptions() {
return { onEditAlt: undefined, editAltLabel: undefined };
return { onEditAlt: undefined, editAltLabel: undefined, addAltLabel: undefined };
},
group: "inline",
inline: true,
+145 -8
View File
@@ -70,8 +70,56 @@ import {
Rows3,
Columns3,
ImageIcon,
Ellipsis,
} from "@/components/icons";
/**
* Groups of the formatting toolbar that move behind "More formatting" when one
* row is too narrow for all of them, the least used first. Each with its
* buttons and the separators that go with it.
*/
const FOLDABLE_GROUPS = [
{ id: "history", buttons: 3, separators: 2 },
{ id: "align", buttons: 3, separators: 1 },
{ id: "headings", buttons: 2, separators: 1 },
{ id: "table", buttons: 1, separators: 0 },
{ id: "blocks", buttons: 2, separators: 0 },
] as const;
type FoldableGroup = (typeof FOLDABLE_GROUPS)[number]["id"];
/**
* Always in the row: font size, bold, italic, underline, strikethrough, the
* two colours, the lists, link, an image's description and More (12), with
* their 3 separators.
*/
const FIXED_TOOLBAR = { buttons: 12, separators: 3 };
/**
* How many of FOLDABLE_GROUPS go behind "More formatting" for one row of
* `width` px. 0 for a toolbar that isn't laid out (hidden, or not measured).
*/
export function formattingFoldCount(width: number, sizes: { button: number; gap: number; separator: number; directionButton?: boolean }): number {
if (!(width > 0)) return 0;
const span = (buttons: number, separators: number) => buttons * (sizes.button + sizes.gap) + separators * (sizes.separator + sizes.gap);
const groups = FOLDABLE_GROUPS.map((group) => span(group.buttons + (group.id === "align" && sizes.directionButton ? 1 : 0), group.separators));
let needed = span(FIXED_TOOLBAR.buttons, FIXED_TOOLBAR.separators) + groups.reduce((sum, group) => sum + group, 0);
// All of it fits without the More button.
if (needed - (sizes.button + sizes.gap) <= width + 1) return 0;
for (let count = 1; count <= groups.length; count++) {
needed -= groups[count - 1];
if (needed <= width + 1) return count;
}
return groups.length;
}
/** The toolbar's inner width and the sizes of what is in it, in px. */
function measureFormattingToolbar(toolbar: HTMLElement, directionButton: boolean): number {
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
const style = getComputedStyle(toolbar);
const width = toolbar.clientWidth - (parseFloat(style.paddingLeft) || 0) - (parseFloat(style.paddingRight) || 0);
const button = toolbar.querySelector<HTMLElement>(":scope > [data-toolbar-item], :scope > div > [data-toolbar-item]")?.offsetWidth || 1.75 * rem;
return formattingFoldCount(width, { button, gap: 0.125 * rem, separator: 1 + 0.25 * rem, directionButton });
}
export interface InlineImageUpload {
src: string;
cid?: string;
@@ -440,6 +488,7 @@ export function RichTextEditor({
}: RichTextEditorProps) {
const rtlEditingSupport = useSettingsStore((st) => st.rtlEditingSupport);
const tComposer = useTranslations("email_composer");
const tUx = useTranslations("compose_ux");
const tQuote = useTranslations("email_viewer");
const onImageUploadRef = React.useRef(onImageUpload);
onImageUploadRef.current = onImageUpload;
@@ -464,6 +513,8 @@ export function RichTextEditor({
// Images waiting for a description, one dialog at a time: a new image is
// found by its src (it has no alt yet), an existing one by its position.
const [altQueue, setAltQueue] = useState<AltRequest[]>([]);
// Insert link, for the key handler set up with the editor.
const addLinkRef = useRef<(() => void) | null>(null);
const editor = useEditor({
extensions: [
@@ -490,6 +541,7 @@ export function RichTextEditor({
ResizableImage.configure({
onEditAlt: (pos: number) => setAltQueue((queue) => [...queue, { pos }]),
editAltLabel: tComposer("image_alt.edit"),
addAltLabel: tUx("add_description"),
}),
Placeholder.configure({
placeholder,
@@ -565,6 +617,12 @@ export function RichTextEditor({
event.preventDefault();
return true;
}
// Ctrl+K inserts a link, as in Gmail, Outlook and Word.
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === "k" && addLinkRef.current) {
event.preventDefault();
addLinkRef.current();
return true;
}
return false;
},
handleDrop: (view, event) => {
@@ -584,11 +642,11 @@ export function RichTextEditor({
if (result) {
const { state } = view;
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY });
// No alt yet (never the file name): the user is asked next.
// No alt yet (never the file name).
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
const tr = state.tr.insert(pos?.pos ?? state.selection.anchor, node);
view.dispatch(tr);
setAltQueue((queue) => [...queue, { src: result.src }]);
// No dialog: the image offers "Add description" itself.
}
});
}
@@ -607,7 +665,7 @@ export function RichTextEditor({
const node = state.schema.nodes.image.create({ src: result.src, alt: null, cid: result.cid });
const tr = state.tr.replaceSelectionWith(node);
view.dispatch(tr);
setAltQueue((queue) => [...queue, { src: result.src }]);
// No dialog: the image offers "Add description" itself.
}
});
}
@@ -694,6 +752,7 @@ export function RichTextEditor({
const { from, to } = editor.state.selection;
setLinkDialog({ href: previousUrl ?? "", from, to });
}, [editor]);
addLinkRef.current = addLink;
const applyLink = useCallback((url: string) => {
if (!editor || !linkDialog) return;
editor
@@ -716,6 +775,7 @@ export function RichTextEditor({
}, [editor, linkDialog]);
const tToolbar = useTranslations("email_composer.toolbar");
const tWindows = useTranslations("compose_windows");
const tCommon = useTranslations("common");
const [tableMenuOpen, setTableMenuOpen] = useState(false);
const tableWrapperRef = useRef<HTMLDivElement>(null);
@@ -734,11 +794,13 @@ export function RichTextEditor({
// focus last keeps it, the arrows, Home and End move inside.
const toolbarRef = useRef<HTMLDivElement>(null);
const rovingItemRef = useRef<string | null>(null);
// Buttons in the "More formatting" panel are the panel's, with its own keys.
const inRow = (el: HTMLElement) => !el.closest("[data-toolbar-more-panel]");
const toolbarItems = () =>
Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? [])
.filter((el) => !el.disabled);
.filter((el) => !el.disabled && inRow(el));
const syncToolbarTabStops = (focus?: HTMLButtonElement) => {
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []);
const all = Array.from(toolbarRef.current?.querySelectorAll<HTMLButtonElement>("[data-toolbar-item]") ?? []).filter(inRow);
const enabled = all.filter((el) => !el.disabled);
const current = focus ?? enabled.find((el) => el.dataset.toolbarItem === rovingItemRef.current) ?? enabled[0];
if (current) rovingItemRef.current = current.dataset.toolbarItem ?? null;
@@ -757,6 +819,36 @@ export function RichTextEditor({
return !!stop && document.activeElement === stop;
};
// One row: what doesn't fit goes behind "More formatting" (a wrapped
// second row took the body's room in a narrow composer).
const [foldCount, setFoldCount] = useState(0);
const [moreOpen, setMoreOpen] = useState(false);
const moreButtonRef = useRef<HTMLButtonElement>(null);
const moreWrapperRef = useRef<HTMLDivElement>(null);
const hasEditor = !!editor;
useLayoutEffect(() => {
const toolbar = toolbarRef.current;
if (!toolbar) return;
const measure = () => setFoldCount(measureFormattingToolbar(toolbar, !!rtlEditingSupport));
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(() => {
measure();
setMoreOpen(false);
});
observer.observe(toolbar);
return () => observer.disconnect();
}, [hasEditor, rtlEditingSupport]);
useEffect(() => {
if (!moreOpen) return;
const handler = (e: MouseEvent) => {
if (moreWrapperRef.current && !moreWrapperRef.current.contains(e.target as Node)) setMoreOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [moreOpen]);
useEffect(() => {
if (!fontSizeMenuOpen) return;
const handler = (e: MouseEvent) => {
@@ -816,6 +908,15 @@ export function RichTextEditor({
const altTarget = findAltTarget(editor, altRequest);
const imageSelected = editor.isActive("image");
// A group that doesn't fit renders in the "More formatting" panel instead, in row order.
const folded = new Set<FoldableGroup>(FOLDABLE_GROUPS.slice(0, foldCount).map((group) => group.id));
const overflow: React.ReactNode[] = [];
const place = (group: FoldableGroup, node: React.ReactNode) => {
if (!folded.has(group)) return node;
overflow.push(<React.Fragment key={group}>{node}</React.Fragment>);
return null;
};
const onToolbarKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
// Keys inside a popover are the popover's.
@@ -836,7 +937,7 @@ export function RichTextEditor({
};
const onToolbarFocus = (e: React.FocusEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item")) syncToolbarTabStops(target);
if (target instanceof HTMLButtonElement && target.hasAttribute("data-toolbar-item") && inRow(target)) syncToolbarTabStops(target);
};
const renderPalette = (current: string | null, apply: (color: string) => void, removeLabel: string, remove: () => void) => (
@@ -1101,6 +1202,7 @@ export function RichTextEditor({
<ToolbarSeparator />
{place("headings", <>
<ToolbarButton
item="heading-1"
pressed={editor.isActive("heading", { level: 1 })}
@@ -1119,6 +1221,7 @@ export function RichTextEditor({
</ToolbarButton>
<ToolbarSeparator />
</>)}
<ToolbarButton
item="bullet-list"
@@ -1136,6 +1239,7 @@ export function RichTextEditor({
>
<ListOrdered className="w-4 h-4" />
</ToolbarButton>
{place("blocks", <>
<ToolbarButton
item="quote"
pressed={editor.isActive("blockquote")}
@@ -1152,9 +1256,11 @@ export function RichTextEditor({
>
<Code className="w-4 h-4" />
</ToolbarButton>
</>)}
<ToolbarSeparator />
{place("align", <>
<ToolbarButton
item="align-left"
pressed={editor.isActive({ textAlign: "left" })}
@@ -1198,6 +1304,7 @@ export function RichTextEditor({
)}
<ToolbarSeparator />
</>)}
<ToolbarButton
item="link"
@@ -1222,7 +1329,7 @@ export function RichTextEditor({
</ToolbarButton>
)}
<div ref={tableWrapperRef} className="relative">
{place("table", <div ref={tableWrapperRef} className="relative">
<ToolbarButton
ref={tableButtonRef}
item="table"
@@ -1274,8 +1381,9 @@ export function RichTextEditor({
</ToolbarPopover>
)
)}
</div>
</div>)}
{place("history", <>
<ToolbarSeparator />
<ToolbarButton
@@ -1304,6 +1412,35 @@ export function RichTextEditor({
>
<Redo className="w-4 h-4" />
</ToolbarButton>
</>)}
{overflow.length > 0 && (
<div ref={moreWrapperRef} className="relative">
<ToolbarButton
ref={moreButtonRef}
item="more-formatting"
popup="dialog"
expanded={moreOpen}
onClick={() => setMoreOpen((v) => !v)}
title={tWindows("more_formatting")}
>
<Ellipsis className="w-4 h-4" />
</ToolbarButton>
{moreOpen && (
<ToolbarPopover
label={tWindows("more_formatting")}
role="dialog"
triggerRef={moreButtonRef}
onClose={() => setMoreOpen(false)}
className="p-1 flex w-max max-w-[16rem] flex-wrap items-center gap-0.5"
>
<div data-toolbar-more-panel="" className="contents">
{overflow}
</div>
</ToolbarPopover>
)}
</div>
)}
</div>
{/* Editor */}
+146
View File
@@ -0,0 +1,146 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
/** Something the composer asks about before a message goes out. */
export type SendWarning =
| { kind: "subject" }
| { kind: "text" }
| { kind: "placeholders"; matches: string[] }
| { kind: "attachment"; keyword: string }
| { kind: "proofreading"; count: number };
interface SendWarningsDialogProps {
/** What to ask about, in the order the composer checks it. Closed when null. */
warnings: SendWarning[] | null;
/** "Back to editing", Escape or a click outside. */
onBack: () => void;
/** "Send anyway". `dontAskAgain`: the box under a single warning that has a setting. */
onSendAnyway: (dontAskAgain: boolean) => void;
}
/** The warnings that can be switched off in Settings (subject and placeholders). */
const HAS_SETTING = new Set<SendWarning["kind"]>(["subject", "placeholders"]);
/**
* One question before sending, whatever it found: a single warning keeps its
* own title and text, several are listed in one dialog with one "Send anyway"
* (two dialogs in a row asked about each in turn).
*/
export function SendWarningsDialog({ warnings, onBack, onSendAnyway }: SendWarningsDialogProps) {
const t = useTranslations("email_composer");
const tSend = useTranslations("compose_send");
const tProofreading = useTranslations("proofreading");
const [dontAskAgain, setDontAskAgain] = useState(false);
const [shownFor, setShownFor] = useState<SendWarning[] | null>(null);
// A new question starts with the box unticked.
if (warnings !== shownFor) {
setShownFor(warnings);
setDontAskAgain(false);
}
const items = warnings ?? [];
const single = items.length === 1 ? items[0] : null;
const placeholderChips = (matches: string[]) => (
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t("placeholder_warning.found")}>
{matches.slice(0, 5).map((p) => (
<li key={p} className="max-w-full truncate rounded bg-muted px-2 py-0.5 font-mono text-xs text-foreground">{p}</li>
))}
{matches.length > 5 && (
<li className="text-xs text-muted-foreground">
{t("placeholder_warning.more", { count: matches.length - 5 })}
</li>
)}
</ul>
);
let title: string;
let description: string | undefined;
let backLabel = tSend("back");
let sendLabel = tSend("send_anyway");
let body: React.ReactNode = null;
if (single?.kind === "subject") {
title = t("empty_subject.title");
description = t("empty_subject.message");
backLabel = t("empty_subject.back");
sendLabel = t("empty_subject.send_anyway");
} else if (single?.kind === "text") {
title = tSend("empty_text_title");
description = tSend("empty_text_message");
} else if (single?.kind === "placeholders") {
title = t("placeholder_warning.title");
description = t("placeholder_warning.message");
backLabel = t("placeholder_warning.back");
sendLabel = t("placeholder_warning.send_anyway");
body = placeholderChips(single.matches);
} else if (single?.kind === "attachment") {
title = t("forgot_attachment.title");
description = t("forgot_attachment.message", { keyword: single.keyword });
backLabel = t("forgot_attachment.back");
sendLabel = t("forgot_attachment.send_anyway");
} else if (single?.kind === "proofreading") {
title = tProofreading("send_warning.title");
description = tProofreading("send_warning.body", { count: single.count });
backLabel = tProofreading("send_warning.review");
sendLabel = tProofreading("send_warning.send");
} else {
title = tSend("warnings_title");
body = (
<ul className="mt-3 space-y-2 text-sm text-foreground" data-testid="send-warnings-list">
{items.map((w) => (
<li key={w.kind} className="flex gap-2">
<span aria-hidden="true" className="text-muted-foreground">•</span>
<div className="min-w-0">
{w.kind === "subject" && tSend("warning_subject")}
{w.kind === "text" && tSend("warning_text")}
{w.kind === "attachment" && tSend("warning_attachment", { keyword: w.keyword })}
{w.kind === "proofreading" && tSend("warning_proofreading", { count: w.count })}
{w.kind === "placeholders" && (
<>
{tSend("warning_placeholders")}
{placeholderChips(w.matches)}
</>
)}
</div>
</li>
))}
</ul>
);
}
return (
<Dialog
isOpen={items.length > 0}
onClose={onBack}
role="alertdialog"
showClose={false}
title={title}
description={description}
testId="send-warnings"
footer={
<>
<Button variant="outline" onClick={onBack}>{backLabel}</Button>
<Button data-testid={single?.kind === "proofreading" ? "proofreading-send-anyway" : "send-warnings-send"} onClick={() => onSendAnyway(dontAskAgain)}>{sendLabel}</Button>
</>
}
>
{body}
{single && HAS_SETTING.has(single.kind) && (
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={dontAskAgain}
onChange={(e) => setDontAskAgain(e.target.checked)}
className="rounded border-input"
/>
{single.kind === "subject" ? t("empty_subject.dont_ask_again") : t("placeholder_warning.dont_ask_again")}
</label>
)}
</Dialog>
);
}
@@ -44,6 +44,16 @@ function renderPicker(list: () => Promise<FileNode[]> = async () => TREE) {
}
describe('FilePickerDialog', () => {
// The X in the header was labelled "Cancel" like the footer button.
it('names the header X "Close" and keeps one Cancel button, both closing', async () => {
const { onClose } = renderPicker();
await screen.findByText('invoice.pdf');
expect(screen.getAllByRole('button', { name: 'cancel' })).toHaveLength(1);
fireEvent.click(screen.getByRole('button', { name: 'close' }));
fireEvent.click(screen.getByRole('button', { name: 'cancel' }));
expect(onClose).toHaveBeenCalledTimes(2);
});
it('lists the root without hidden or shared nodes, and offers Shared with me', async () => {
renderPicker();
expect(await screen.findByText('invoice.pdf')).toBeInTheDocument();
+2 -1
View File
@@ -45,6 +45,7 @@ function byFolderThenName(a: FileNode, b: FileNode): number {
export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogProps) {
const t = useTranslations("files");
const tComposer = useTranslations("email_composer");
const tCommon = useTranslations("common");
const [nodes, setNodes] = useState<FileNode[] | null>(null);
const [failed, setFailed] = useState(false);
const [attempt, setAttempt] = useState(0);
@@ -245,7 +246,7 @@ export function FilePickerDialog({ client, onClose, onPick }: FilePickerDialogPr
<button
type="button"
onClick={onClose}
aria-label={t("cancel")}
aria-label={tCommon("close")}
className="p-1.5 rounded-md hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
>
<X className="w-4 h-4" />
+28 -13
View File
@@ -22,6 +22,11 @@ interface SubAddressHelperProps {
recipientEmails: string[];
onSelectTag: (tag: string) => void;
disabled?: boolean;
/** Opened from elsewhere (the composer's From list) instead of its own button. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** No button of its own: the host opens it. */
hideTrigger?: boolean;
}
export function SubAddressHelper({
@@ -29,9 +34,17 @@ export function SubAddressHelper({
recipientEmails,
onSelectTag,
disabled = false,
open,
onOpenChange,
hideTrigger = false,
}: SubAddressHelperProps) {
const t = useTranslations('identities.sub_address');
const [isOpen, setIsOpen] = useState(false);
const [ownOpen, setOwnOpen] = useState(false);
const isOpen = open ?? ownOpen;
const setIsOpen = (next: boolean) => {
if (open === undefined) setOwnOpen(next);
onOpenChange?.(next);
};
const [tag, setTag] = useState('');
const [error, setError] = useState<string | null>(null);
const popoverRef = useRef<HTMLDivElement>(null);
@@ -128,18 +141,20 @@ export function SubAddressHelper({
return (
<div className="relative">
{/* Trigger Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 me-1" />
<Tag className="w-4 h-4" />
</Button>
{!hideTrigger && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setIsOpen(!isOpen)}
disabled={disabled}
title={t('button_tooltip')}
className="h-8 px-2"
>
<Plus className="w-4 h-4 me-1" />
<Tag className="w-4 h-4" />
</Button>
)}
{/* Popover */}
{isOpen && (
@@ -0,0 +1,178 @@
import React from 'react';
import { act, 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';
// The "Continue draft" bar and clicking another message while writing: the
// bar's X is its own button and deletes the saved draft, with Undo; another
// message closes the composer the way its X does (it keeps the draft).
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
}));
// The composer hands its draft to the host as it unmounts, and offers the
// host its close when the test says so.
vi.mock('@/components/email/email-composer', async () => {
const { useEffect } = await import('react');
return {
EmailComposer: ({ onSaveState, requestCloseRef }: {
onSaveState: (data: Record<string, unknown>) => void;
requestCloseRef?: { current: unknown };
}) => {
useEffect(() => {
if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose;
return () => {
if (requestCloseRef) requestCloseRef.current = null;
onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div>stub composer</div>;
},
};
});
vi.mock('@/components/search/mv-search-form', () => ({
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
}));
vi.mock('@/components/email/email-viewer', () => ({ EmailViewer: () => <div>message viewer</div> }));
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: { id: string }) => void }) => (
<button onClick={() => void onEmailSelect({ id: 'other-mail' })}>stub other mail</button>
),
}));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox', totalEmails: 0, unreadEmails: 0 } as Mailbox;
let deleteEmail: ReturnType<typeof vi.fn>;
beforeEach(() => {
device.current = { isMobile: false, isTablet: false, isDesktop: true };
stub.requestClose = 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) });
deleteEmail = vi.fn().mockResolvedValue(undefined);
const client = {
getAccountId: () => 'acc', getUsername: () => 'a@example.org',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
deleteEmail,
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: [], selectedEmail: null, 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.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
useToastStore.getState().clearToasts();
});
async function parkDraft() {
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByText('stub composer');
// No close offered: the composer unmounts and parks its draft.
fireEvent.click(screen.getByText('stub other mail'));
await screen.findByText('email_composer.continue_draft');
}
const discardButton = () => screen.getByRole('button', { name: 'discard_draft' });
describe('the Continue draft bar', () => {
it('has a Discard button of its own, outside the bar button', async () => {
await parkDraft();
expect(discardButton().parentElement?.closest('button')).toBeNull();
});
it('deletes the saved draft once the Undo window is over', async () => {
await parkDraft();
vi.useFakeTimers({ shouldAdvanceTime: true });
fireEvent.click(discardButton());
expect(screen.queryByText('email_composer.continue_draft')).toBeNull();
const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded');
expect(undoToast?.action?.label).toBe('undo');
expect(deleteEmail).not.toHaveBeenCalled();
await act(async () => { await vi.advanceTimersByTimeAsync(undoToast!.duration! + 10); });
expect(deleteEmail).toHaveBeenCalledWith('draft-7', 'acc');
});
it('brings the bar back on Undo and keeps the draft', async () => {
await parkDraft();
vi.useFakeTimers({ shouldAdvanceTime: true });
fireEvent.click(discardButton());
const undoToast = useToastStore.getState().toasts.find((t) => t.title === 'draft_discarded')!;
act(() => undoToast.action!.onClick());
expect(await screen.findByText('email_composer.continue_draft')).toBeInTheDocument();
await act(async () => { await vi.advanceTimersByTimeAsync(60_000); });
expect(deleteEmail).not.toHaveBeenCalled();
});
});
describe('another message clicked while writing', () => {
it('closes the composer through its own close, which keeps the draft', async () => {
const requestClose = vi.fn();
stub.requestClose = requestClose;
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByText('stub composer');
fireEvent.click(screen.getByText('stub other mail'));
await waitFor(() => expect(requestClose).toHaveBeenCalledTimes(1));
// Still there until it has saved and closes itself; no bar.
expect(screen.getByText('stub composer')).toBeInTheDocument();
expect(screen.queryByText('email_composer.continue_draft')).toBeNull();
});
});
@@ -0,0 +1,159 @@
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';
// Reply in the standard layout on a computer: the composer docks under the
// message it answers, which stays on screen above it, as in Gmail and Outlook.
const device = vi.hoisted(() => ({ current: { isMobile: false, isTablet: false, isDesktop: true } }));
const stub = vi.hoisted(() => ({ requestClose: null as null | ((afterClose?: () => void) => void) }));
vi.mock('@/components/layout/mv-top-bar', () => ({ MvTopBar: () => null }));
vi.mock('@/components/layout/sidebar', () => ({
Sidebar: ({ onCompose }: { onCompose: () => void }) => <button onClick={onCompose}>stub compose</button>,
}));
// The composer hands its draft to the host as it unmounts, and offers the
// host its close when the test says so.
vi.mock('@/components/email/email-composer', async () => {
const { useEffect } = await import('react');
return {
EmailComposer: ({ onSaveState, requestCloseRef, replyTo }: {
onSaveState: (data: Record<string, unknown>) => void;
requestCloseRef?: { current: unknown };
replyTo?: { quoteHeaderHtml?: string };
}) => {
useEffect(() => {
if (requestCloseRef && stub.requestClose) requestCloseRef.current = stub.requestClose;
return () => {
if (requestCloseRef) requestCloseRef.current = null;
onSaveState({ subject: 'Parked', draftId: 'draft-7', draftAccountId: 'acc', mode: 'compose' });
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return <div data-testid="stub-composer" data-quote={replyTo?.quoteHeaderHtml ?? ''}>stub composer</div>;
},
};
});
vi.mock('@/components/search/mv-search-form', () => ({
MvSearchForm: ({ onSearch }: { onSearch: () => void }) => <button onClick={onSearch}>stub filter search</button>,
}));
vi.mock('@/components/email/email-viewer', () => ({
EmailViewer: ({ onReply, onForward, replyComposerOpen }: { onReply: () => void; onForward: () => void; replyComposerOpen?: boolean }) => (
<div data-testid="viewer" data-reply-open={replyComposerOpen ? 'yes' : 'no'}>
message viewer
<button onClick={() => onReply()}>stub reply</button>
<button onClick={() => onForward()}>stub forward</button>
</div>
),
}));
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: { id: string }) => void }) => (
<button onClick={() => void onEmailSelect({ id: 'other-mail' })}>stub other mail</button>
),
}));
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.requestClose = 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.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
useToastStore.getState().clearToasts();
});
describe('a reply in the standard layout', () => {
for (const action of ['stub reply', 'stub forward']) {
it(`docks under the message, which stays on screen (${action})`, async () => {
render(<MailApp />);
fireEvent.click(await screen.findByText(action));
const composer = await screen.findByText('stub composer');
expect(screen.getByTestId('viewer')).toBeInTheDocument();
expect(composer.closest('[data-reply-dock]')).not.toBeNull();
// No quick reply box under the message as well.
expect(screen.getByTestId('viewer')).toHaveAttribute('data-reply-open', 'yes');
});
}
// compose-30: a forward says who the message went to.
it('gives a forward a header with To', async () => {
render(<MailApp />);
fireEvent.click(await screen.findByText('stub forward'));
await waitFor(() => expect(screen.getByTestId('stub-composer').getAttribute('data-quote')).toContain('forward_to_label'));
});
it('takes the whole pane for a new message', async () => {
render(<MailApp />);
fireEvent.click(screen.getByText('stub compose'));
await screen.findByText('stub composer');
expect(screen.queryByTestId('viewer')).toBeNull();
});
});
@@ -0,0 +1,156 @@
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 { 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';
// compose-24: after Send the composer stayed on screen, greyed and without a
// word, until the server answered. It closes at once now, "Sending…" and then
// "Sent · View" say what happens, and a send that fails brings the text back.
vi.mock('next/dynamic', () => ({ default: (load: () => Promise<React.ComponentType<Record<string, unknown>>>) => {
const Component = lazy(async () => ({ default: await load() }));
return function Dynamic(props: Record<string, unknown>) {
return <Suspense fallback={null}><Component {...props} /></Suspense>;
};
} }));
vi.mock('@/components/email/email-composer', () => ({
EmailComposer: ({ onSend, onSendFailed, initialData }: {
onSend?: (data: unknown) => Promise<void>;
onSendFailed?: (state: unknown) => void;
initialData?: { subject?: string } | null;
}) => (
<div>
<span>stub subject: {initialData?.subject ?? ''}</span>
<button onClick={() => {
// As the composer does: what it handed over comes back on a failure.
onSend?.({ to: ['bob@example.com'], cc: [], bcc: [], subject: 'Hi', body: 'Hello' })
?.catch(() => onSendFailed?.({ to: 'bob@example.com', cc: '', bcc: '', subject: 'Hi', body: 'Hello', mode: 'compose' }));
}}>
stub send
</button>
</div>
),
}));
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: ({ onOpenSearch }: { onOpenSearch?: () => void }) => <button onClick={onOpenSearch}>stub open search</button>,
}));
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: true, isTablet: false, isDesktop: false }),
useMediaQuery: () => false,
useIsDesktop: () => false,
}));
vi.mock('@/lib/push-bindings', () => ({ reconcilePushBindings: () => {}, releasePushBindings: () => {} }));
vi.mock('@/components/email/email-list', () => ({ EmailList: () => <div>message list</div> }));
const { MailApp } = await import('../mail-app');
const inbox = { id: 'inbox', name: 'Inbox', role: 'inbox' } as Mailbox;
const sendEmail = vi.fn();
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) });
const client = {
getAccountId: () => 'acc', getUsername: () => 'me@example.com',
getSieveAccountId: () => null, hasDelayedSend: () => false, getSession: () => null,
};
useAuthStore.setState({
isAuthenticated: true, isLoading: false, client: client as never, activeAccountId: 'login', identities: [],
getClientForAccount: () => client as never,
getAllConnectedClients: () => new Map([['login', client]]) as never,
});
useSettingsStore.setState({ enableUnifiedMailbox: false, mailLayout: 'split', sendDelaySeconds: 0 } as never);
sendEmail.mockReset().mockResolvedValue({});
useToastStore.setState({ toasts: [] });
useEmailStore.setState({
emails: [], selectedEmail: null, selectedMailbox: inbox.id, mailboxes: [inbox],
viewingAccountId: null, accountMailboxes: {}, isUnifiedView: false, unifiedRole: null,
isScheduledView: false, isLoadingEmail: false, isLoading: false, searchQuery: '',
searchFilters: { ...DEFAULT_SEARCH_FILTERS }, searchMailboxId: '', selectedKeyword: null,
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),
sendEmail,
} as never);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
const toastTitled = (title: string) => useToastStore.getState().toasts.find((t) => t.title === title);
async function openComposer() {
render(<MailApp />);
fireEvent.click(screen.getByRole('button', { name: 'sidebar.compose' }));
await screen.findByText('stub send', {}, { timeout: 5000 });
}
describe('sending', () => {
it('closes the composer at once and says "Sending…", then "Sent" with View', async () => {
let finish: (result: unknown) => void = () => {};
sendEmail.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
await openComposer();
fireEvent.click(screen.getByText('stub send'));
// The server hasn't answered yet.
await waitFor(() => expect(screen.queryByText('stub send')).not.toBeInTheDocument());
expect(sendEmail).toHaveBeenCalled();
expect(toastTitled('compose_send.sending')).toBeDefined();
await act(async () => { finish({ scheduled: false, emailId: 'sent-1' }); });
await waitFor(() => expect(toastTitled('compose_send.sent')).toBeDefined());
expect(toastTitled('compose_send.sent')!.action?.label).toBe('compose_send.view');
expect(toastTitled('compose_send.sending')).toBeUndefined();
});
it('opens the composer again with the text when the send fails', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
sendEmail.mockRejectedValue(new Error('refused'));
await openComposer();
fireEvent.click(screen.getByText('stub send'));
expect(await screen.findByText('stub subject: Hi', {}, { timeout: 5000 })).toBeInTheDocument();
expect(toastTitled('compose_send.sending')).toBeUndefined();
expect(toastTitled('compose_send.sent')).toBeUndefined();
});
it('leaves a delayed send to the undo toast', async () => {
sendEmail.mockResolvedValue({ scheduled: true, emailSubmissionId: 'sub-1', sendAt: new Date(Date.now() + 5000).toISOString() });
await openComposer();
fireEvent.click(screen.getByText('stub send'));
await waitFor(() => expect(sendEmail).toHaveBeenCalled());
await waitFor(() => expect(toastTitled('compose_send.sending')).toBeUndefined());
expect(toastTitled('compose_send.sent')).toBeUndefined();
expect(screen.queryByText('stub send')).not.toBeInTheDocument();
});
});
@@ -119,6 +119,18 @@ describe('undo send toast', () => {
expect(toast.pauseOnHover).toBe(false);
});
// 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 () => {
render(<MailApp />);
sendDelayedBy(3 * 86_400_000);
await waitFor(() => expect(useToastStore.getState().toasts.some((t) => t.title === 'compose_send.scheduled_for')).toBe(true));
const toast = useToastStore.getState().toasts.find((t) => t.title === 'compose_send.scheduled_for')!;
expect(toast.action?.label).toBe('cancel');
expect(toast.secondaryAction?.label).toBe('email_viewer.send_now');
});
it('says so when the undo comes too late', async () => {
cancelUndoSend.mockRejectedValue(new Error('The requested message is no longer in the queue.'));
vi.spyOn(console, 'error').mockImplementation(() => {});
+103 -46
View File
@@ -42,8 +42,10 @@ import { findDraftIdentityIdForLogin, listRowSource, resolveDraftSource, type Dr
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 { 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 { formatRejectedRecipients, RescheduleRestoredError } from "@/lib/jmap/client";
import { subscribePushStateChanges } from "@/lib/push-events";
import { createMailPushListener } from "@/lib/mail-push-listener";
@@ -269,6 +271,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// "Confirm before deleting / archiving" (lib/mail-removal-confirm).
const tRemove = useTranslations('email_list.confirm_remove');
const tCommon = useTranslations('common');
const tComposeUx = useTranslations('compose_ux');
const tComposeWords = useTranslations('compose_words');
const appName = productName(useConfig().appName);
const tQuote = useTranslations('quote_header');
const tComposer = useTranslations('email_composer');
@@ -299,6 +303,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const composerSessionRef = useRef(composerSessionId);
composerSessionRef.current = composerSessionId;
const composerHostRef = useRef('pane');
// The compose session docked under its message (dockedReply).
const dockedSessionRef = useRef<number | null>(null);
const showComposerRef = useRef(showComposer);
showComposerRef.current = showComposer;
// Fields a plugin filled in through onBeforeCompose for the composer about
@@ -2605,6 +2611,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// the user's message without so much as a toast (#702).
let submitted = false;
// The composer closes at once and "Sending…" shows meanwhile. A send that
// fails comes back through onSendFailed, which opens the composer again.
setShowComposer(false);
setPendingDraft(null);
if (isMobile && !useEmailStore.getState().selectedEmail) setActiveView('list');
const progress = startSendProgress({
sending: t('compose_send.sending'),
sent: t('compose_send.sent'),
view: t('compose_send.view'),
});
try {
const effectiveMode = pendingDraft?.mode ?? composerMode;
const originalEmailId = answeredEmailId;
@@ -2612,7 +2629,17 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const result = await sendEmail(sendClient, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, composerSendOptions(data));
submitted = true;
announceSent(result, data);
setShowComposer(false);
// "Sent" with View. A delayed or scheduled send gets the undo toast, and
// a filing error, a refused recipient or the offline outbox say so themselves.
if (result.scheduled || result.filingError || result.rejectedRecipients?.length || result.queued) {
progress.dismiss();
} else {
const sentId = result.emailId;
const ownerAccountId = result.emailAccountId && result.emailAccountId !== sendClient.getAccountId() ? result.emailAccountId : undefined;
progress.sent(sentId && sendClient === client
? () => void applyMailDeepLinkRef.current({ kind: 'message', id: sentId, ownerAccountId })
: undefined);
}
// Sending an edited draft destroys it server-side - and every autosave
// before that already replaced it under a fresh id (createDraft is a
// destroy+create). If the email on display is the draft this session
@@ -2697,11 +2724,6 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
return;
}
// Delayed sends get the undo toast instead; a filing error or a refused
// recipient already warned.
// A message kept in the offline outbox said so itself (#523).
if (!result.filingError && !result.rejectedRecipients?.length && !result.queued) toast.success(t('notifications.email_sent'));
// Refresh the current mailbox to update the UI
if (!isScheduledView) {
await refreshCurrentMailbox(client);
@@ -2709,6 +2731,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
} catch (error) {
console.error("Failed to send email:", error);
if (submitted) progress.sent();
else progress.dismiss();
// Post-send bookkeeping - refreshing Sent, flagging the original as
// answered, re-reading the thread - is cosmetic once the mail is out.
if (!submitted) throw error;
@@ -2779,6 +2803,9 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
fromLabel: tQuote('from_label'),
dateLabel: tQuote('date_label'),
subjectLabel: tQuote('subject_label'),
// A forward says who the message went to, as Gmail and Outlook do.
toLabel: tComposeWords('forward_to_label'),
ccLabel: tComposeWords('forward_cc_label'),
},
});
setComposerQuoteHeader(header);
@@ -2786,7 +2813,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
console.warn('[quote-header] plugin transform failed; using default', err);
setComposerQuoteHeader(null);
}
}, [tCommon, tQuote]);
}, [tCommon, tQuote, tComposeWords]);
// Force a clean composer remount on every fresh entry point so prior
// compose state can't bleed into the new session (#329 C). The composer is
@@ -2999,9 +3026,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
? Math.max(remainingMs - 1000, 1000)
: Math.max(sendDelaySeconds, 8) * 1000;
// A message scheduled for later says when it goes, and offers Cancel
// where a send-delay window offers Undo send.
toast.success(isUndoWindow
? t('email_viewer.sending_in', { seconds: Math.round(remainingMs / 1000) })
: t('email_viewer.scheduled_send_created'), {
: t('compose_send.scheduled_for', { when: formatScheduledSendAt(pending.sendAt, useSettingsStore.getState().timeFormat) }), {
duration: undoDurationMs,
pauseOnHover: false,
secondaryAction: (pending.emailId && pending.identityId)
@@ -3043,7 +3072,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}
: undefined,
action: {
label: t('email_viewer.undo_send'),
label: isUndoWindow ? t('email_viewer.undo_send') : tCommon('cancel'),
onClick: () => {
if (!undoClient) return refuseSignedOut();
void (async () => {
@@ -4535,7 +4564,8 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
let delayedUntil: string | undefined;
if (sendDelaySeconds > 0) {
if (!client.hasDelayedSend()) {
const confirmed = await confirmDialog({
// As in the composer: only a delay the user picked asks.
const confirmed = !useSettingsStore.getState().explicitSettings.includes('sendDelaySeconds') || await confirmDialog({
title: t('email_composer.send_delay_unsupported_title'),
message: t('email_composer.send_delay_unsupported_confirm'),
confirmText: t('email_composer.send'),
@@ -4886,7 +4916,16 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
const mvInlineReply = mvShell && showComposer && !isEmbedded && !mvReplyPoppedOut
&& (pendingDraft?.mode ?? composerMode) !== 'compose' && Boolean(selectedEmail);
const floatingComposer = mvShell && showComposer && !isEmbedded && !mvInlineReply;
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : 'pane';
// Elsewhere on a computer a reply or forward docks under the message it
// answers, which stays readable above it, as in Gmail and Outlook. Not
// for a draft being edited: that message is the draft itself.
// 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;
const composerHost = floatingComposer ? 'window' : mvInlineReply ? 'inline' : dockedReply ? 'dock' : 'pane';
composerHostRef.current = composerHost;
const hasViewerContent = (showComposer && !floatingComposer) || Boolean(conversationThread)
|| Boolean(selectedEmail && !(mvShell && cursorOnly)) || deepLinkPane !== null;
@@ -4915,11 +4954,13 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
if (!client || !email) return new Error('the mail page is not ready');
setCursorOnly(false);
// If composing, suspend the composer (unmount will trigger onSaveState).
// Mountain View's compose window stays open while other mail is read,
// as Gmail's does. (#1229)
// 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
// other mail is read, as Gmail's does. (#1229)
if (showComposer && !floatingComposer) {
setShowComposer(false);
const requestClose = composerRequestCloseRef.current;
if (requestClose) requestClose();
else setShowComposer(false);
}
// Find the list-level email for metadata (accountId, scheduled flags, etc.).
@@ -5267,6 +5308,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setComposerPrefill(null);
return outcome;
}}
onSendFailed={(state) => {
setPendingDraft(state);
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
onScheduledSendCreated={async () => {
if (client) {
await refreshScheduledMetadata(client);
@@ -5959,40 +6005,45 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
{/* Inline Composer - shown in viewer pane.
In Pro/embedded mode the composer is hoisted into its own
Pro tab (see the effect below), so we never render it inline. */}
{(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply) ? composerNode : (
{(showComposer && !isEmbedded && !floatingComposer && !mvInlineReply && !dockedReply) ? composerNode : (
<>
{/* Pending draft banner */}
{pendingDraft && (
<button
onClick={() => {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
className="flex items-center gap-3 px-4 py-2.5 bg-primary/10 border-b border-primary/20 hover:bg-primary/15 transition-colors cursor-pointer w-full text-start"
>
<PenSquare className="w-4 h-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
{pendingDraft.subject && (
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
)}
</div>
<X
className="w-4 h-4 text-muted-foreground hover:text-foreground shrink-0"
onClick={async (e) => {
e.stopPropagation();
const confirmed = await confirmDialog({
title: t('email_composer.discard_draft_title'),
message: t('email_composer.discard_draft_confirm'),
confirmText: t('email_composer.discard'),
variant: "destructive",
});
if (confirmed) {
setPendingDraft(null);
}
{pendingDraft && !dockedReply && (
<div className="flex items-center bg-primary/10 border-b border-primary/20" data-testid="continue-draft-bar">
<button
onClick={() => {
setShowComposer(true);
if (isMobile) setActiveView('viewer');
}}
/>
</button>
className="flex flex-1 min-w-0 items-center gap-3 ps-4 pe-2 py-2.5 hover:bg-primary/15 transition-colors cursor-pointer text-start"
>
<PenSquare className="w-4 h-4 text-primary shrink-0" />
<div className="flex-1 min-w-0">
<span className="text-sm font-medium text-primary">{t('email_composer.continue_draft')}</span>
{pendingDraft.subject && (
<span className="text-xs text-muted-foreground ms-2 truncate">{pendingDraft.subject}</span>
)}
</div>
</button>
{/* Its own button, so the keyboard reaches it. Deletes the saved
draft too, with Undo instead of a question. */}
<button
type="button"
aria-label={tComposeUx('discard_draft')}
title={tComposeUx('discard_draft')}
data-testid="continue-draft-discard"
onClick={() => {
const draft = pendingDraft;
setPendingDraft(null);
discardWithUndo(() => {
if (draft.draftId) void handleDiscardDraft(draft.draftId, { accountId: draft.draftAccountId, localAccountId: draft.draftLocalAccountId });
}, { discarded: tComposeUx('draft_discarded'), undo: tComposeUx('undo') }, () => setPendingDraft(draft));
}}
className="grid h-9 w-9 shrink-0 place-items-center me-2 rounded-md text-muted-foreground hover:text-foreground hover:bg-primary/15 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Mobile Conversation View - shown when thread is selected on mobile */}
{isMobile && !conversationThread && deepLinkPane === 'thread' ? (
@@ -6132,6 +6183,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
await handleConversationReplyAll(full ?? e);
} : undefined}
mvInlineComposer={mvInlineReply ? <MvComposeInline onPopOut={handleMvPopOut}>{composerNode}</MvComposeInline> : undefined}
replyComposerOpen={dockedReply}
onToggleFullscreen={!isEmbedded && !isMobile ? () => setFullscreenReading((v) => !v) : undefined}
onOpenInWindow={!isEmbedded && !isMobile && openEmailInWindow && selectedEmail ? () => openEmailInWindow(selectedEmail) : undefined}
isFullscreen={fullscreenReading}
@@ -6185,6 +6237,11 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
</ErrorBoundary>
</>
)}
{dockedReply && (
<div className="flex basis-[62%] min-h-[18rem] shrink-0 flex-col border-t border-border" data-reply-dock="">
{composerNode}
</div>
)}
</>
)}
</section>
@@ -4,6 +4,7 @@ import { FakeBroadcastChannel } from '@/lib/__tests__/fixtures/fake-broadcast-ch
import { openPopoutChannel, type PopoutMessage, type RescuedDraft } from '@/lib/popout/channel';
import { createComposeHandoff, rememberComposePayload, rememberedComposePayload } from '@/lib/popout/compose-handoff';
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 { emailHooks } from '@/lib/plugin-hooks';
@@ -547,3 +548,90 @@ describe('after this page crashed', () => {
expect(rememberedComposePayload('h2')).toBeNull();
});
});
describe('a send held for undo', () => {
const closeSelf = vi.fn();
const MAILBOXES = [{ id: 'mDrafts', role: 'drafts' }, { id: 'mSent', role: 'sent' }];
const startDraft = {
id: 'd0', subject: 'Before sending', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [],
};
const restoredDraft = {
id: 'e1', subject: 'Back again', to: [{ email: 'a@example.org' }], cc: [], bcc: [], from: [{ name: 'Me', email: 'me@example.org' }],
keywords: { $draft: true }, mailboxIds: { mDrafts: true },
bodyValues: { t: { value: 'Text' } }, textBody: [{ partId: 't', type: 'text/plain' }], htmlBody: [], attachments: [],
};
let originalEmail: ReturnType<typeof useEmailStore.getState>;
let sendAt: string;
let cancelUndoSend: ReturnType<typeof vi.fn>;
beforeEach(() => {
closeSelf.mockClear();
originalEmail = useEmailStore.getState();
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: { closeSelf, setRestore: vi.fn() } } as never;
useIdentityStore.setState({ identities: [{ id: 'id-1', email: 'me@example.org', name: 'Me' }] } as never);
identityMocks.multi = { enabled: false, allIdentities: [], groups: [] };
const client = { getEmail: vi.fn(async (id: string) => (id === 'e1' ? restoredDraft : startDraft)), getMailboxes: vi.fn(async () => MAILBOXES) };
useAuthStore.setState({ client: client as never, getClientForAccount: (id: string) => (id === 'alice' ? client : undefined) } as never);
cancelUndoSend = vi.fn(async () => restoredDraft);
useEmailStore.setState({
sendEmail: vi.fn(async () => {
useEmailStore.setState({ pendingUndoSend: { submissionId: 'sub1', emailId: 'e1', identityId: 'id-1', sendAt, isSmime: false } });
return { scheduled: true, emailSubmissionId: 'sub1', emailId: 'e1', sendAt };
}),
cancelUndoSend,
} as never);
});
afterEach(() => {
useEmailStore.setState(originalEmail);
window.bulwarkDesktop = undefined;
});
type Sending = { onSend: (data: object) => Promise<unknown>; onClose: () => void };
const DATA = { to: ['a@example.org'], cc: [], bcc: [], subject: 'Hi', body: 'Text', localAccountId: 'alice' };
/** Opens the window, sends from it and lets the composer close, as it does after a send. */
async function sendFromWindow(seconds: number) {
sendAt = new Date(Date.now() + seconds * 1000).toISOString();
window.history.replaceState(null, '', '/en/popout/compose?draft=d0&account=alice');
render(<PopoutComposeApp />);
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Before sending'));
const props = composer as unknown as Sending;
await act(async () => { await props.onSend(DATA); });
act(() => props.onClose());
}
it('stays open with "Sending in" and Undo, and does not hand the undo to the main window', async () => {
await sendFromWindow(5);
expect(screen.getByTestId('popout-sending').textContent).toContain('sending_in');
expect(screen.getByRole('button', { name: 'undo_send' })).toBeInTheDocument();
expect(closeSelf).not.toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend ?? null).toBeNull();
});
it('closes once the undo time is over', async () => {
await sendFromWindow(1.2);
expect(closeSelf).not.toHaveBeenCalled();
await waitFor(() => expect(closeSelf).toHaveBeenCalled(), { timeout: 4000 });
});
it('Undo takes the send back and opens the message again in this window', async () => {
await sendFromWindow(30);
fireEvent.click(screen.getByRole('button', { name: 'undo_send' }));
await waitFor(() => expect(cancelUndoSend).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ submissionId: 'sub1', localAccountId: 'alice' })));
await waitFor(() => expect(screen.getByTestId('composer').textContent).toBe('Back again'));
expect(closeSelf).not.toHaveBeenCalled();
expect(new URLSearchParams(window.location.search).get('draft')).toBe('e1');
});
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();
expect(closeSelf).toHaveBeenCalled();
const sent = heard.find((message) => message.type === 'sent') as Extract<PopoutMessage, { type: 'sent' }> | undefined;
expect(sent?.notice.pendingUndoSend?.submissionId).toBe('sub1');
});
});
@@ -93,9 +93,20 @@ afterEach(() => {
const answers = () => heard.filter((message) => message.type === 'session-answer');
describe('in an ordinary browser tab', () => {
/** A browser tab: not installed, with or without a mouse. */
function stubTab(fine: boolean) {
delete window.bulwarkDesktop;
vi.stubGlobal('matchMedia', (query: string) => ({
matches: query.includes('pointer: fine') ? fine : false,
addEventListener() {},
removeEventListener() {},
}));
window.matchMedia = globalThis.matchMedia;
}
describe('in a browser tab on a phone or tablet', () => {
it('runs nothing: no channel and no lock', async () => {
delete window.bulwarkDesktop;
stubTab(false);
const opened = FakeBroadcastChannel.open.size;
render(<PopoutHost />);
await settle();
@@ -107,6 +118,18 @@ describe('in an ordinary browser tab', () => {
});
});
describe('in a browser tab on a computer', () => {
it('lends its session to a composer popped out of it', async () => {
stubTab(true);
render(<PopoutHost />);
await settle();
await expect(requestWindowSession({}, 2000)).resolves.toMatchObject({
activeAccountId: 'alice',
sessions: [{ accountId: 'alice', authMode: 'oauth' }],
});
});
});
describe('PopoutHost', () => {
it('holds no lock while idle, which would keep the page out of the back/forward cache', async () => {
render(<PopoutHost />);
@@ -286,6 +309,17 @@ describe('PopoutHost', () => {
expect(useToastStore.getState().toasts).toHaveLength(1);
});
it('says once that a closed pop-out kept its draft, with Discard', async () => {
render(<><PopoutHost /><PopoutHost /></>);
await settle();
popout.post({ type: 'draft-kept', requestId: 'k1', notice: { draftId: 'd1', localAccountId: 'alice' } });
await settle();
const toasts = useToastStore.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].title).toBe('email_composer.draft_saved');
expect(toasts[0].action?.label).toBe('email_composer.discard');
});
it('refreshes the list and counts when a pop-out changed mail, once for a burst', async () => {
render(<PopoutHost />);
await settle();
+159 -6
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { EmailComposer, type ComposerDraftData } from "@/components/email/email-composer";
import { ErrorBoundary, ComposerErrorFallback } from "@/components/error";
@@ -13,8 +13,9 @@ import { toast } from "@/stores/toast-store";
import { formatRejectedRecipients } from "@/lib/jmap/client";
import { debug } from "@/lib/debug";
import { composerSendOptions } from "@/lib/outgoing/composer-send-options";
import { announceSent } from "@/lib/plugin-after-send";
import { announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type RescuedDraft } from "@/lib/popout/channel";
import { announceSent, cancelAnnouncedSend, releaseAnnouncedSend } from "@/lib/plugin-after-send";
import { cancelFailureToast } from "@/lib/send-failure-message";
import { announceDraftKept, announceMailChanged, announceSentToMainWindow, askRescuedDraftId, watchMainWindow, type PopoutSentNotice, type RescuedDraft } from "@/lib/popout/channel";
import { acknowledgeComposeHandoff, forgetComposePayload, receiveComposeHandoff, rememberComposePayload, rememberedComposePayload, watchHandoffCancel } from "@/lib/popout/compose-handoff";
import type { ComposePopoutPayload } from "@/lib/popout/compose-payload";
import { returnComposerToMain, type ReturnResult } from "@/lib/popout/return-to-main";
@@ -30,6 +31,7 @@ import { emailHooks } from "@/lib/plugin-hooks";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { useIdentityStore } from "@/stores/identity-store";
import type { Email } from "@/lib/jmap/types";
import { Send } from "@/components/icons";
import { PopoutNotice, PopoutShell, PopoutSkeleton } from "./popout-shell";
/** A warning with no main window left to show it stays this long before the window goes. */
@@ -49,6 +51,91 @@ const MAIN_CHECK_MS = 1000;
/** How long a reloaded window waits to hear what became of the text it handed over on the way out. */
const RESCUE_ANSWER_MS = 2000;
/** A send held this long or less is an undo window ("Sending in 5 s"), not a message scheduled for later. */
const UNDO_WINDOW_MS = 120_000;
/** Undo goes this long before the server lets the message go, as in the main window. */
const UNDO_MARGIN_MS = 1000;
/** What the plugins are told when the hold runs out gets this long before the window goes. */
const RELEASE_WAIT_MS = 1000;
type HeldSend = NonNullable<PopoutSentNotice["pendingUndoSend"]>;
/** A draft to open again in this window, after Undo brought the message back to Drafts. */
interface ReopenTarget {
draftId: string;
accountId: string;
ownerAccountId?: string;
}
/**
* 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.
* The window stays until the hold runs out, then goes.
*/
function SendingWithUndo({ held, onUndone, onGone }: {
held: HeldSend;
/** The send was taken back; the message, back in Drafts, when there is one to open. */
onUndone: (restored: Email | null) => void;
onGone: () => void;
}) {
const tViewer = useTranslations("email_viewer");
const [now, setNow] = useState(() => Date.now());
const [busy, setBusy] = useState(false);
const sendAt = Date.parse(held.sendAt);
const over = !Number.isFinite(sendAt) || now >= sendAt - UNDO_MARGIN_MS;
useEffect(() => {
if (over) return;
const timer = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(timer);
}, [over]);
// Too late to undo: the plugins hear of the send now, and the window goes.
useEffect(() => {
if (!over || busy) return;
releaseAnnouncedSend(held.submissionId);
const timer = setTimeout(onGone, RELEASE_WAIT_MS);
return () => clearTimeout(timer);
}, [over, busy, held.submissionId, onGone]);
const undo = async () => {
const auth = useAuthStore.getState();
// Never another login: the submission id names another submission there.
const client = held.localAccountId ? auth.getClientForAccount(held.localAccountId) : auth.client;
if (!client) {
toast.error(tViewer("undo_send_signed_out"));
return;
}
setBusy(true);
let restored: Email | null;
try {
restored = await useEmailStore.getState().cancelUndoSend(client, held);
} catch (error) {
debug.error("Failed to undo the send:", error);
const { title, message } = cancelFailureToast(error, tViewer, tViewer("undo_send_failed"));
toast.error(title, message ? { message } : undefined);
setBusy(false);
return;
}
// Never left the server: plugins don't hear about it.
cancelAnnouncedSend(held.submissionId);
onUndone(held.isSmime ? null : restored);
};
const seconds = Math.max(0, Math.ceil((sendAt - now) / 1000));
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-4 p-6 text-center" data-testid="popout-sending">
<Send className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<p role="status" className="text-sm text-foreground">{tViewer("sending_in", { seconds })}</p>
{!over && (
<Button onClick={() => void undo()} disabled={busy} data-testid="popout-undo-send">
{tViewer("undo_send")}
</Button>
)}
</div>
);
}
function readLaunch(): { handoffId: string | null; accountId?: string; draftId?: string; ownerAccountId?: string } {
if (typeof window === "undefined") return { handoffId: null };
const search = new URLSearchParams(window.location.search);
@@ -214,7 +301,12 @@ function NewMessageGate({ onReady }: { onReady: (payload: ComposePopoutPayload)
}
/** The composer alone, in its own window: sending, autosave and discard go through the shared stores, as in a Pro tab. */
function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload; handoffId: string | null }) {
function PopoutComposer({ payload, handoffId, onReopen }: {
payload: ComposePopoutPayload;
handoffId: string | null;
/** Undo brought the message back to Drafts: open it again here. */
onReopen: (target: ReopenTarget) => void;
}) {
const t = useTranslations();
const tQuick = useTranslations("desktopQuickCompose");
const client = useAuthStore((s) => s.client);
@@ -267,6 +359,16 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
const accountId = data.localAccountId ?? useAuthStore.getState().activeAccountId;
const pending = useEmailStore.getState().pendingUndoSend;
const withLogin = pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null;
// An undo window stays here, where Send was pressed: the window shows it
// and closes when it runs out. A message scheduled for later goes to the
// main window as before.
const remaining = withLogin ? Date.parse(withLogin.sendAt) - Date.now() : NaN;
const keepHere = result.scheduled && withLogin && Number.isFinite(remaining) && remaining <= UNDO_WINDOW_MS ? withLogin : null;
if (keepHere) {
heldRef.current = keepHere;
useEmailStore.setState({ pendingUndoSend: null });
}
const rejectedRecipients = result.rejectedRecipients?.length ? formatRejectedRecipients(result.rejectedRecipients) : undefined;
const taken = await announceSentToMainWindow({
accountId,
@@ -274,7 +376,7 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
queued: !!result.queued,
filingError: !!result.filingError,
rejectedRecipients,
pendingUndoSend: pending ? { ...pending, localAccountId: pending.localAccountId ?? accountId ?? undefined } : null,
pendingUndoSend: keepHere ? null : withLogin,
});
// No main window to say so, and this is the only notice these get (#1123).
if (!taken && (result.filingError || rejectedRecipients)) {
@@ -301,11 +403,36 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
}
}, [client, t]);
// A send held for undo, kept in this window until it runs out.
const heldRef = useRef<HeldSend | null>(null);
const [held, setHeld] = useState<HeldSend | null>(null);
const handleClose = useCallback(() => {
announceMailChanged(useAuthStore.getState().activeAccountId);
if (heldRef.current) {
setHeld(heldRef.current);
return;
}
closePopoutWindow();
}, []);
const heldGone = useCallback(() => {
announceMailChanged(useAuthStore.getState().activeAccountId);
closePopoutWindow();
}, []);
const heldUndone = useCallback((restored: Email | null) => {
const sent = heldRef.current;
heldRef.current = null;
const login = sent?.localAccountId ?? useAuthStore.getState().activeAccountId;
announceMailChanged(login);
if (!restored || !login) {
closePopoutWindow();
return;
}
onReopen({ draftId: restored.id, accountId: login, ownerAccountId: sent?.submissionAccountId });
}, [onReopen]);
const [mainWindow] = useState(watchMainWindow);
useEffect(() => () => mainWindow.stop(), [mainWindow]);
@@ -357,6 +484,9 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
onUnloadState: (state: ComposerDraftData) => {
if (handoffId) rememberComposePayload(handoffId, { ...payload, initialData: state, prefill: null });
},
onDraftKept: (draftId: string, where: { accountId?: string; localAccountId?: string }) => {
announceDraftKept({ draftId, ...where, localAccountId: where.localAccountId ?? useAuthStore.getState().activeAccountId ?? undefined });
},
onDraftSaved: (draftId: string, where: { accountId?: string; localAccountId?: string } = {}) => {
const active = useAuthStore.getState().activeAccountId;
announceMailChanged(active);
@@ -373,6 +503,8 @@ function PopoutComposer({ payload, handoffId }: { payload: ComposePopoutPayload;
},
}), [handoffId, mainWindow, payload]);
if (held) return <SendingWithUndo held={held} onUndone={heldUndone} onGone={heldGone} />;
return (
<ErrorBoundary fallback={ComposerErrorFallback}>
<EmailComposer
@@ -405,6 +537,26 @@ export function PopoutComposeApp() {
// draft is the newest there is. An ordinary reload remembers as it unloads.
const [handoffId] = useState(() => (named && draftId && !rememberedComposePayload(named) ? null : named));
const [payload, setPayload] = useState<ComposePopoutPayload | null>(null);
// After Undo: the message back in Drafts, opened again in a new composer here.
const [reopen, setReopen] = useState<ReopenTarget | null>(null);
const [generation, setGeneration] = useState(0);
const handleReopen = useCallback((target: ReopenTarget) => {
// A reload starts from the draft, as after a save.
const url = new URL(window.location.href);
url.searchParams.delete("handoff");
url.searchParams.set("draft", target.draftId);
url.searchParams.set("account", target.accountId);
if (target.ownerAccountId) url.searchParams.set("owner", target.ownerAccountId);
else url.searchParams.delete("owner");
window.history.replaceState(null, "", `${url.pathname}${url.search}`);
setPayload(null);
setReopen(target);
}, []);
const reopened = useCallback((next: ComposePopoutPayload) => {
setReopen(null);
setGeneration((n) => n + 1);
setPayload(next);
}, []);
useEffect(() => {
if (!handoffId) return;
@@ -438,7 +590,8 @@ export function PopoutComposeApp() {
return (
<PopoutShell kind="compose" accountId={accountId}>
{payload ? <PopoutComposer payload={payload} handoffId={handoffId} />
{payload ? <PopoutComposer key={generation} payload={payload} handoffId={generation === 0 ? handoffId : null} onReopen={handleReopen} />
: reopen ? <RestoredDraft draftId={reopen.draftId} accountId={reopen.accountId} ownerAccountId={reopen.ownerAccountId} onReady={reopened} />
: handoffId ? <PopoutSkeleton label={t("opening")} />
: draftId ? <RestoredDraft draftId={draftId} accountId={accountId} ownerAccountId={ownerAccountId} onReady={setPayload} />
: <NewMessageGate onReady={setPayload} />}
+26 -2
View File
@@ -8,6 +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 { 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";
@@ -61,8 +62,9 @@ function claimOnce(name: string, work: () => void | Promise<void>): void {
/**
* The main window's side of pop-outs: lends them its session and renews it
* for them, passes on that mail changed, and takes over what a pop-out can't
* say any more once it has closed. Only where pop-outs are on offer: an
* ordinary browser tab runs none of this.
* say any more once it has closed. Only where pop-outs are on offer: a
* browser tab on a computer runs it for composers popped out of it, one on a
* phone or tablet runs none of this.
*/
export function PopoutHost() {
const available = usePopoutsAvailable();
@@ -176,6 +178,28 @@ export function PopoutHost() {
case "draft-rescue":
startRescue(message.draft);
break;
case "draft-kept": {
// Said here, as the pop-out closes with its Escape or X.
const { draftId, accountId, localAccountId } = message.notice;
claimOnce(`bulwark-popout-kept:${message.requestId}`, () => {
const tr = tRef.current;
showDraftSavedToast(() => {
const auth = useAuthStore.getState();
const login = localAccountId ? auth.getClientForAccount(localAccountId) : auth.client;
if (!login) {
toast.error(tr("email_composer.discard_signed_out"));
return;
}
void login.deleteEmail(draftId, accountId).catch((error: unknown) => console.error("Failed to discard draft:", error));
}, {
saved: tr("email_composer.draft_saved"),
discard: tr("email_composer.discard"),
discarded: tr("compose_ux.draft_discarded"),
undo: tr("compose_ux.undo"),
});
});
break;
}
case "rescue-query": {
const rescue = rescues.get(message.rescueKey);
if (rescue) void rescue.then((draftId) => channel?.post({ type: "rescue-answer", rescueKey: message.rescueKey, draftId }));
@@ -21,7 +21,7 @@ vi.mock('../settings-section', async (importOriginal) => ({
const ROWS = {
plainTextMode: 'plain_text_mode.label',
autoSelectReplyIdentity: 'auto_select_reply_identity.label',
autoSelectReplyIdentity: 'reply_from_received.label',
replyIdentityMatch: 'reply_identity_match.label',
} as const;
@@ -0,0 +1,41 @@
import { render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ComposingSettings } from '../composing-settings';
import { useSettingsStore } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import en from '@/locales/en/common.json';
// "Reply from the address it was sent to" explained catch-all matching,
// which belongs to the row below it. The row now says what it does and
// when it helps.
vi.mock('next-intl', () => ({
useTranslations: () => (key: string) => key,
useLocale: () => 'en',
}));
vi.mock('../settings-section', async (importOriginal) => ({
...(await importOriginal<typeof import('../settings-section')>()),
SettingsSection: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
describe('ComposingSettings wording', () => {
beforeEach(() => {
useSettingsStore.getState().resetToDefaults();
useAuthStore.setState({ client: null });
});
it('describes replying from the received address without the catch-all note', () => {
render(<ComposingSettings />);
expect(screen.getByText('reply_from_received.description')).toBeTruthy();
expect(screen.queryByText('auto_select_reply_identity.description')).toBeNull();
expect(en.compose_words.reply_from_received.description).not.toMatch(/catch-all|Received Address Matching/i);
});
it('calls the send delay "Undo send"', () => {
render(<ComposingSettings />);
expect(screen.getByText('undo_send.label')).toBeTruthy();
expect(screen.queryByText('send_delay.label')).toBeNull();
expect(en.compose_words.undo_send.label).toBe('Undo send');
});
});
+8 -9
View File
@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { useSettingsStore, undoSendOptions } from '@/stores/settings-store';
import type { MentionAddsRecipientTo, ReplyIdentityMatch, SendDelaySeconds } from '@/stores/settings-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -21,6 +21,7 @@ import { X, ChevronRight } from '@/components/icons';
export function ComposingSettings() {
const t = useTranslations('settings.email_behavior');
const tPage = useTranslations('settings.composing_page');
const tWords = useTranslations('compose_words');
const templatesEnabled = usePolicyStore((s) => s.isFeatureEnabled('templatesEnabled'));
// The admin panel can lock or hide these three (restriction-keys.ts).
const { isSettingLocked, isSettingHidden } = usePolicyStore();
@@ -102,7 +103,7 @@ export function ComposingSettings() {
</SettingItem>
{!isSettingHidden('autoSelectReplyIdentity') && (
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
<SettingItem label={tWords('reply_from_received.label')} description={tWords('reply_from_received.description')} locked={isSettingLocked('autoSelectReplyIdentity')}>
<ToggleSwitch
checked={autoSelectReplyIdentity}
onChange={(checked) => updateSetting('autoSelectReplyIdentity', checked)}
@@ -157,17 +158,15 @@ export function ComposingSettings() {
</SettingsSection>
<SettingsSection id="sending" title={tPage('sections.sending')}>
<SettingItem label={t('send_delay.label')} description={t('send_delay.description')}>
<SettingItem label={tWords('undo_send.label')} description={tWords('undo_send.description')}>
<div className="flex flex-col items-end gap-1">
<Select
value={String(sendDelaySeconds)}
onChange={(value) => updateSetting('sendDelaySeconds', Number(value) as SendDelaySeconds)}
options={[
{ value: '0', label: t('send_delay.off') },
{ value: '10', label: t('send_delay.seconds', { seconds: 10 }) },
{ value: '30', label: t('send_delay.seconds', { seconds: 30 }) },
{ value: '60', label: t('send_delay.seconds', { seconds: 60 }) },
]}
options={undoSendOptions(sendDelaySeconds).map((seconds) => ({
value: String(seconds),
label: seconds === 0 ? t('send_delay.off') : t('send_delay.seconds', { seconds }),
}))}
/>
{sendDelaySeconds > 0 && !delayedSendSupported && (
<p className="max-w-64 text-end text-xs text-amber-600 dark:text-amber-400">{t('send_delay.unsupported')}</p>
@@ -0,0 +1,49 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { render, screen } from '@testing-library/react';
import { TemplatePicker } from '../template-picker';
import { DEFAULT_POLICY } from '@/lib/admin/types';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { usePolicyStore } from '@/stores/policy-store';
import { useTemplateStore } from '@/stores/template-store';
import { useAccountTemplatesStore } from '@/stores/account-templates-store';
import type { EmailTemplate } from '@/lib/template-types';
// compose-18: a template saved from the composer showed its name twice
// ("Meeting follow-up / Meeting follow-up") under the heading "Other", with
// no other heading for it to stand apart from. The next-intl mock returns keys.
const own = (id: string, name: string, extra: Partial<EmailTemplate> = {}): EmailTemplate => ({
id, name, subject: name, body: `${name} body`, category: '', isFavorite: false,
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', ...extra,
});
beforeEach(() => {
const subject = { email: 'alice@example.org', groups: [] };
usePolicyStore.setState({ rawPolicy: DEFAULT_POLICY, policy: resolvePolicy(DEFAULT_POLICY, subject).policy, subject });
useTemplateStore.setState({ templates: [own('t1', 'Meeting follow-up')], recentTemplateIds: [], deletedTemplateIds: {} });
useAccountTemplatesStore.setState({ personal: {}, teams: {}, active: null });
});
describe('the template picker', () => {
it('shows a name once, and no "Other" heading without categories', () => {
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(screen.getAllByText('Meeting follow-up')).toHaveLength(1);
expect(screen.queryByText('section_uncategorized')).not.toBeInTheDocument();
});
it('keeps the subject when it says more than the name', () => {
useTemplateStore.setState({ templates: [own('t1', 'Follow-up', { subject: 'Thanks for meeting today' })] });
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(screen.getByText('Thanks for meeting today')).toBeInTheDocument();
});
it('keeps "Other" apart from the categories', () => {
useTemplateStore.setState({ templates: [own('t1', 'Follow-up'), own('t2', 'Invoice', { category: 'Billing' })] });
render(<TemplatePicker isOpen onClose={() => {}} onSelect={() => {}} />);
expect(screen.getByText('Billing', { selector: 'h3' })).toBeInTheDocument();
expect(screen.getByText('section_uncategorized')).toBeInTheDocument();
});
});
@@ -0,0 +1,87 @@
'use client';
import { useId, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Dialog } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import { validateTemplateName } from '@/lib/template-utils';
interface QuickSaveTemplateDialogProps {
/** The name to start with: the message's subject. */
defaultName: string;
onSave: (name: string) => void;
/** "Edit details…": the full template editor, with the name typed so far. */
onEditDetails: (name: string) => void;
onClose: () => void;
}
/**
* "Save as template" from the composer: a name and Save. The full editor
* (category, recipients, sending identity, favourite) is one click further,
* behind "Edit details…"; it used to open straight away with nine fields.
*/
export function QuickSaveTemplateDialog({ defaultName, onSave, onEditDetails, onClose }: QuickSaveTemplateDialogProps) {
const tComposer = useTranslations('email_composer');
const tSend = useTranslations('compose_send');
const tSettings = useTranslations('settings.templates');
const tCommon = useTranslations('common');
const fieldId = useId();
const [name, setName] = useState(defaultName);
const [error, setError] = useState<string | null>(null);
const save = () => {
const problem = validateTemplateName(name);
if (problem) {
setError(problem);
return;
}
onSave(name.trim());
};
return (
<Dialog
isOpen
onClose={onClose}
title={tComposer('save_as_template')}
size="sm"
dirty={name !== defaultName}
testId="quick-save-template"
onSubmit={(e) => {
e.preventDefault();
save();
}}
footer={
<>
<Button type="button" variant="ghost" className="me-auto" onClick={() => onEditDetails(name)}>
{tSend('template_edit_details')}
</Button>
<Button type="button" variant="outline" onClick={onClose}>{tCommon('cancel')}</Button>
<Button type="submit" disabled={!name.trim()}>{tCommon('save')}</Button>
</>
}
>
<div className="mt-4">
<label htmlFor={fieldId} className="text-sm font-medium text-foreground">{tSend('template_name')}</label>
<Input
id={fieldId}
value={name}
onChange={(e) => {
setName(e.target.value);
if (error) setError(null);
}}
className={cn('mt-1', error && 'border-destructive')}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${fieldId}-error` : undefined}
autoFocus
/>
{error && (
<p id={`${fieldId}-error`} className="text-xs text-red-600 dark:text-red-400 mt-1">
{tSettings(`validation.${error}`)}
</p>
)}
</div>
</Dialog>
);
}
+3 -1
View File
@@ -22,6 +22,8 @@ import type { Editor } from '@tiptap/react';
interface TemplateFormProps {
template?: EmailTemplate;
initialData?: {
/** A name typed before the full editor opened (Save as template). */
name?: string;
subject?: string;
body?: string;
/** The body is the composer's HTML, not plain text (#1506). */
@@ -57,7 +59,7 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
const browserList = useTemplateStore((s) => s.templates);
const templates = categorySource ?? browserList;
const [name, setName] = useState(template?.name || '');
const [name, setName] = useState(template?.name || initialData?.name || '');
const [category, setCategory] = useState(template?.category || '');
const [subject, setSubject] = useState(template?.subject || initialData?.subject || '');
const [body, setBody] = useState(template?.body || initialData?.body || '');
+11 -6
View File
@@ -114,7 +114,8 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
</span>
)}
</div>
{template.subject && (
{/* Not the name a second time ("Meeting follow-up / Meeting follow-up"). */}
{template.subject && template.subject.trim() !== template.name.trim() && (
<p className="text-xs text-muted-foreground truncate mt-1">
{template.subject}
</p>
@@ -131,13 +132,15 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
</button>
);
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource) => {
const renderSection = (title: string, items: EmailTemplate[], source: TemplateSource, withHeading = true) => {
if (items.length === 0) return null;
return (
<div className="mb-4" key={`${source.key}:${title}`}>
<h3 className="text-xs font-semibold text-muted-foreground mb-1 px-3">
{title}
</h3>
{withHeading && (
<h3 className="text-xs font-semibold text-muted-foreground mb-1 px-3">
{title}
</h3>
)}
<div className="space-y-0.5">{items.map((item) => renderTemplateItem(item, source))}</div>
</div>
);
@@ -171,7 +174,9 @@ export function TemplatePicker({ isOpen, onClose, onSelect, accountId }: Templat
{Object.entries(byCategory)
.filter(([cat]) => cat !== '')
.map(([cat, items]) => renderSection(cat, items, source))}
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source)}
{/* "Other" only sets them apart from favourites, recent or categories. */}
{renderSection(t('section_uncategorized'), byCategory[''] ?? [], source,
favorites.length > 0 || recent.length > 0 || Object.keys(byCategory).some((cat) => cat !== ''))}
</>
);
if (!withHeading) return <div key={source.key}>{body}</div>;
@@ -208,6 +208,25 @@ describe('toasts and the focused element', () => {
expect(toastContainer()).not.toHaveAttribute('data-dodging');
});
// With the focus in the composer's text or the quick reply box, "Email
// sent" and "Sending in 5 seconds" moved to the top and covered the
// composer's close button and the reader's toolbar. A large field the
// stack only overlaps at a corner keeps the toasts at the bottom.
it('stays at the bottom over a corner of a large focused field', () => {
const h = window.innerHeight;
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.hasAttribute('data-toast-container')) return rect(h - 100, 600, 360, 80);
if (this.id === 'body') return rect(h - 400, 300, 700, 360);
return rect(10, 10, 80, 36);
});
render(<><Toasts /><textarea id="body" /></>);
act(() => { (document.getElementById('body') as HTMLElement).focus(); });
act(() => { toast.success('Email sent'); });
act(() => { vi.advanceTimersByTime(20); });
expect(toastContainer()).not.toHaveAttribute('data-dodging');
expect(toastContainer()).not.toHaveClass('top-5');
});
// On a phone the top of the screen is the header with Back and the message
// actions; an Undo toast that moved there blocked them. (#1472)
it('stays at the bottom on a phone, clear of the header', () => {
+13 -5
View File
@@ -361,10 +361,14 @@ export function ToastItem({ toast, onClose, held = false, onFocusLost }: ToastPr
}
/**
* True when the toast stack, in its usual place at the bottom, overlaps the
* element that has the focus. The stack's own box is measured where it is
* now; while it sits at the top, its bottom place is worked out from the gap
* it left there (data-bottom-gap).
* True when the toast stack, in its usual place at the bottom, hides at
* least half of the element that has the focus: a button or a field. A
* large element it only overlaps at a corner (the composer's text, the
* quick reply box) stays usable, and moving the stack to the top for it put
* the toasts over the composer's close button and the reader's toolbar. The
* stack's own box is measured where it is now; while it sits at the top,
* its bottom place is worked out from the gap it left there
* (data-bottom-gap).
*/
export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
if (!stack) return false;
@@ -382,7 +386,11 @@ export function stackWouldCoverFocus(stack: HTMLElement | null): boolean {
stack.dataset.bottomGap = String(Math.max(0, window.innerHeight - box.bottom));
}
const f = focused.getBoundingClientRect();
return f.right > box.left && f.left < box.right && f.bottom > top && f.top < bottom;
const overlapWidth = Math.min(f.right, box.right) - Math.max(f.left, box.left);
const overlapHeight = Math.min(f.bottom, bottom) - Math.max(f.top, top);
if (overlapWidth <= 0 || overlapHeight <= 0) return false;
const area = Math.max(f.width, 0) * Math.max(f.height, 0);
return area === 0 || overlapWidth * overlapHeight >= area / 2;
}
export function ToastContainer({ toasts, onClose }: { toasts: Toast[]; onClose: (id: string) => void }) {
+21
View File
@@ -0,0 +1,21 @@
"use client";
import { useSyncExternalStore } from "react";
function subscribe(onChange: () => void): () => void {
window.addEventListener("online", onChange);
window.addEventListener("offline", onChange);
return () => {
window.removeEventListener("online", onChange);
window.removeEventListener("offline", onChange);
};
}
/**
* Whether the browser says it has a network, kept up to date. Also without
* offline mode, whose store only follows it while that runs. True on the
* server and while the page hydrates.
*/
export function useBrowserOnline(): boolean {
return useSyncExternalStore(subscribe, () => navigator.onLine !== false, () => true);
}
+12 -9
View File
@@ -7,27 +7,30 @@ import { resolveEmailBlobContext } from "@/stores/email-store";
import { toast } from "@/stores/toast-store";
import type { Email } from "@/lib/jmap/types";
import { openMessageWindow } from "@/lib/popout/open";
import { popoutsAvailable } from "@/lib/popout/support";
import { composerPopoutsAvailable, popoutsAvailable } from "@/lib/popout/support";
import { isPopoutWindow } from "@/lib/popout/window-role";
/**
* Whether to offer "Open in new window" here. False on the server and in the
* first render, so the markup hydrates the same; false in the demo, whose
* client has no session to share, and inside a pop-out (for a message: it is
* already in its own window).
* Whether to offer "Open in new window" here: for a composer, the default,
* also in a browser tab on a computer; for a message only in an installed or
* the desktop app. False on the server and in the first render, so the
* markup hydrates the same; false in the demo, whose client has no session to
* share, and inside a pop-out (for a message: it is already in its own window).
* The main window's side of pop-outs (PopoutHost) runs wherever a composer can
* move out.
*/
export function usePopoutsAvailable(): boolean {
export function usePopoutsAvailable(kind: "compose" | "message" = "compose"): boolean {
const isDemoMode = useAuthStore((s) => s.isDemoMode);
const [available, setAvailable] = useState(false);
useEffect(() => {
const check = () => setAvailable(popoutsAvailable() && !isPopoutWindow());
const check = () => setAvailable((kind === "message" ? popoutsAvailable() : composerPopoutsAvailable()) && !isPopoutWindow());
check();
// Installing the running tab turns it into a window without a reload.
if (typeof window.matchMedia !== "function") return;
const media = window.matchMedia("(display-mode: standalone)");
media.addEventListener?.("change", check);
return () => media.removeEventListener?.("change", check);
}, []);
}, [kind]);
return available && !isDemoMode;
}
@@ -37,7 +40,7 @@ export function usePopoutsAvailable(): boolean {
* blocks the window; nothing else changes then.
*/
export function useOpenEmailInWindow(): ((email: Email) => void) | null {
const available = usePopoutsAvailable();
const available = usePopoutsAvailable("message");
const t = useTranslations("pwaWindows");
const open = useCallback((email: Email) => {
const auth = useAuthStore.getState();
+18 -2
View File
@@ -31,6 +31,22 @@ describe('findUnfilledPlaceholders', () => {
expect(findUnfilledPlaceholders('', 'Treffpunkt: [ÜBERGABEORT]')).toEqual(['[ÜBERGABEORT]']);
});
// A template's "Hi [Name]" went out unchanged: only capitals and
// "your/insert" prompts counted.
it('finds capitalised words in brackets that a template left to fill in', () => {
expect(findUnfilledPlaceholders('', ['Hi [Name],', '[Step] is done. Regards, [First Name] at [Company]. [Date]'].join('\n'), EN)).toEqual([
'[Name]',
'[Step]',
'[First Name]',
'[Company]',
'[Date]',
]);
});
it('leaves longer bracketed phrases and links alone', () => {
expect(findUnfilledPlaceholders('', 'See [Chapter Four Part Two] and [Name](https://example.com).', EN)).toEqual([]);
});
it('finds fill-ins that ask the sender to write something', () => {
expect(findUnfilledPlaceholders('', 'Best regards,\n[Your Name]\n[Insert date]', EN)).toEqual([
'[Your Name]',
@@ -94,7 +110,7 @@ describe('findUnfilledPlaceholders', () => {
it('skips word lists that are not lists of strings', () => {
const broken = [{ before: 'your' }, { before: [1, '', '-', null] }, null, { after: {} }] as unknown as PlaceholderWords[];
expect(findUnfilledPlaceholders('', '[Your Name] [TIME]', broken)).toEqual(['[TIME]']);
expect(findUnfilledPlaceholders('', '[Your name] [TIME]', broken)).toEqual(['[TIME]']);
});
it('finds the circles that stand in for a name in Japanese and Korean', () => {
@@ -104,7 +120,7 @@ describe('findUnfilledPlaceholders', () => {
it('leaves bracketed words and headings in other languages alone', () => {
const text = [
'【重要】お知らせ 【通知】',
'[Votre] [Ваше] [您的]',
'[votre] [ваше] [您的]',
'Le [ton] juste. Vote [tu].',
'Siehe [Anlage 2] und [Kapitel 3].',
'Termin: ○ und ◯.',
@@ -57,8 +57,8 @@ describe('api.app.searchSettings', () => {
expect(results.length).toBeGreaterThan(0);
expect(results[0].pageId).toBe('composing');
expect(results[0].page).toBe('Verfassen');
const de = (await import('@/locales/de/common.json')).default as unknown as { settings: { email_behavior: { send_delay: { label: string } } } };
expect(results.map((r) => r.label)).toContain(de.settings.email_behavior.send_delay.label);
const de = (await import('@/locales/de/common.json')).default as unknown as { compose_words: { undo_send: { label: string } } };
expect(results.map((r) => r.label)).toContain(de.compose_words.undo_send.label);
});
it('leaves out pages the server does not offer', async () => {
+47
View File
@@ -132,6 +132,53 @@ describe('popoutsAvailable', () => {
});
});
describe('composerPopoutsAvailable', () => {
async function available() {
const { composerPopoutsAvailable } = await import('@/lib/popout/support');
return composerPopoutsAvailable();
}
it('is on in a browser tab on a computer: a composer opens as a popup window, as in Gmail', async () => {
stubDisplay({ standalone: false });
expect(await available()).toBe(true);
});
it('is off in a browser tab on a phone or tablet, where window.open makes a tab', async () => {
stubDisplay({ standalone: false, fine: false });
expect(await available()).toBe(false);
});
it('is on wherever a message can move out too', async () => {
stubDisplay();
expect(await available()).toBe(true);
stubDisplay({ standalone: false, fine: false });
window.bulwarkDesktop = { version: 1, platform: 'win32', windows: {} };
expect(await available()).toBe(true);
});
it('is off in an installed app on a touch device', async () => {
stubDisplay({ fine: false });
expect(await available()).toBe(false);
});
it('is off in a tab without what keeps a shared session safe', async () => {
stubDisplay({ standalone: false });
vi.stubGlobal('BroadcastChannel', undefined);
expect(await available()).toBe(false);
vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
vi.stubGlobal('crypto', {});
expect(await available()).toBe(false);
});
it('lets the hook offer a composer its own window from a tab, and still not a message', async () => {
stubDisplay({ standalone: false });
const { renderHook } = await import('@testing-library/react');
const { usePopoutsAvailable } = await import('@/hooks/use-popouts-available');
expect(renderHook(() => usePopoutsAvailable()).result.current).toBe(true);
expect(renderHook(() => usePopoutsAvailable('message')).result.current).toBe(false);
}, 60_000);
});
describe('opening windows', () => {
it('opens a message as a popup in the app, with its login and account', async () => {
const { openMessageWindow } = await import('@/lib/popout/open');
+32
View File
@@ -90,3 +90,35 @@ describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', (
expect(h.text).not.toContain('<noname@x.tld>');
});
});
// Gmail and Outlook list who the forwarded mail went to.
describe('buildQuoteHeader forward recipients', () => {
const email = {
from: [sender],
to: [{ name: 'Anna Becker', email: 'anna@example.com' }, { email: 'team@example.com' }],
cc: [{ name: 'Carol <x>', email: 'carol@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T10:00:00Z',
};
it('adds To and Cc lines after the subject', async () => {
const h = await buildQuoteHeader({ mode: 'forward', email, ...base });
expect(h.text).toContain('Subject: Hello\nTo: Anna Becker <anna@example.com>, team@example.com\nCc: Carol <x> <carol@example.com>\n');
expect(h.html).toContain('To: Anna Becker &lt;anna@example.com&gt;, team@example.com<br>Cc: Carol &lt;x&gt; &lt;carol@example.com&gt;<br>');
});
it('uses the caller\'s To and Cc labels', async () => {
const labels = {
formatReplyLine: () => '', forwardedSeparator: '--', fromLabel: 'Von', dateLabel: 'Datum', subjectLabel: 'Betreff',
toLabel: 'An', ccLabel: 'Kopie',
};
const h = await buildQuoteHeader({ mode: 'forward', email, labels, ...base });
expect(h.text).toContain('\nAn: Anna Becker <anna@example.com>, team@example.com\nKopie: Carol <x> <carol@example.com>\n');
});
it('leaves out a line for an empty list', async () => {
const h = await buildQuoteHeader({ mode: 'forward', email: { ...email, cc: [] }, ...base });
expect(h.text).toContain('\nTo: ');
expect(h.text).not.toContain('Cc:');
});
});
+2 -2
View File
@@ -43,7 +43,7 @@ describe('settings search index (English messages)', () => {
expect(labelsFor('privacy')).toContain('Sender favicons');
expect(labelsFor('message_list')).toContain('Clear search when switching folders');
expect(labelsFor('privacy')).toContain('Trusted senders');
expect(labelsFor('composing')).toContain('Undo send / send delay');
expect(labelsFor('composing')).toContain('Undo send');
expect(labelsFor('general')).toContain('Time zone');
expect(labelsFor('organize')).toContain('Nested tags');
});
@@ -83,7 +83,7 @@ describe('settings search index (English messages)', () => {
it('finds the newer signature settings on their tabs', () => {
expect(labelsFor('composing')).toContain('Signature on new mail');
expect(labelsFor('composing')).toContain('Signature on replies & forwards');
expect(labelsFor('composing')).toContain('Undo send / send delay');
expect(labelsFor('composing')).toContain('Undo send');
});
it('marks the rows the Advanced switch hides', () => {
+10
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { formatShortcutCombo, shortcutAlternatives } from '../shortcut-keys';
import { GENERAL_SHORTCUTS, SHORTCUT_APPS, sectionsWithGeneral, shortcutAppForPath } from '../keyboard-shortcuts';
import { MAIL_SHORTCUTS } from '../keyboard-shortcuts/mail';
describe('shortcut notation', () => {
it('writes Mod as ⌘ on Apple devices and Ctrl elsewhere, Alt as ⌥', () => {
@@ -37,3 +38,12 @@ describe('shortcut sections', () => {
expect(empty).toEqual([expect.objectContaining({ id: 'global', entries: GENERAL_SHORTCUTS })]);
});
});
describe('the composer section of the shortcut list', () => {
// compose-19: "t" types a t while writing, so it isn't a composer key.
it('lists the editing keys of other mail apps and no single letter', () => {
const keys = MAIL_SHORTCUTS.composer.map((entry) => entry.key);
expect(keys).toEqual(expect.arrayContaining(['Mod + S', 'Mod + K', 'Mod + Shift + C', 'Mod + Shift + B']));
expect(keys.filter((key) => /^[a-z]$/i.test(key))).toEqual([]);
});
});
@@ -0,0 +1,18 @@
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', () => {
const files = [
{ name: 'a.pdf' },
{ name: 'b.pdf', uploading: true },
{ name: 'c.pdf' },
{ name: 'big.zip', uploading: true },
{ 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']);
// Each keeps its place in the composer's list, for Remove.
expect(shown.map(({ index }) => index)).toEqual([4, 1, 3, 0, 2]);
});
});
@@ -0,0 +1,57 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { discardWithUndo, showDraftSavedToast } from '../draft-close-toast';
import { useToastStore } from '@/stores/toast-store';
const labels = { saved: 'Draft saved', discard: 'Discard', discarded: 'Draft discarded', undo: 'Undo' };
const shown = () => useToastStore.getState().toasts;
describe('closing a composer that kept its draft', () => {
beforeEach(() => {
vi.useFakeTimers();
useToastStore.getState().clearToasts();
});
afterEach(() => {
vi.useRealTimers();
useToastStore.getState().clearToasts();
});
it('says "Draft saved" and offers Discard', () => {
const discard = vi.fn();
showDraftSavedToast(discard, labels);
expect(shown()).toHaveLength(1);
expect(shown()[0].title).toBe('Draft saved');
expect(shown()[0].action?.label).toBe('Discard');
expect(discard).not.toHaveBeenCalled();
});
it('deletes only once the Undo window is over', () => {
const discard = vi.fn();
showDraftSavedToast(discard, labels);
shown()[0].action!.onClick();
const undoToast = shown().find((t) => t.title === 'Draft discarded')!;
expect(undoToast.action?.label).toBe('Undo');
expect(undoToast.pauseOnHover).toBe(false);
expect(discard).not.toHaveBeenCalled();
vi.advanceTimersByTime(undoToast.duration!);
expect(discard).toHaveBeenCalledTimes(1);
});
it('keeps the draft on Undo and puts back what was taken away', () => {
const discard = vi.fn();
const restore = vi.fn();
discardWithUndo(discard, labels, restore);
shown()[0].action!.onClick();
vi.advanceTimersByTime(60_000);
expect(discard).not.toHaveBeenCalled();
expect(restore).toHaveBeenCalledTimes(1);
});
it('deletes at once when the page goes away during the Undo window', () => {
const discard = vi.fn();
discardWithUndo(discard, labels);
window.dispatchEvent(new Event('pagehide'));
expect(discard).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(60_000);
expect(discard).toHaveBeenCalledTimes(1);
});
});
+14
View File
@@ -0,0 +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.
*/
export function attachmentsForDisplay<T extends { error?: unknown; uploading?: boolean }>(
attachments: readonly T[],
): Array<{ att: T; index: number }> {
const rank = (att: T) => (att.error ? 0 : att.uploading ? 1 : 2);
return attachments
.map((att, index) => ({ att, index }))
.sort((a, b) => rank(a.att) - rank(b.att) || a.index - b.index);
}
+56
View File
@@ -0,0 +1,56 @@
import { toast, useToastStore } from "@/stores/toast-store";
import { UNDO_TOAST_DURATION } from "@/lib/email-action-toast";
export interface DiscardToastLabels {
discarded: string;
undo: string;
}
export interface DraftSavedToastLabels extends DiscardToastLabels {
saved: string;
discard: string;
}
/**
* "Draft discarded" with Undo. `discard` runs once the toast is gone without
* an Undo (or the page goes away), so Undo needs nothing from the server:
* the draft is still there. `restore` puts back what the caller took off
* screen.
*/
export function discardWithUndo(discard: () => void, labels: DiscardToastLabels, restore?: () => void): void {
let settled = false;
let timer: ReturnType<typeof setTimeout> | null = null;
const commit = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
window.removeEventListener("pagehide", commit);
discard();
};
const id = toast.success(labels.discarded, {
action: {
label: labels.undo,
onClick: () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
window.removeEventListener("pagehide", commit);
restore?.();
},
},
duration: UNDO_TOAST_DURATION,
// The toast and the delete end together: a pause on hover would leave
// an Undo on screen for a draft that is already gone.
pauseOnHover: false,
});
const shown = useToastStore.getState().toasts.find((t) => t.id === id)?.duration;
timer = setTimeout(commit, shown && shown > 0 ? shown : UNDO_TOAST_DURATION);
window.addEventListener("pagehide", commit);
}
/** After a composer closed and kept its draft: "Draft saved", with Discard. */
export function showDraftSavedToast(discard: () => void, labels: DraftSavedToastLabels): void {
toast.success(labels.saved, {
action: { label: labels.discard, onClick: () => discardWithUndo(discard, labels) },
});
}
+5 -1
View File
@@ -39,7 +39,11 @@ export const MAIL_SHORTCUTS = {
composer: [
{ key: "Mod + Enter", description: "shortcuts.composer.send" },
{ key: "Mod + Shift + Enter", description: "shortcuts.composer.schedule_send" },
{ key: "t", description: "shortcuts.composer.template_picker" },
// "t" opens templates only outside a text field, so it isn't listed: in the composer it types a t.
{ key: "Mod + S", description: "compose_ux.shortcut_save_draft" },
{ key: "Mod + K", description: "compose_ux.shortcut_insert_link" },
{ key: "Mod + Shift + C", description: "compose_ux.shortcut_show_cc" },
{ key: "Mod + Shift + B", description: "compose_ux.shortcut_show_bcc" },
// Proofreading (components/email/proofreading); shown even without a checker.
{ key: "Mod + '", description: "proofreading.shortcuts.next_issue" },
{ key: "Mod + ;", description: "proofreading.shortcuts.previous_issue" },
+13 -3
View File
@@ -29,9 +29,7 @@ export interface QuickReplyEnvelope {
export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope {
const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
.map((r) => r.email)
.filter((address): address is string => Boolean(address));
const recipients = quickReplyRecipients(email, identities).map((r) => r.email);
const primaryIdentity = identities[0];
const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
? findDraftIdentityId(identities, email.from?.[0])
@@ -46,6 +44,18 @@ export function quickReplyEnvelope(email: ReplySource, identities: Identity[]):
return { recipients, sendingIdentity, primaryIdentity };
}
/**
* The people a quick reply goes to, with their names, by the same rules as
* quickReplyEnvelope: for the box's "Reply to Ben Carter…" placeholder.
*/
export function quickReplyRecipients(email: ReplySource, identities: Identity[]): { name?: string; email: string }[] {
const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
return buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
.filter((r): r is typeof r & { email: string } => Boolean(r.email))
.map((r) => ({ name: r.name || undefined, email: r.email }));
}
/**
* Saves the text typed into a quick reply as a draft reply, before an
* automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded
+48
View File
@@ -0,0 +1,48 @@
/**
* The toast of a send from the moment the composer closes: "Sending…" while
* the server takes the message, then "Sent" with View. The composer used to
* stay on screen, greyed and without a word, until the server answered.
*/
import { toast } from '@/stores/toast-store';
import { announce } from '@/lib/announce';
import { effectiveToastDuration } from '@/lib/toast-duration';
export interface SendProgressLabels {
sending: string;
sent: string;
view: string;
}
export interface SendProgress {
/** The message is out. `view` opens it; without it there is no View. */
sent(view?: () => void): void;
/** Another toast says what happened (undo, scheduled, failed, kept offline). */
dismiss(): void;
}
export function startSendProgress(labels: SendProgressLabels): SendProgress {
const id = toast.progress(labels.sending, { progress: null });
let settled = false;
return {
sent(view) {
if (settled) return;
settled = true;
const action = view ? { label: labels.view, onClick: view } : undefined;
toast.update(id, {
type: 'success',
title: labels.sent,
progress: undefined,
action,
duration: effectiveToastDuration({ type: 'success', action }),
});
// An update isn't read out as a new toast is.
announce(labels.sent);
},
dismiss() {
if (settled) return;
settled = true;
toast.dismiss(id);
},
};
}
+6 -1
View File
@@ -10,6 +10,11 @@ const TEMPLATE_PLACEHOLDER = /\{\{\s*[\w.-]+\s*\}\}/g;
// Markdown link.
const CAPS_BRACKET = /\[[\p{Lu}\d][\p{Lu}\d _./-]{0,28}[\p{Lu}\d]\](?!\()/gu;
// [Name], [Company], [First Name]: one to three capitalised words, the way
// templates name a fill-in. A lowercase word ([Yours truly]) or a number
// ([Anlage 2]) makes it ordinary text.
const TITLE_BRACKET = /\[\p{Lu}[\p{Ll}'’-]+(?: \p{Lu}[\p{Ll}'’-]+){0,2}\](?!\()/gu;
/**
* The words that mark a fill-in in one language. They live in each locale
* file under email_composer.placeholder_words, so a new translation brings
@@ -113,7 +118,7 @@ function collect(text: string, patterns: RegExp[]): Match[] {
* to check (see PlaceholderWords).
*/
export function findUnfilledPlaceholders(subject: string, bodyText: string, words: PlaceholderWords[] = []): string[] {
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, CIRCLE_MARKER];
const bodyPatterns = [TEMPLATE_PLACEHOLDER, CAPS_BRACKET, TITLE_BRACKET, CIRCLE_MARKER];
const prompt = promptBracketPattern(words);
const unfinished = unfinishedWordPattern(words);
if (prompt) bodyPatterns.push(prompt);
+17
View File
@@ -105,6 +105,7 @@ export type PopoutMessage =
| { type: 'moved'; requestId: string; notice: PopoutMovedNotice }
| { type: 'moved-ack'; requestId: string }
| { type: 'mail-changed'; accountId: string | null }
| { type: 'draft-kept'; requestId: string; notice: DraftKeptNotice }
| { type: 'state-change' }
| { type: 'draft-rescue'; draft: RescuedDraft }
| { type: 'rescue-query'; rescueKey: string }
@@ -119,6 +120,15 @@ export type PopoutMessage =
| { type: 'main-gone'; windowId: string }
| { type: 'account-removed'; accountId: string };
/** A pop-out closed and kept its draft: the main window says so, with Discard. */
export interface DraftKeptNotice {
draftId: string;
/** The account it lives in, when not the login's own. */
accountId?: string;
/** The login it was saved through. */
localAccountId?: string;
}
export interface PopoutChannel {
post(message: PopoutMessage): void;
close(): void;
@@ -246,6 +256,13 @@ export function announceMailChanged(accountId: string | null): void {
channel?.close();
}
/** Tells the main window that a closing pop-out kept its draft. */
export function announceDraftKept(notice: DraftKeptNotice): void {
const channel = openPopoutChannel(() => {});
channel?.post({ type: 'draft-kept', requestId: newId(), notice });
channel?.close();
}
/**
* The draft id a closing window's text was saved under, for the same window
* after a reload, whose own copy still names the version that replaced. Null
+26 -5
View File
@@ -1,7 +1,8 @@
/**
* Whether "Open in new window" is on offer: an installed app or the desktop
* app, on a device with windows. In a browser tab, a tab does the same job
* and the app behaves as it always has.
* for a message, and the app behaves as it always has. A composer is the
* exception: it moves to a window of its own from a tab too, as in Gmail.
*/
import { IS_LITE } from '@/lib/lite';
@@ -13,16 +14,36 @@ export function hasDesktopWindows(): boolean {
return typeof window !== 'undefined' && !!window.bulwarkDesktop?.windows;
}
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
export function popoutsAvailable(): boolean {
/** A second window can share this one's session, safely. */
function windowsCanShareSessions(): boolean {
// Lite has no such route: a static export can't list every message id.
if (IS_LITE || typeof window === 'undefined') return false;
// Sharing the session and renewing tokens safely across windows need these.
if (typeof BroadcastChannel === 'undefined' || !navigator.locks || !canSealSessions()) return false;
return typeof BroadcastChannel !== 'undefined' && !!navigator.locks && canSealSessions();
}
/** A device with a mouse or trackpad: a phone or tablet has no second window. */
function hasFinePointer(): boolean {
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
}
/** Display mode is `browser` in Electron popups too, hence the bridge check. */
export function popoutsAvailable(): boolean {
if (!windowsCanShareSessions()) return false;
if (hasDesktopWindows()) return true;
if (!isStandaloneDisplayMode()) return false;
// A home-screen app on a phone or tablet has no second window: window.open
// leaves for the browser there.
if ((window.navigator as Navigator & { standalone?: boolean }).standalone === true) return false;
return typeof window.matchMedia === 'function' && window.matchMedia('(pointer: fine)').matches;
return hasFinePointer();
}
/**
* A composer can move to a window of its own: wherever a message can, and in
* a browser tab on a computer too, where it opens as a popup window.
*/
export function composerPopoutsAvailable(): boolean {
if (popoutsAvailable()) return true;
if (!windowsCanShareSessions() || isStandaloneDisplayMode()) return false;
return hasFinePointer();
}
+38 -2
View File
@@ -20,6 +20,36 @@ export interface QuoteHeaderLabels {
fromLabel: string;
dateLabel: string;
subjectLabel: string;
/** The forward header's To and Cc lines; left out when not given. */
toLabel?: string;
ccLabel?: string;
}
type HeaderAddress = { email?: string; name?: string };
/** "Name <address>, address", as the forward header's From line writes one. */
function formatAddressList(list: HeaderAddress[] | undefined): string {
return (list ?? [])
.filter((r) => r.email)
.map((r) => (r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email as string))
.join(", ");
}
/**
* The To and Cc lines of a forward header, as Gmail and Outlook write them:
* one per list that has anyone on it, for a label the caller gave. Values
* are plain text; escape them for HTML.
*/
export function forwardRecipientLines(
email: { to?: HeaderAddress[]; cc?: HeaderAddress[] },
labels: { toLabel?: string; ccLabel?: string },
): { label: string; value: string }[] {
const lines: { label: string; value: string }[] = [];
const to = formatAddressList(email.to);
const cc = formatAddressList(email.cc);
if (labels.toLabel && to) lines.push({ label: labels.toLabel, value: to });
if (labels.ccLabel && cc) lines.push({ label: labels.ccLabel, value: cc });
return lines;
}
interface BuildArgs {
@@ -49,6 +79,8 @@ const DEFAULT_LABELS: QuoteHeaderLabels = {
fromLabel: "From",
dateLabel: "Date",
subjectLabel: "Subject",
toLabel: "To",
ccLabel: "Cc",
};
function defaultHeader(args: BuildArgs): QuoteHeader {
@@ -73,12 +105,16 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
const subject = email.subject || "";
if (mode === "forward") {
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
const recipients = forwardRecipientLines(email, labels);
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`
+ recipients.map((line) => `${line.label}: ${line.value}\n`).join("");
// Escape the interpolated values for the HTML variant: the sender string is
// "Name <email>", and the unescaped "<email>" would be parsed as an HTML tag
// by the rich-text composer and silently dropped (#482). Subject/name are
// likewise user-controlled. Label/separator strings are trusted i18n text.
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br>`
+ recipients.map((line) => `${line.label}: ${escapeHtml(line.value)}<br>`).join("")
+ `<br></div>`;
return { html, text, wrapInBlockquote: false };
}
+4 -4
View File
@@ -271,7 +271,7 @@ const ENTRIES: SettingSchemaEntry[] = [
opts(`${EB}mention_adds_recipient.`, ['to', 'cc', 'off'])),
bool('rtlEditingSupport', 'composing', `${EB}rtl_editing.label`),
bool('quickReplyEnabled', 'composing', `${EB}quick_reply.label`),
bool('autoSelectReplyIdentity', 'composing', `${EB}auto_select_reply_identity.label`),
bool('autoSelectReplyIdentity', 'composing', 'compose_words.reply_from_received.label'),
// The page shows it only while the composer may rewrite From.
choice('replyIdentityMatch', 'composing', `${EB}reply_identity_match.label`,
opts(`${EB}reply_identity_match.`, ['exact', 'domain']), { gate: 'customFromEnabled' }),
@@ -280,12 +280,12 @@ const ENTRIES: SettingSchemaEntry[] = [
bool('signatureNewMailEnabled', 'composing', `${EB}signature_new_mail.label`),
bool('signatureReplyForwardEnabled', 'composing', `${EB}signature_reply_forward.label`),
bool('signatureSeparatorEnabled', 'composing', `${EB}signature_separator.label`),
// 10/30/60 read "{seconds} seconds" (send_delay.seconds): formatted.
choice('sendDelaySeconds', 'composing', `${EB}send_delay.label`, [
// 5/10/30 read "{seconds} seconds" (send_delay.seconds): formatted.
choice('sendDelaySeconds', 'composing', 'compose_words.undo_send.label', [
{ value: 0, label: `${EB}send_delay.off` },
{ value: 5, label: null },
{ value: 10, label: null },
{ value: 30, label: null },
{ value: 60, label: null },
]),
bool('sendConfirmation', 'accessibility', `${A}confirm_send.label`),
bool('attachmentReminderEnabled', 'composing', `${EB}attachment_reminder.label`),
+2 -2
View File
@@ -239,13 +239,13 @@ export const tabSearchEntries: Record<Tab, SettingsSearchEntry[]> = {
{ label: 'proofreading.settings.writing_language' },
{ label: 'settings.composing_page.sections.replying' },
{ label: 'settings.email_behavior.quick_reply.label' },
{ label: 'settings.email_behavior.auto_select_reply_identity.label' },
{ label: 'compose_words.reply_from_received.label' },
{ label: 'settings.email_behavior.signature_position.label' },
{ label: 'settings.email_behavior.signature_new_mail.label' },
{ label: 'settings.email_behavior.signature_reply_forward.label' },
{ label: 'settings.email_behavior.signature_separator.label', advanced: true },
{ label: 'settings.composing_page.sections.sending' },
{ label: 'settings.email_behavior.send_delay.label' },
{ label: 'compose_words.undo_send.label' },
{ label: 'settings.email_behavior.attachment_reminder.label' },
{ label: 'settings.email_behavior.empty_subject_warning.label' },
{ label: 'settings.email_behavior.placeholder_warning.label' },
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[صورة: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "تم تجاهل المسودة",
"undo": "تراجع",
"discard_draft": "تجاهل المسودة",
"save_failed_close": "تعذّر حفظ المسودة. حاول مرة أخرى أو تجاهلها.",
"try_again": "حاول مرة أخرى",
"add_description": "إضافة وصف",
"attachments_summary": "المرفقات: {count}، {size}",
"from_sub_address": "استخدام عنوان فرعي…",
"from_edit_sender": "تعديل المرسل…",
"shortcut_insert_link": "إدراج رابط",
"shortcut_save_draft": "حفظ المسودة",
"shortcut_show_cc": "إظهار نسخة",
"shortcut_show_bcc": "إظهار نسخة مخفية"
},
"confirm_dialog": {
"confirm": "تأكيد",
"cancel": "إلغاء"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "العودة إلى التعديل",
"send_anyway": "الإرسال على أي حال",
"warnings_title": "هل تريد إرسال هذه الرسالة على أي حال؟",
"warning_subject": "بلا موضوع",
"warning_text": "بلا نص",
"warning_attachment": "تذكر \"{keyword}\" لكن لا يوجد ملف مرفق",
"warning_placeholders": "نص مؤقت سيراه المستلمون:",
"warning_proofreading": "مشكلات إملائية أو نحوية مفتوحة: {count}",
"empty_text_title": "هل تريد الإرسال بدون نص؟",
"empty_text_message": "لا تحتوي هذه الرسالة على نص ولا على مرفق. هل تريد إرسالها على أي حال؟",
"scheduled_for": "مجدولة في {when}",
"sending": "جارٍ الإرسال…",
"sent": "تم الإرسال",
"view": "عرض",
"picker_button": "إضافة من جهات الاتصال",
"picker_title": "إضافة مستلمين",
"tab_contacts": "جهات الاتصال",
"tab_directory": "الدليل",
"contacts_empty_title": "لا توجد جهات اتصال بعد",
"contacts_empty_hint": "يظهر هنا الأشخاص الذين تحفظهم في جهات الاتصال.",
"contacts_no_match": "لا توجد جهة اتصال تطابق بحثك",
"template_name": "اسم القالب",
"template_edit_details": "تعديل التفاصيل…"
},
"org_templates": {
"section_personal": "قوالبي",
"section_organisation": "المؤسسة",
@@ -6684,6 +6724,24 @@
"not_running": "لم تُرسل. هناك فحص تشترطه مؤسستك لا يعمل حاليًا. حاول مرة أخرى بعد قليل.",
"stopped_working": "لم تُرسل. توقف فحص تشترطه مؤسستك عن العمل. أعد تحميل الصفحة. إذا لم يساعد ذلك، أبلغ المسؤول."
},
"compose_words": {
"undo_send": {
"label": "التراجع عن الإرسال",
"description": "يمنحك بضع ثوانٍ لاسترجاع الرسالة بعد الضغط على إرسال."
},
"reply_from_received": {
"label": "الرد من العنوان المستلَم إليه",
"description": "الرد من العنوان الذي أُرسلت إليه الرسالة. مفيد إذا كانت لديك عدة عناوين."
},
"require_tls": "التسليم عبر اتصال آمن فقط",
"require_tls_hint": "الرسالة نفسها غير مشفّرة. تُرَد إذا لم يتمكن خادم من استخدام TLS.",
"encrypt": "تشفير…",
"encrypt_hint": "يتطلب إضافة S/MIME أو PGP. يفتح الإعدادات.",
"quick_reply_to": "الرد على {names}…",
"open_full_editor": "فتح المحرر الكامل",
"forward_to_label": "إلى",
"forward_cc_label": "نسخة"
},
"plugin_list": {
"filters": "المرشحات",
"remove_filter": "إزالة المرشح {label}"
@@ -7438,6 +7496,14 @@
"types_character": "تكتب هذه المفاتيح حرفًا على لوحة مفاتيحك، وسيتوقف عن العمل في كل التطبيقات. جرّب مفاتيح أخرى.",
"reserved": "يستخدم النظام أو معظم التطبيقات هذه المفاتيح. جرّب مفاتيح أخرى."
},
"compose_windows": {
"more_formatting": "مزيد من التنسيق",
"offline": "دون اتصال",
"offline_hint": "يحتاج الإرسال إلى اتصال.",
"offline_hint_outbox": "ما ترسله الآن ينتظر في صندوق الصادر ويُرسل عند عودة الاتصال.",
"offline_send_saved": "أنت دون اتصال. الرسالة محفوظة في المسودات.",
"offline_send_unsaved": "أنت دون اتصال. تبقى الرسالة هنا وتُحفظ في المسودات عند عودة الاتصال."
},
"desktopDevTools": {
"section_title": "استكشاف الأخطاء وإصلاحها",
"label": "أدوات المطورين ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Imatge: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Esborrany descartat",
"undo": "Desfés",
"discard_draft": "Descarta l'esborrany",
"save_failed_close": "No s'ha pogut desar l'esborrany. Torneu-ho a provar o descarteu-lo.",
"try_again": "Torna-ho a provar",
"add_description": "Afegeix una descripció",
"attachments_summary": "Adjunts: {count}, {size}",
"from_sub_address": "Fes servir una subadreça…",
"from_edit_sender": "Edita el remitent…",
"shortcut_insert_link": "Insereix un enllaç",
"shortcut_save_draft": "Desa l'esborrany",
"shortcut_show_cc": "Mostra Cc",
"shortcut_show_bcc": "Mostra Cco"
},
"confirm_dialog": {
"confirm": "Confirma",
"cancel": "Cancel·la"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Torna a l'edició",
"send_anyway": "Envia igualment",
"warnings_title": "Vols enviar aquest missatge igualment?",
"warning_subject": "Sense assumpte",
"warning_text": "Sense text",
"warning_attachment": "Esmenta «{keyword}», però no hi ha cap fitxer adjunt",
"warning_placeholders": "Text de mostra que veuran els destinataris:",
"warning_proofreading": "Errors d'ortografia o gramàtica pendents: {count}",
"empty_text_title": "Vols enviar-lo sense text?",
"empty_text_message": "Aquest missatge no té text ni cap fitxer adjunt. Vols enviar-lo igualment?",
"scheduled_for": "Programat per a {when}",
"sending": "S'està enviant…",
"sent": "Enviat",
"view": "Mostra",
"picker_button": "Afegeix des dels contactes",
"picker_title": "Afegeix destinataris",
"tab_contacts": "Contactes",
"tab_directory": "Directori",
"contacts_empty_title": "Encara no hi ha contactes",
"contacts_empty_hint": "Aquí apareixen les persones que deses a Contactes.",
"contacts_no_match": "Cap contacte coincideix amb la cerca",
"template_name": "Nom de la plantilla",
"template_edit_details": "Edita els detalls…"
},
"org_templates": {
"section_personal": "Les meves plantilles",
"section_organisation": "Organització",
@@ -6684,6 +6724,24 @@
"not_running": "No s'ha enviat. Una comprovació que exigeix la vostra organització no s'està executant. Torneu-ho a provar d'aquí a un moment.",
"stopped_working": "No s'ha enviat. Una comprovació que exigeix la vostra organització ha deixat de funcionar. Torneu a carregar la pàgina. Si no ho soluciona, aviseu l'administrador."
},
"compose_words": {
"undo_send": {
"label": "Desfés l'enviament",
"description": "Us dona uns segons per recuperar un missatge després de prémer Envia."
},
"reply_from_received": {
"label": "Respon des de l'adreça de recepció",
"description": "Respon des de l'adreça a la qual es va enviar el correu. Útil si teniu diverses adreces."
},
"require_tls": "Lliura només per una connexió segura",
"require_tls_hint": "El missatge en si no està xifrat. Es retorna si un servidor no pot fer servir TLS.",
"encrypt": "Xifra…",
"encrypt_hint": "Cal un complement S/MIME o PGP. Obre la configuració.",
"quick_reply_to": "Respon a {names}…",
"open_full_editor": "Obre l'editor complet",
"forward_to_label": "Per a",
"forward_cc_label": "CC"
},
"plugin_list": {
"filters": "Filtres",
"remove_filter": "Treu el filtre {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Aquestes tecles escriuen un caràcter al teu teclat, i deixaria de funcionar a totes les aplicacions. Prova altres tecles.",
"reserved": "El sistema o la majoria d'aplicacions fan servir aquestes tecles. Prova altres tecles."
},
"compose_windows": {
"more_formatting": "Més format",
"offline": "Sense connexió",
"offline_hint": "Per enviar cal connexió.",
"offline_hint_outbox": "El que envieu ara espera a la safata de sortida i s'envia quan torneu a tenir connexió.",
"offline_send_saved": "No teniu connexió. El missatge està desat a Esborranys.",
"offline_send_unsaved": "No teniu connexió. El missatge es queda aquí i es desa a Esborranys quan torneu a tenir connexió."
},
"desktopDevTools": {
"section_title": "Resolució de problemes",
"label": "Eines per a desenvolupadors ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Obrázek: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Koncept zahozen",
"undo": "Zpět",
"discard_draft": "Zahodit koncept",
"save_failed_close": "Koncept se nepodařilo uložit. Zkuste to znovu, nebo ho zahoďte.",
"try_again": "Zkusit znovu",
"add_description": "Přidat popis",
"attachments_summary": "Přílohy: {count}, {size}",
"from_sub_address": "Použít podadresu…",
"from_edit_sender": "Upravit odesílatele…",
"shortcut_insert_link": "Vložit odkaz",
"shortcut_save_draft": "Uložit koncept",
"shortcut_show_cc": "Zobrazit kopii",
"shortcut_show_bcc": "Zobrazit skrytou kopii"
},
"confirm_dialog": {
"confirm": "Potvrdit",
"cancel": "Zrušit"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Zpět k úpravám",
"send_anyway": "Přesto odeslat",
"warnings_title": "Přesto tuto zprávu odeslat?",
"warning_subject": "Bez předmětu",
"warning_text": "Bez textu",
"warning_attachment": "Zmiňuje „{keyword}“, ale není připojen žádný soubor",
"warning_placeholders": "Zástupný text, který příjemci uvidí:",
"warning_proofreading": "Nevyřešené pravopisné či gramatické chyby: {count}",
"empty_text_title": "Odeslat bez textu?",
"empty_text_message": "Tato zpráva nemá text ani přílohu. Přesto ji odeslat?",
"scheduled_for": "Naplánováno na {when}",
"sending": "Odesílání…",
"sent": "Odesláno",
"view": "Zobrazit",
"picker_button": "Přidat z kontaktů",
"picker_title": "Přidat příjemce",
"tab_contacts": "Kontakty",
"tab_directory": "Adresář organizace",
"contacts_empty_title": "Zatím žádné kontakty",
"contacts_empty_hint": "Zde se zobrazí lidé, které uložíte do Kontaktů.",
"contacts_no_match": "Hledání neodpovídá žádný kontakt",
"template_name": "Název šablony",
"template_edit_details": "Upravit podrobnosti…"
},
"org_templates": {
"section_personal": "Moje šablony",
"section_organisation": "Organizace",
@@ -6684,6 +6724,24 @@
"not_running": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, právě neběží. Zkuste to za chvíli znovu.",
"stopped_working": "Neodesláno. Kontrola, kterou vaše organizace vyžaduje, přestala fungovat. Načtěte stránku znovu. Pokud to nepomůže, obraťte se na správce."
},
"compose_words": {
"undo_send": {
"label": "Vrátit odeslání",
"description": "Dává vám několik sekund na to, abyste zprávu po stisknutí Odeslat vzali zpět."
},
"reply_from_received": {
"label": "Automaticky vybírat adresu pro odpověď",
"description": "Odpovídat z adresy, na kterou zpráva přišla. Hodí se, pokud máte více adres."
},
"require_tls": "Doručit jen přes zabezpečené spojení",
"require_tls_hint": "Samotná zpráva šifrovaná není. Vrátí se, pokud server nemůže použít TLS.",
"encrypt": "Šifrovat…",
"encrypt_hint": "Vyžaduje doplněk S/MIME nebo PGP. Otevře Nastavení.",
"quick_reply_to": "Odpověď pro {names}…",
"open_full_editor": "Otevřít v plném editoru",
"forward_to_label": "Komu",
"forward_cc_label": "Kopie"
},
"plugin_list": {
"filters": "Filtry",
"remove_filter": "Odebrat filtr {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Tyto klávesy na vaší klávesnici píší znak, který by pak nefungoval v žádné aplikaci. Zkuste jiné klávesy.",
"reserved": "Tyto klávesy používá systém nebo většina aplikací. Zkuste jiné klávesy."
},
"compose_windows": {
"more_formatting": "Další formátování",
"offline": "Offline",
"offline_hint": "Odeslání potřebuje připojení.",
"offline_hint_outbox": "Co teď odešlete, počká mezi zprávami k odeslání a odejde, až budete znovu online.",
"offline_send_saved": "Jste offline. Zpráva je uložená v Konceptech.",
"offline_send_unsaved": "Jste offline. Zpráva tu zůstane a uloží se do Konceptů, až budete znovu online."
},
"desktopDevTools": {
"section_title": "Řešení potíží",
"label": "Nástroje pro vývojáře ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Billede: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Kladde kasseret",
"undo": "Fortryd",
"discard_draft": "Kassér kladde",
"save_failed_close": "Kladden kunne ikke gemmes. Prøv igen, eller kassér den.",
"try_again": "Prøv igen",
"add_description": "Tilføj beskrivelse",
"attachments_summary": "Vedhæftede filer: {count}, {size}",
"from_sub_address": "Brug en underadresse…",
"from_edit_sender": "Rediger afsender…",
"shortcut_insert_link": "Indsæt link",
"shortcut_save_draft": "Gem kladde",
"shortcut_show_cc": "Vis Cc",
"shortcut_show_bcc": "Vis Bcc"
},
"confirm_dialog": {
"confirm": "Bekræft",
"cancel": "Annuller"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Tilbage til redigering",
"send_anyway": "Send alligevel",
"warnings_title": "Send denne besked alligevel?",
"warning_subject": "Intet emne",
"warning_text": "Ingen tekst",
"warning_attachment": "Nævner \"{keyword}\", men ingen fil er vedhæftet",
"warning_placeholders": "Pladsholdertekst, som modtagerne vil se:",
"warning_proofreading": "Åbne stave- eller grammatikfejl: {count}",
"empty_text_title": "Send uden tekst?",
"empty_text_message": "Denne besked har ingen tekst og ingen vedhæftet fil. Send den alligevel?",
"scheduled_for": "Planlagt til {when}",
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
"picker_button": "Tilføj fra kontakter",
"picker_title": "Tilføj modtagere",
"tab_contacts": "Kontakter",
"tab_directory": "Adressefortegnelse",
"contacts_empty_title": "Ingen kontakter endnu",
"contacts_empty_hint": "Personer, du gemmer i Kontakter, vises her.",
"contacts_no_match": "Ingen kontakter matcher din søgning",
"template_name": "Skabelonens navn",
"template_edit_details": "Rediger detaljer…"
},
"org_templates": {
"section_personal": "Mine skabeloner",
"section_organisation": "Organisation",
@@ -6684,6 +6724,24 @@
"not_running": "Ikke sendt. En kontrol, som din organisation kræver, kører ikke. Prøv igen om et øjeblik.",
"stopped_working": "Ikke sendt. En kontrol, som din organisation kræver, virker ikke længere. Genindlæs siden. Hvis det ikke hjælper, så kontakt din administrator."
},
"compose_words": {
"undo_send": {
"label": "Fortryd afsendelse",
"description": "Giver dig et par sekunder til at tage en besked tilbage, efter du har trykket på Send."
},
"reply_from_received": {
"label": "Svar fra modtaget adresse",
"description": "Svar fra den adresse, mailen blev sendt til. Nyttigt, hvis du har flere adresser."
},
"require_tls": "Lever kun via en sikker forbindelse",
"require_tls_hint": "Selve beskeden er ikke krypteret. Den sendes retur, hvis en server ikke kan bruge TLS.",
"encrypt": "Krypter…",
"encrypt_hint": "Kræver en S/MIME- eller PGP-tilføjelse. Åbner Indstillinger.",
"quick_reply_to": "Svar til {names}…",
"open_full_editor": "Åbn fuld editor",
"forward_to_label": "Til",
"forward_cc_label": "Cc"
},
"plugin_list": {
"filters": "Filtre",
"remove_filter": "Fjern filteret {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Disse taster skriver et tegn på dit tastatur, og det ville holde op med at virke i alle apps. Prøv andre taster.",
"reserved": "Dit system eller de fleste apps bruger disse taster. Prøv andre taster."
},
"compose_windows": {
"more_formatting": "Mere formatering",
"offline": "Offline",
"offline_hint": "Det kræver forbindelse at sende.",
"offline_hint_outbox": "Det, du sender nu, venter i udbakken og sendes, når du er online igen.",
"offline_send_saved": "Du er offline. Beskeden er gemt i Kladder.",
"offline_send_unsaved": "Du er offline. Beskeden bliver her og gemmes i Kladder, når du er online igen."
},
"desktopDevTools": {
"section_title": "Fejlfinding",
"label": "Udviklerværktøjer ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Bild: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Entwurf verworfen",
"undo": "Rückgängig",
"discard_draft": "Entwurf verwerfen",
"save_failed_close": "Der Entwurf konnte nicht gespeichert werden. Versuchen Sie es erneut oder verwerfen Sie ihn.",
"try_again": "Erneut versuchen",
"add_description": "Beschreibung hinzufügen",
"attachments_summary": "Anhänge: {count}, {size}",
"from_sub_address": "Unteradresse verwenden…",
"from_edit_sender": "Absender bearbeiten…",
"shortcut_insert_link": "Link einfügen",
"shortcut_save_draft": "Entwurf speichern",
"shortcut_show_cc": "Cc anzeigen",
"shortcut_show_bcc": "Bcc anzeigen"
},
"confirm_dialog": {
"confirm": "Bestätigen",
"cancel": "Abbrechen"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Zurück zur Bearbeitung",
"send_anyway": "Trotzdem senden",
"warnings_title": "Diese Nachricht trotzdem senden?",
"warning_subject": "Kein Betreff",
"warning_text": "Kein Text",
"warning_attachment": "Erwähnt „{keyword}“, aber keine Datei ist angehängt",
"warning_placeholders": "Platzhaltertext, den die Empfänger sehen:",
"warning_proofreading": "Offene Rechtschreib- oder Grammatikfehler: {count}",
"empty_text_title": "Ohne Text senden?",
"empty_text_message": "Diese Nachricht hat keinen Text und keinen Anhang. Trotzdem senden?",
"scheduled_for": "Geplant für {when}",
"sending": "Wird gesendet…",
"sent": "Gesendet",
"view": "Anzeigen",
"picker_button": "Aus Kontakten hinzufügen",
"picker_title": "Empfänger hinzufügen",
"tab_contacts": "Kontakte",
"tab_directory": "Verzeichnis",
"contacts_empty_title": "Noch keine Kontakte",
"contacts_empty_hint": "Personen, die Sie in Kontakten speichern, erscheinen hier.",
"contacts_no_match": "Kein Kontakt passt zu Ihrer Suche",
"template_name": "Name der Vorlage",
"template_edit_details": "Details bearbeiten…"
},
"org_templates": {
"section_personal": "Meine Vorlagen",
"section_organisation": "Organisation",
@@ -6684,6 +6724,24 @@
"not_running": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, läuft gerade nicht. Versuchen Sie es gleich noch einmal.",
"stopped_working": "Nicht gesendet. Eine Prüfung, die Ihre Organisation vorschreibt, funktioniert nicht mehr. Laden Sie die Seite neu. Wenn das nicht hilft, wenden Sie sich an Ihre Administration."
},
"compose_words": {
"undo_send": {
"label": "Senden rückgängig machen",
"description": "Gibt Ihnen ein paar Sekunden, um eine Nachricht nach dem Klick auf Senden zurückzuholen."
},
"reply_from_received": {
"label": "Antwortadresse automatisch wählen",
"description": "Die Antwort geht von der Adresse aus, an die die E-Mail ging. Nützlich, wenn Sie mehrere Adressen haben."
},
"require_tls": "Nur über eine sichere Verbindung zustellen",
"require_tls_hint": "Die Nachricht selbst wird nicht verschlüsselt. Sie kommt zurück, wenn ein Server kein TLS nutzen kann.",
"encrypt": "Verschlüsseln…",
"encrypt_hint": "Dafür brauchen Sie eine S/MIME- oder PGP-Erweiterung. Öffnet die Einstellungen.",
"quick_reply_to": "Antwort an {names}…",
"open_full_editor": "Im vollen Editor öffnen",
"forward_to_label": "An",
"forward_cc_label": "Cc"
},
"plugin_list": {
"filters": "Filter",
"remove_filter": "Filter {label} entfernen"
@@ -7438,6 +7496,14 @@
"types_character": "Diese Tasten erzeugen auf Ihrer Tastatur ein Zeichen, das dann in keiner App mehr funktionieren würde. Versuchen Sie andere Tasten.",
"reserved": "Ihr System oder die meisten Apps verwenden diese Tasten. Versuchen Sie andere Tasten."
},
"compose_windows": {
"more_formatting": "Weitere Formatierung",
"offline": "Offline",
"offline_hint": "Zum Senden ist eine Verbindung nötig.",
"offline_hint_outbox": "Was Sie jetzt senden, wartet im Postausgang und wird gesendet, sobald Sie wieder online sind.",
"offline_send_saved": "Sie sind offline. Die Nachricht ist in den Entwürfen gespeichert.",
"offline_send_unsaved": "Sie sind offline. Die Nachricht bleibt hier und wird in den Entwürfen gespeichert, sobald Sie wieder online sind."
},
"desktopDevTools": {
"section_title": "Fehlerbehebung",
"label": "Entwicklertools ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Image: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Draft discarded",
"undo": "Undo",
"discard_draft": "Discard draft",
"save_failed_close": "The draft could not be saved. Try again, or discard it.",
"try_again": "Try again",
"add_description": "Add description",
"attachments_summary": "Attachments: {count}, {size}",
"from_sub_address": "Use a sub-address…",
"from_edit_sender": "Edit sender…",
"shortcut_insert_link": "Insert link",
"shortcut_save_draft": "Save draft",
"shortcut_show_cc": "Show Cc",
"shortcut_show_bcc": "Show Bcc"
},
"confirm_dialog": {
"confirm": "Confirm",
"cancel": "Cancel"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Back to editing",
"send_anyway": "Send anyway",
"warnings_title": "Send this message anyway?",
"warning_subject": "No subject",
"warning_text": "No text",
"warning_attachment": "Mentions \"{keyword}\", but no file is attached",
"warning_placeholders": "Placeholder text that recipients will see:",
"warning_proofreading": "Open spelling or grammar issues: {count}",
"empty_text_title": "Send without text?",
"empty_text_message": "This message has no text and no attachment. Send it anyway?",
"scheduled_for": "Scheduled for {when}",
"sending": "Sending…",
"sent": "Sent",
"view": "View",
"picker_button": "Add from contacts",
"picker_title": "Add recipients",
"tab_contacts": "Contacts",
"tab_directory": "Directory",
"contacts_empty_title": "No contacts yet",
"contacts_empty_hint": "People you save in Contacts show up here.",
"contacts_no_match": "No contact matches your search",
"template_name": "Template name",
"template_edit_details": "Edit details…"
},
"org_templates": {
"section_personal": "My templates",
"section_organisation": "Organisation",
@@ -6684,6 +6724,24 @@
"not_running": "Not sent. A check your organisation requires isn't running. Try again in a moment.",
"stopped_working": "Not sent. A check your organisation requires has stopped working. Reload the page. If that doesn't help, tell your administrator."
},
"compose_words": {
"undo_send": {
"label": "Undo send",
"description": "Gives you a few seconds to take a message back after you press Send."
},
"reply_from_received": {
"label": "Reply from the address it was sent to",
"description": "Reply from the address the mail was sent to. Useful if you have several addresses."
},
"require_tls": "Only deliver over a secure connection",
"require_tls_hint": "The message itself isn't encrypted. It bounces if a server can't use TLS.",
"encrypt": "Encrypt…",
"encrypt_hint": "Needs an S/MIME or PGP add-on. Opens Settings.",
"quick_reply_to": "Reply to {names}…",
"open_full_editor": "Open full editor",
"forward_to_label": "To",
"forward_cc_label": "Cc"
},
"plugin_list": {
"filters": "Filters",
"remove_filter": "Remove filter {label}"
@@ -7438,6 +7496,14 @@
"types_character": "These keys type a character on your keyboard, and it would stop working in every app. Try other keys.",
"reserved": "Your system or most apps use these keys. Try other keys."
},
"compose_windows": {
"more_formatting": "More formatting",
"offline": "Offline",
"offline_hint": "Sending needs a connection.",
"offline_hint_outbox": "What you send now waits in the outbox and goes out when you're back online.",
"offline_send_saved": "You're offline. The message is saved in Drafts.",
"offline_send_unsaved": "You're offline. The message stays here and is saved in Drafts when you're back online."
},
"desktopDevTools": {
"section_title": "Troubleshooting",
"label": "Developer tools ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Imagen: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Borrador descartado",
"undo": "Deshacer",
"discard_draft": "Descartar borrador",
"save_failed_close": "No se pudo guardar el borrador. Inténtelo de nuevo o descártelo.",
"try_again": "Reintentar",
"add_description": "Añadir descripción",
"attachments_summary": "Adjuntos: {count}, {size}",
"from_sub_address": "Usar una subdirección…",
"from_edit_sender": "Editar remitente…",
"shortcut_insert_link": "Insertar enlace",
"shortcut_save_draft": "Guardar borrador",
"shortcut_show_cc": "Mostrar CC",
"shortcut_show_bcc": "Mostrar CCO"
},
"confirm_dialog": {
"confirm": "Confirmar",
"cancel": "Cancelar"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Volver a editar",
"send_anyway": "Enviar de todos modos",
"warnings_title": "¿Enviar este mensaje de todos modos?",
"warning_subject": "Sin asunto",
"warning_text": "Sin texto",
"warning_attachment": "Menciona «{keyword}», pero no hay ningún archivo adjunto",
"warning_placeholders": "Texto de marcador que verán los destinatarios:",
"warning_proofreading": "Errores de ortografía o gramática pendientes: {count}",
"empty_text_title": "¿Enviar sin texto?",
"empty_text_message": "Este mensaje no tiene texto ni archivos adjuntos. ¿Enviarlo de todos modos?",
"scheduled_for": "Programado para {when}",
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
"picker_button": "Añadir desde contactos",
"picker_title": "Añadir destinatarios",
"tab_contacts": "Contactos",
"tab_directory": "Directorio",
"contacts_empty_title": "Aún no hay contactos",
"contacts_empty_hint": "Aquí aparecen las personas que guardas en Contactos.",
"contacts_no_match": "Ningún contacto coincide con la búsqueda",
"template_name": "Nombre de la plantilla",
"template_edit_details": "Editar detalles…"
},
"org_templates": {
"section_personal": "Mis plantillas",
"section_organisation": "Organización",
@@ -6684,6 +6724,24 @@
"not_running": "No enviado. Una comprobación que exige tu organización no está en marcha. Vuelve a intentarlo en un momento.",
"stopped_working": "No enviado. Una comprobación que exige tu organización ha dejado de funcionar. Vuelve a cargar la página. Si no se soluciona, avisa al administrador."
},
"compose_words": {
"undo_send": {
"label": "Deshacer envío",
"description": "Le da unos segundos para recuperar un mensaje después de pulsar Enviar."
},
"reply_from_received": {
"label": "Seleccionar dirección de respuesta automáticamente",
"description": "Responder desde la dirección a la que se envió el correo. Útil si tiene varias direcciones."
},
"require_tls": "Entregar solo por una conexión segura",
"require_tls_hint": "El mensaje en sí no se cifra. Se devuelve si un servidor no puede usar TLS.",
"encrypt": "Cifrar…",
"encrypt_hint": "Necesita un complemento S/MIME o PGP. Abre la Configuración.",
"quick_reply_to": "Responder a {names}…",
"open_full_editor": "Abrir el editor completo",
"forward_to_label": "Para",
"forward_cc_label": "CC"
},
"plugin_list": {
"filters": "Filtros",
"remove_filter": "Quitar el filtro {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Estas teclas escriben un carácter en tu teclado, y dejaría de funcionar en todas las aplicaciones. Prueba otras teclas.",
"reserved": "Tu sistema o la mayoría de las aplicaciones usan estas teclas. Prueba otras teclas."
},
"compose_windows": {
"more_formatting": "Más formato",
"offline": "Sin conexión",
"offline_hint": "Para enviar hace falta conexión.",
"offline_hint_outbox": "Lo que envíes ahora espera en la bandeja de salida y se envía cuando vuelvas a tener conexión.",
"offline_send_saved": "No tienes conexión. El mensaje está guardado en Borradores.",
"offline_send_unsaved": "No tienes conexión. El mensaje se queda aquí y se guarda en Borradores cuando vuelvas a tener conexión."
},
"desktopDevTools": {
"section_title": "Solución de problemas",
"label": "Herramientas para desarrolladores ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Pilt: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Mustand loobutud",
"undo": "Võta tagasi",
"discard_draft": "Loobu mustandist",
"save_failed_close": "Mustandit ei saanud salvestada. Proovige uuesti või loobuge sellest.",
"try_again": "Proovi uuesti",
"add_description": "Lisa kirjeldus",
"attachments_summary": "Manused: {count}, {size}",
"from_sub_address": "Kasuta alamaadressi…",
"from_edit_sender": "Muuda saatjat…",
"shortcut_insert_link": "Lisa link",
"shortcut_save_draft": "Salvesta mustand",
"shortcut_show_cc": "Näita koopiat",
"shortcut_show_bcc": "Näita pimekoopiat"
},
"confirm_dialog": {
"confirm": "Kinnita",
"cancel": "Tühista"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Tagasi muutmise juurde",
"send_anyway": "Saada ikkagi",
"warnings_title": "Kas saata see kiri ikkagi?",
"warning_subject": "Teema puudub",
"warning_text": "Tekst puudub",
"warning_attachment": "Mainib sõna „{keyword}“, kuid ühtegi faili pole lisatud",
"warning_placeholders": "Kohatäitetekst, mida saajad näevad:",
"warning_proofreading": "Lahendamata õigekirja- või grammatikavead: {count}",
"empty_text_title": "Kas saata ilma tekstita?",
"empty_text_message": "Selles kirjas pole teksti ega manust. Kas saata see ikkagi?",
"scheduled_for": "Ajastatud: {when}",
"sending": "Saatmine…",
"sent": "Saadetud",
"view": "Vaata",
"picker_button": "Lisa kontaktidest",
"picker_title": "Lisa saajaid",
"tab_contacts": "Kontaktid",
"tab_directory": "Kataloog",
"contacts_empty_title": "Kontakte veel pole",
"contacts_empty_hint": "Siin kuvatakse inimesed, kelle salvestate kontaktidesse.",
"contacts_no_match": "Otsingule ei vasta ükski kontakt",
"template_name": "Malli nimi",
"template_edit_details": "Muuda üksikasju…"
},
"org_templates": {
"section_personal": "Minu mallid",
"section_organisation": "Organisatsioon",
@@ -6684,6 +6724,24 @@
"not_running": "Ei saadetud. Teie organisatsiooni nõutud kontroll praegu ei tööta. Proovige hetke pärast uuesti.",
"stopped_working": "Ei saadetud. Teie organisatsiooni nõutud kontroll ei tööta enam. Laadige leht uuesti. Kui see ei aita, teatage administraatorile."
},
"compose_words": {
"undo_send": {
"label": "Saatmise tühistamine",
"description": "Annab sulle pärast Saada vajutamist mõne sekundi, et kiri tagasi võtta."
},
"reply_from_received": {
"label": "Vasta kirja saanud aadressilt",
"description": "Vasta aadressilt, kuhu kiri saadeti. Kasulik, kui sul on mitu aadressi."
},
"require_tls": "Toimeta kohale ainult turvalise ühenduse kaudu",
"require_tls_hint": "Kiri ise ei ole krüpteeritud. See tuleb tagasi, kui server ei saa TLS-i kasutada.",
"encrypt": "Krüpteeri…",
"encrypt_hint": "Vajab S/MIME või PGP lisa. Avab seaded.",
"quick_reply_to": "Vastus saajale {names}…",
"open_full_editor": "Ava täielik redaktor",
"forward_to_label": "Saajale",
"forward_cc_label": "Koopia"
},
"plugin_list": {
"filters": "Filtrid",
"remove_filter": "Eemalda filter {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Need klahvid kirjutavad teie klaviatuuril märgi, mis lakkaks kõigis rakendustes töötamast. Proovige teisi klahve.",
"reserved": "Neid klahve kasutab süsteem või enamik rakendusi. Proovige teisi klahve."
},
"compose_windows": {
"more_formatting": "Rohkem vormindust",
"offline": "Võrguta",
"offline_hint": "Saatmiseks on vaja ühendust.",
"offline_hint_outbox": "Mida praegu saadad, ootab väljuvate kirjade kastis ja läheb teele, kui oled jälle võrgus.",
"offline_send_saved": "Oled võrguta. Kiri on salvestatud mustanditesse.",
"offline_send_unsaved": "Oled võrguta. Kiri jääb siia ja salvestatakse mustanditesse, kui oled jälle võrgus."
},
"desktopDevTools": {
"section_title": "Tõrkeotsing",
"label": "Arendaja tööriistad ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[تصویر: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "پیش‌نویس دور انداخته شد",
"undo": "واگرد",
"discard_draft": "دور انداختن پیش‌نویس",
"save_failed_close": "پیش‌نویس ذخیره نشد. دوباره تلاش کنید یا آن را دور بیندازید.",
"try_again": "تلاش دوباره",
"add_description": "افزودن توضیح",
"attachments_summary": "پیوست‌ها: {count}، {size}",
"from_sub_address": "استفاده از زیرنشانی…",
"from_edit_sender": "ویرایش فرستنده…",
"shortcut_insert_link": "درج پیوند",
"shortcut_save_draft": "ذخیرهٔ پیش‌نویس",
"shortcut_show_cc": "نمایش رونوشت",
"shortcut_show_bcc": "نمایش رونوشت پنهان"
},
"confirm_dialog": {
"confirm": "تأیید",
"cancel": "انصراف"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "بازگشت به ویرایش",
"send_anyway": "ارسال به هر حال",
"warnings_title": "این پیام به هر حال ارسال شود؟",
"warning_subject": "بدون موضوع",
"warning_text": "بدون متن",
"warning_attachment": "به «{keyword}» اشاره می‌کند، اما هیچ فایلی پیوست نشده است",
"warning_placeholders": "متن جایگزینی که گیرندگان خواهند دید:",
"warning_proofreading": "خطاهای املایی یا دستوری باز: {count}",
"empty_text_title": "بدون متن ارسال شود؟",
"empty_text_message": "این پیام متن و پیوست ندارد. به هر حال ارسال شود؟",
"scheduled_for": "زمان‌بندی‌شده برای {when}",
"sending": "در حال ارسال…",
"sent": "ارسال شد",
"view": "مشاهده",
"picker_button": "افزودن از مخاطبین",
"picker_title": "افزودن گیرندگان",
"tab_contacts": "مخاطبین",
"tab_directory": "فهرست سازمانی",
"contacts_empty_title": "هنوز مخاطبی وجود ندارد",
"contacts_empty_hint": "افرادی که در مخاطبین ذخیره می‌کنید اینجا نمایش داده می‌شوند.",
"contacts_no_match": "هیچ مخاطبی با جستجوی شما مطابقت ندارد",
"template_name": "نام قالب",
"template_edit_details": "ویرایش جزئیات…"
},
"org_templates": {
"section_personal": "قالب‌های من",
"section_organisation": "سازمان",
@@ -6684,6 +6724,24 @@
"not_running": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده در حال اجرا نیست. چند لحظه دیگر دوباره تلاش کنید.",
"stopped_working": "ارسال نشد. بررسی‌ای که سازمان شما الزامی کرده دیگر کار نمی‌کند. صفحه را دوباره بارگیری کنید. اگر کمکی نکرد، به مدیر سیستم خبر دهید."
},
"compose_words": {
"undo_send": {
"label": "لغو ارسال",
"description": "پس از زدن ارسال، چند ثانیه فرصت می‌دهد تا پیام را پس بگیرید."
},
"reply_from_received": {
"label": "پاسخ از آدرس دریافتی",
"description": "پاسخ از نشانی‌ای که نامه به آن فرستاده شده بود. اگر چند نشانی دارید مفید است."
},
"require_tls": "تحویل فقط از راه اتصال امن",
"require_tls_hint": "خود پیام رمزگذاری نمی‌شود. اگر سروری نتواند از TLS استفاده کند، برگشت می‌خورد.",
"encrypt": "رمزگذاری…",
"encrypt_hint": "به افزونهٔ S/MIME یا PGP نیاز دارد. تنظیمات را باز می‌کند.",
"quick_reply_to": "پاسخ به {names}…",
"open_full_editor": "باز کردن ویرایشگر کامل",
"forward_to_label": "به",
"forward_cc_label": "رونوشت"
},
"plugin_list": {
"filters": "فیلترها",
"remove_filter": "حذف فیلتر {label}"
@@ -7438,6 +7496,14 @@
"types_character": "این کلیدها روی صفحه‌کلید شما نویسه‌ای می‌نویسند و آن نویسه دیگر در هیچ برنامه‌ای کار نمی‌کند. کلیدهای دیگری را امتحان کنید.",
"reserved": "سیستم یا بیشتر برنامه‌ها از این کلیدها استفاده می‌کنند. کلیدهای دیگری را امتحان کنید."
},
"compose_windows": {
"more_formatting": "قالب‌بندی بیشتر",
"offline": "آفلاین",
"offline_hint": "ارسال به اتصال نیاز دارد.",
"offline_hint_outbox": "آنچه اکنون می‌فرستید در صندوق خروجی می‌ماند و وقتی دوباره آنلاین شدید فرستاده می‌شود.",
"offline_send_saved": "آفلاین هستید. پیام در پیش‌نویس‌ها ذخیره شده است.",
"offline_send_unsaved": "آفلاین هستید. پیام همین‌جا می‌ماند و وقتی دوباره آنلاین شدید در پیش‌نویس‌ها ذخیره می‌شود."
},
"desktopDevTools": {
"section_title": "عیب‌یابی",
"label": "ابزارهای توسعه‌دهنده ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Image : {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Brouillon supprimé",
"undo": "Annuler",
"discard_draft": "Supprimer le brouillon",
"save_failed_close": "Le brouillon n'a pas pu être enregistré. Réessayez ou supprimez-le.",
"try_again": "Réessayer",
"add_description": "Ajouter une description",
"attachments_summary": "Pièces jointes : {count}, {size}",
"from_sub_address": "Utiliser une sous-adresse…",
"from_edit_sender": "Modifier l'expéditeur…",
"shortcut_insert_link": "Insérer un lien",
"shortcut_save_draft": "Enregistrer le brouillon",
"shortcut_show_cc": "Afficher Cc",
"shortcut_show_bcc": "Afficher Cci"
},
"confirm_dialog": {
"confirm": "Confirmer",
"cancel": "Annuler"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Retour à l'édition",
"send_anyway": "Envoyer quand même",
"warnings_title": "Envoyer ce message quand même ?",
"warning_subject": "Pas d'objet",
"warning_text": "Pas de texte",
"warning_attachment": "Mentionne « {keyword} », mais aucun fichier n'est joint",
"warning_placeholders": "Texte de remplacement que les destinataires verront :",
"warning_proofreading": "Fautes d'orthographe ou de grammaire restantes : {count}",
"empty_text_title": "Envoyer sans texte ?",
"empty_text_message": "Ce message n'a ni texte ni pièce jointe. L'envoyer quand même ?",
"scheduled_for": "Programmé pour {when}",
"sending": "Envoi…",
"sent": "Envoyé",
"view": "Afficher",
"picker_button": "Ajouter depuis les contacts",
"picker_title": "Ajouter des destinataires",
"tab_contacts": "Contacts",
"tab_directory": "Annuaire",
"contacts_empty_title": "Aucun contact pour l'instant",
"contacts_empty_hint": "Les personnes enregistrées dans Contacts s'affichent ici.",
"contacts_no_match": "Aucun contact ne correspond à votre recherche",
"template_name": "Nom du modèle",
"template_edit_details": "Modifier les détails…"
},
"org_templates": {
"section_personal": "Mes modèles",
"section_organisation": "Organisation",
@@ -6684,6 +6724,24 @@
"not_running": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne pas. Réessayez dans un instant.",
"stopped_working": "Non envoyé. Une vérification exigée par votre organisation ne fonctionne plus. Rechargez la page. Si cela ne suffit pas, prévenez votre administrateur."
},
"compose_words": {
"undo_send": {
"label": "Annuler l'envoi",
"description": "Vous laisse quelques secondes pour reprendre un message après avoir cliqué sur Envoyer."
},
"reply_from_received": {
"label": "Sélection automatique de l'adresse de réponse",
"description": "Répondre depuis l'adresse à laquelle le message a été envoyé. Utile si vous avez plusieurs adresses."
},
"require_tls": "Livrer uniquement par une connexion sécurisée",
"require_tls_hint": "Le message lui-même n'est pas chiffré. Il est renvoyé si un serveur ne peut pas utiliser TLS.",
"encrypt": "Chiffrer…",
"encrypt_hint": "Nécessite une extension S/MIME ou PGP. Ouvre les Paramètres.",
"quick_reply_to": "Répondre à {names}…",
"open_full_editor": "Ouvrir l'éditeur complet",
"forward_to_label": "À",
"forward_cc_label": "Cc"
},
"plugin_list": {
"filters": "Filtres",
"remove_filter": "Retirer le filtre {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Ces touches produisent un caractère sur votre clavier, et il ne fonctionnerait plus dans aucune application. Essayez d'autres touches.",
"reserved": "Votre système ou la plupart des applications utilisent ces touches. Essayez d'autres touches."
},
"compose_windows": {
"more_formatting": "Plus de mise en forme",
"offline": "Hors ligne",
"offline_hint": "L'envoi nécessite une connexion.",
"offline_hint_outbox": "Ce que vous envoyez maintenant attend dans la boîte d'envoi et part dès que vous êtes de nouveau en ligne.",
"offline_send_saved": "Vous êtes hors ligne. Le message est enregistré dans les Brouillons.",
"offline_send_unsaved": "Vous êtes hors ligne. Le message reste ici et sera enregistré dans les Brouillons dès que vous serez de nouveau en ligne."
},
"desktopDevTools": {
"section_title": "Dépannage",
"label": "Outils de développement ({shortcut})",
+66
View File
@@ -1176,6 +1176,21 @@
"plain_text": "[תמונה: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "הטיוטה נמחקה",
"undo": "ביטול",
"discard_draft": "מחיקת הטיוטה",
"save_failed_close": "לא ניתן היה לשמור את הטיוטה. נסו שוב או מחקו אותה.",
"try_again": "ניסיון נוסף",
"add_description": "הוספת תיאור",
"attachments_summary": "קבצים מצורפים: {count}, {size}",
"from_sub_address": "שימוש בכתובת משנה…",
"from_edit_sender": "עריכת השולח…",
"shortcut_insert_link": "הוספת קישור",
"shortcut_save_draft": "שמירת טיוטה",
"shortcut_show_cc": "הצגת עותק",
"shortcut_show_bcc": "הצגת עותק מוסתר"
},
"confirm_dialog": {
"confirm": "אשר",
"cancel": "לְבַטֵל"
@@ -3920,6 +3935,31 @@
}
}
},
"compose_send": {
"back": "חזור לעריכה",
"send_anyway": "שלח בכל זאת",
"warnings_title": "לשלוח את ההודעה בכל זאת?",
"warning_subject": "אין נושא",
"warning_text": "אין טקסט",
"warning_attachment": "מוזכר \"{keyword}\", אבל לא צורף קובץ",
"warning_placeholders": "טקסט ממלא מקום שהנמענים יראו:",
"warning_proofreading": "שגיאות כתיב או דקדוק פתוחות: {count}",
"empty_text_title": "לשלוח ללא טקסט?",
"empty_text_message": "להודעה הזו אין טקסט ואין קובץ מצורף. לשלוח אותה בכל זאת?",
"scheduled_for": "מתוזמן ל-{when}",
"sending": "שולח…",
"sent": "נשלח",
"view": "הצג",
"picker_button": "הוסף מאנשי הקשר",
"picker_title": "הוספת נמענים",
"tab_contacts": "אנשי קשר",
"tab_directory": "ספרייה",
"contacts_empty_title": "אין עדיין אנשי קשר",
"contacts_empty_hint": "אנשים שתשמרו באנשי הקשר יופיעו כאן.",
"contacts_no_match": "אין איש קשר שתואם לחיפוש",
"template_name": "שם התבנית",
"template_edit_details": "עריכת פרטים…"
},
"org_templates": {
"section_personal": "התבניות שלי",
"section_organisation": "ארגון",
@@ -6685,6 +6725,24 @@
"not_running": "לא נשלח. בדיקה שהארגון שלך מחייב לא פועלת כרגע. נסו שוב בעוד רגע.",
"stopped_working": "לא נשלח. בדיקה שהארגון שלך מחייב הפסיקה לפעול. טענו את הדף מחדש. אם זה לא עוזר, פנו למנהל המערכת."
},
"compose_words": {
"undo_send": {
"label": "ביטול שליחה",
"description": "נותן לך כמה שניות להחזיר הודעה אחרי שלחצת על שלח."
},
"reply_from_received": {
"label": "בחירה אוטומטית בכתובת תשובה",
"description": "השב מהכתובת שאליה נשלח הדואר. שימושי אם יש לך כמה כתובות."
},
"require_tls": "מסירה רק דרך חיבור מאובטח",
"require_tls_hint": "ההודעה עצמה אינה מוצפנת. היא תוחזר אם שרת לא יכול להשתמש ב-TLS.",
"encrypt": "הצפנה…",
"encrypt_hint": "דורש תוסף S/MIME או PGP. פותח את ההגדרות.",
"quick_reply_to": "תשובה אל {names}…",
"open_full_editor": "פתיחה בעורך המלא",
"forward_to_label": "אל",
"forward_cc_label": "עותק"
},
"plugin_list": {
"filters": "מסננים",
"remove_filter": "הסר את המסנן {label}"
@@ -7439,6 +7497,14 @@
"types_character": "המקשים האלה מקלידים תו במקלדת שלכם, והוא יפסיק לעבוד בכל האפליקציות. נסו מקשים אחרים.",
"reserved": "המערכת או רוב האפליקציות משתמשות במקשים האלה. נסו מקשים אחרים."
},
"compose_windows": {
"more_formatting": "עוד עיצוב",
"offline": "אין חיבור",
"offline_hint": "כדי לשלוח צריך חיבור.",
"offline_hint_outbox": "מה ששולחים עכשיו ממתין בתיבת הדואר היוצא ונשלח כשחוזרים לרשת.",
"offline_send_saved": "אין חיבור. ההודעה שמורה בטיוטות.",
"offline_send_unsaved": "אין חיבור. ההודעה נשארת כאן ותישמר בטיוטות כשתחזרו לרשת."
},
"desktopDevTools": {
"section_title": "פתרון בעיות",
"label": "כלים למפתחים ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Kép: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Piszkozat elvetve",
"undo": "Visszavonás",
"discard_draft": "Piszkozat elvetése",
"save_failed_close": "A piszkozatot nem sikerült menteni. Próbálja újra, vagy vesse el.",
"try_again": "Újra",
"add_description": "Leírás hozzáadása",
"attachments_summary": "Mellékletek: {count}, {size}",
"from_sub_address": "Alcím használata…",
"from_edit_sender": "Feladó szerkesztése…",
"shortcut_insert_link": "Hivatkozás beszúrása",
"shortcut_save_draft": "Piszkozat mentése",
"shortcut_show_cc": "Másolat megjelenítése",
"shortcut_show_bcc": "Titkos másolat megjelenítése"
},
"confirm_dialog": {
"confirm": "Megerősítés",
"cancel": "Mégse"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Vissza a szerkesztéshez",
"send_anyway": "Küldés így is",
"warnings_title": "Elküldi így is ezt az üzenetet?",
"warning_subject": "Nincs tárgy",
"warning_text": "Nincs szöveg",
"warning_attachment": "Említi ezt: „{keyword}”, de nincs csatolt fájl",
"warning_placeholders": "Helykitöltő szöveg, amelyet a címzettek látni fognak:",
"warning_proofreading": "Nyitott helyesírási vagy nyelvtani hibák: {count}",
"empty_text_title": "Küldés szöveg nélkül?",
"empty_text_message": "Ennek az üzenetnek nincs szövege és melléklete. Elküldi így is?",
"scheduled_for": "Ütemezve: {when}",
"sending": "Küldés…",
"sent": "Elküldve",
"view": "Megtekintés",
"picker_button": "Hozzáadás a névjegyekből",
"picker_title": "Címzettek hozzáadása",
"tab_contacts": "Névjegyek",
"tab_directory": "Címtár",
"contacts_empty_title": "Még nincsenek névjegyek",
"contacts_empty_hint": "A névjegyekbe mentett személyek itt jelennek meg.",
"contacts_no_match": "Egy névjegy sem felel meg a keresésnek",
"template_name": "Sablon neve",
"template_edit_details": "Részletek szerkesztése…"
},
"org_templates": {
"section_personal": "Saját sablonok",
"section_organisation": "Szervezet",
@@ -6684,6 +6724,24 @@
"not_running": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés most nem fut. Próbálja újra egy pillanat múlva.",
"stopped_working": "Nincs elküldve. Egy, a szervezete által előírt ellenőrzés leállt. Töltse be újra az oldalt. Ha ez nem segít, szóljon a rendszergazdának."
},
"compose_words": {
"undo_send": {
"label": "Küldés visszavonása",
"description": "A Küldés megnyomása után néhány másodpercet ad az üzenet visszavonására."
},
"reply_from_received": {
"label": "Válasz a fogadott címről",
"description": "Válasz arról a címről, amelyre a levél érkezett. Hasznos, ha több címe van."
},
"require_tls": "Kézbesítés csak biztonságos kapcsolaton át",
"require_tls_hint": "Maga az üzenet nincs titkosítva. Visszapattan, ha egy szerver nem tud TLS-t használni.",
"encrypt": "Titkosítás…",
"encrypt_hint": "S/MIME vagy PGP bővítmény kell hozzá. Megnyitja a Beállításokat.",
"quick_reply_to": "Válasz neki: {names}…",
"open_full_editor": "Megnyitás a teljes szerkesztőben",
"forward_to_label": "Címzett",
"forward_cc_label": "Másolat"
},
"plugin_list": {
"filters": "Szűrők",
"remove_filter": "{label} szűrő eltávolítása"
@@ -7438,6 +7496,14 @@
"types_character": "Ezek a billentyűk karaktert írnak az Ön billentyűzetén, amely többé egyik alkalmazásban sem működne. Próbáljon más billentyűket.",
"reserved": "Ezeket a billentyűket a rendszer vagy a legtöbb alkalmazás használja. Próbáljon más billentyűket."
},
"compose_windows": {
"more_formatting": "További formázás",
"offline": "Offline",
"offline_hint": "A küldéshez kapcsolat kell.",
"offline_hint_outbox": "Amit most elküld, a postázandók között vár, és elmegy, amint újra online lesz.",
"offline_send_saved": "Offline állapotban van. Az üzenet a Piszkozatok között van mentve.",
"offline_send_unsaved": "Offline állapotban van. Az üzenet itt marad, és a Piszkozatok közé kerül, amint újra online lesz."
},
"desktopDevTools": {
"section_title": "Hibaelhárítás",
"label": "Fejlesztői eszközök ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[Immagine: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "Bozza eliminata",
"undo": "Annulla",
"discard_draft": "Elimina bozza",
"save_failed_close": "Impossibile salvare la bozza. Riprova o eliminala.",
"try_again": "Riprova",
"add_description": "Aggiungi descrizione",
"attachments_summary": "Allegati: {count}, {size}",
"from_sub_address": "Usa un sottoindirizzo…",
"from_edit_sender": "Modifica mittente…",
"shortcut_insert_link": "Inserisci link",
"shortcut_save_draft": "Salva bozza",
"shortcut_show_cc": "Mostra Cc",
"shortcut_show_bcc": "Mostra Ccn"
},
"confirm_dialog": {
"confirm": "Conferma",
"cancel": "Annulla"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "Torna alla modifica",
"send_anyway": "Invia comunque",
"warnings_title": "Inviare comunque questo messaggio?",
"warning_subject": "Nessun oggetto",
"warning_text": "Nessun testo",
"warning_attachment": "Menziona \"{keyword}\", ma non c'è nessun file allegato",
"warning_placeholders": "Testo segnaposto che i destinatari vedranno:",
"warning_proofreading": "Errori di ortografia o grammatica aperti: {count}",
"empty_text_title": "Inviare senza testo?",
"empty_text_message": "Questo messaggio non ha testo né allegati. Inviarlo comunque?",
"scheduled_for": "Programmato per {when}",
"sending": "Invio in corso…",
"sent": "Inviato",
"view": "Visualizza",
"picker_button": "Aggiungi dai contatti",
"picker_title": "Aggiungi destinatari",
"tab_contacts": "Contatti",
"tab_directory": "Rubrica aziendale",
"contacts_empty_title": "Ancora nessun contatto",
"contacts_empty_hint": "Le persone salvate in Contatti compaiono qui.",
"contacts_no_match": "Nessun contatto corrisponde alla ricerca",
"template_name": "Nome del modello",
"template_edit_details": "Modifica dettagli…"
},
"org_templates": {
"section_personal": "I miei modelli",
"section_organisation": "Organizzazione",
@@ -6684,6 +6724,24 @@
"not_running": "Non inviato. Un controllo richiesto dalla tua organizzazione non è attivo. Riprova tra un momento.",
"stopped_working": "Non inviato. Un controllo richiesto dalla tua organizzazione ha smesso di funzionare. Ricarica la pagina. Se non basta, avvisa l'amministratore."
},
"compose_words": {
"undo_send": {
"label": "Annulla invio",
"description": "Ti dà qualche secondo per riprendere un messaggio dopo aver premuto Invia."
},
"reply_from_received": {
"label": "Seleziona automaticamente l'indirizzo di risposta",
"description": "Rispondi dall'indirizzo a cui è stata inviata l'email. Utile se hai più indirizzi."
},
"require_tls": "Consegna solo tramite una connessione sicura",
"require_tls_hint": "Il messaggio in sé non è cifrato. Viene respinto se un server non può usare TLS.",
"encrypt": "Cifra…",
"encrypt_hint": "Serve un componente aggiuntivo S/MIME o PGP. Apre le Impostazioni.",
"quick_reply_to": "Rispondi a {names}…",
"open_full_editor": "Apri l'editor completo",
"forward_to_label": "A",
"forward_cc_label": "Cc"
},
"plugin_list": {
"filters": "Filtri",
"remove_filter": "Rimuovi il filtro {label}"
@@ -7438,6 +7496,14 @@
"types_character": "Questi tasti scrivono un carattere sulla tua tastiera, che smetterebbe di funzionare in tutte le app. Prova altri tasti.",
"reserved": "Il sistema o la maggior parte delle app usano questi tasti. Prova altri tasti."
},
"compose_windows": {
"more_formatting": "Altre opzioni di formattazione",
"offline": "Offline",
"offline_hint": "Per inviare serve una connessione.",
"offline_hint_outbox": "Quello che invii ora aspetta nella posta in uscita e parte quando torni online.",
"offline_send_saved": "Sei offline. Il messaggio è salvato nelle Bozze.",
"offline_send_unsaved": "Sei offline. Il messaggio resta qui e viene salvato nelle Bozze quando torni online."
},
"desktopDevTools": {
"section_title": "Risoluzione dei problemi",
"label": "Strumenti per sviluppatori ({shortcut})",
+66
View File
@@ -1233,6 +1233,21 @@
"plain_text": "[画像: {alt}]"
}
},
"compose_ux": {
"draft_discarded": "下書きを破棄しました",
"undo": "元に戻す",
"discard_draft": "下書きを破棄",
"save_failed_close": "下書きを保存できませんでした。もう一度試すか、破棄してください。",
"try_again": "再試行",
"add_description": "説明を追加",
"attachments_summary": "添付ファイル: {count} 件、{size}",
"from_sub_address": "サブアドレスを使用…",
"from_edit_sender": "差出人を編集…",
"shortcut_insert_link": "リンクを挿入",
"shortcut_save_draft": "下書きを保存",
"shortcut_show_cc": "Cc を表示",
"shortcut_show_bcc": "Bcc を表示"
},
"confirm_dialog": {
"confirm": "確認",
"cancel": "キャンセル"
@@ -4027,6 +4042,31 @@
}
}
},
"compose_send": {
"back": "編集に戻る",
"send_anyway": "そのまま送信",
"warnings_title": "このメッセージをそのまま送信しますか?",
"warning_subject": "件名がありません",
"warning_text": "本文がありません",
"warning_attachment": "「{keyword}」とありますが、ファイルが添付されていません",
"warning_placeholders": "受信者に表示されるプレースホルダー:",
"warning_proofreading": "未解決のスペル・文法の問題: {count}",
"empty_text_title": "本文なしで送信しますか?",
"empty_text_message": "このメッセージには本文も添付ファイルもありません。そのまま送信しますか?",
"scheduled_for": "{when} に送信予定",
"sending": "送信中…",
"sent": "送信しました",
"view": "表示",
"picker_button": "連絡先から追加",
"picker_title": "宛先を追加",
"tab_contacts": "連絡先",
"tab_directory": "ディレクトリ",
"contacts_empty_title": "連絡先はまだありません",
"contacts_empty_hint": "連絡先に保存した人がここに表示されます。",
"contacts_no_match": "検索に一致する連絡先はありません",
"template_name": "テンプレート名",
"template_edit_details": "詳細を編集…"
},
"org_templates": {
"section_personal": "マイテンプレート",
"section_organisation": "組織",
@@ -6684,6 +6724,24 @@
"not_running": "送信されませんでした。組織で必須のチェックが動作していません。しばらくしてからもう一度お試しください。",
"stopped_working": "送信されませんでした。組織で必須のチェックが動作しなくなりました。ページを再読み込みしてください。解決しない場合は管理者に連絡してください。"
},
"compose_words": {
"undo_send": {
"label": "送信取り消し",
"description": "送信を押した後、数秒間メッセージを取り消せます。"
},
"reply_from_received": {
"label": "返信元アドレスを自動選択",
"description": "メールの宛先になったアドレスから返信します。複数のアドレスがある場合に便利です。"
},
"require_tls": "安全な接続でのみ配信",
"require_tls_hint": "メッセージ自体は暗号化されません。サーバーが TLS を使えない場合は返送されます。",
"encrypt": "暗号化…",
"encrypt_hint": "S/MIME または PGP のアドオンが必要です。設定を開きます。",
"quick_reply_to": "{names} に返信…",
"open_full_editor": "フルエディターで開く",
"forward_to_label": "宛先",
"forward_cc_label": "CC"
},
"plugin_list": {
"filters": "フィルター",
"remove_filter": "フィルター「{label}」を解除"
@@ -7438,6 +7496,14 @@
"types_character": "このキーはお使いのキーボードで文字を入力します。割り当てると、すべてのアプリでその文字を入力できなくなります。別のキーをお試しください。",
"reserved": "このキーはシステムまたは多くのアプリが使用しています。別のキーをお試しください。"
},
"compose_windows": {
"more_formatting": "その他の書式",
"offline": "オフライン",
"offline_hint": "送信には接続が必要です。",
"offline_hint_outbox": "今送信したものは送信トレイで待機し、オンラインに戻ると送信されます。",
"offline_send_saved": "オフラインです。メッセージは下書きに保存されています。",
"offline_send_unsaved": "オフラインです。メッセージはここに残り、オンラインに戻ると下書きに保存されます。"
},
"desktopDevTools": {
"section_title": "トラブルシューティング",
"label": "デベロッパー ツール ({shortcut})",

Some files were not shown because too many files have changed in this diff Show More