Merge branch 'feat/do-ux-compose-send' into feat/do-ux-compose-fixes

This commit is contained in:
Linus Rath
2026-11-04 07:41:26 +01:00
52 changed files with 2116 additions and 326 deletions
@@ -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">
@@ -370,17 +370,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();
});
});
@@ -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';
@@ -546,6 +547,22 @@ describe('composer with the @ recipient list', () => {
expect(to.value).toBe('Sarah Kim <sarah@example.com>');
});
// 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.
it('lets Escape in the directory picker leave the composer alone', async () => {
// Every other capability answers nothing.
@@ -329,6 +329,21 @@ describe('composer-send-menu', () => {
expect(follows(schedule, plugin)).toBe(true);
});
// 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();
@@ -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();
});
});
@@ -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();
@@ -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} />);
@@ -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>'
+161 -242
View File
@@ -55,6 +55,8 @@ import { matchesCombo } from "@/lib/plugin-sandbox/shortcuts";
import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils";
import { forwardRecipientLines } from "@/lib/quote-header";
import { ScheduleSendDialog } from "./schedule-send-dialog";
import { SendWarningsDialog, type SendWarning } from "./send-warnings-dialog";
import { QuickSaveTemplateDialog } from "@/components/templates/quick-save-template-dialog";
import { debug } from "@/lib/debug";
import { toast } from "@/stores/toast-store";
import { useContextMenu, isContextMenuKey } from "@/hooks/use-context-menu";
@@ -370,6 +372,11 @@ interface EmailComposerProps {
/** The id plugins know this composer by, for onAfterEmailSend. */
composerId?: string;
}) => void | false | Promise<void | false>;
/**
* onSend failed. A host that closes the composer as soon as onSend starts
* gets the message back here, to open it again: it must never vanish.
*/
onSendFailed?: (state: ComposerDraftData) => void;
onScheduledSendCreated?: () => void | Promise<void>;
onClose?: () => void;
/**
@@ -574,6 +581,7 @@ const OFFLINE_SEND_ERRORS: Record<string, string> = {
export function EmailComposer({
onSend,
onSendFailed,
onScheduledSendCreated,
onClose,
requestCloseRef,
@@ -601,7 +609,6 @@ export function EmailComposer({
const tViewer = useTranslations('email_viewer');
const tClassification = useTranslations('classification');
const tEdit = useTranslations('desktopEdit');
const tProofreading = useTranslations('proofreading');
const tOfflineDrafts = useTranslations('offlineDrafts');
const tOffline = useTranslations('offline');
const tOfflineNotes = useTranslations('offlineConflicts');
@@ -1197,6 +1204,9 @@ export function EmailComposer({
const [filesPicker, setFilesPicker] = useState<{ client: IJMAPClient; accountId?: string } | null>(null);
const [showDirectoryPicker, setShowDirectoryPicker] = useState(false);
const [showSaveAsTemplate, setShowSaveAsTemplate] = useState(false);
// Save as template asks for a name only; "Edit details…" opens the full
// editor with the name typed so far (null: the name dialog).
const [saveTemplateDetails, setSaveTemplateDetails] = useState<string | null>(null);
const [showCloseDialog, setShowCloseDialog] = useState(false);
// The formatting toolbar is folded away on a phone until asked for.
const [showFormattingOnMobile, setShowFormattingOnMobile] = useState(false);
@@ -1210,19 +1220,11 @@ export function EmailComposer({
const [closeDialogKind, setCloseDialogKind] = useState<'close' | 'discard'>('close');
const [showAllAttachments, setShowAllAttachments] = useState(false);
const [previewAttachment, setPreviewAttachment] = useState<ComposerAttachment | null>(null);
const [showAttachmentWarning, setShowAttachmentWarning] = useState(false);
const [attachmentWarningKeyword, setAttachmentWarningKeyword] = useState('');
const [attachmentWarningDelayedUntil, setAttachmentWarningDelayedUntil] = useState<string | undefined>();
const [showEmptySubjectWarning, setShowEmptySubjectWarning] = useState(false);
const [emptySubjectDelayedUntil, setEmptySubjectDelayedUntil] = useState<string | undefined>();
const [emptySubjectDontAskAgain, setEmptySubjectDontAskAgain] = useState(false);
const [showPlaceholderWarning, setShowPlaceholderWarning] = useState(false);
// A proofreading checker asked to warn before sending with open issues.
const [proofreadingWarning, setProofreadingWarning] = useState<{ count: number; delayedUntil?: string } | null>(null);
// What the checks before sending found (no subject, no text, placeholders,
// a missing attachment, open proofreading issues), asked about in one dialog.
const [sendWarnings, setSendWarnings] = useState<{ items: SendWarning[]; delayedUntil?: string } | null>(null);
// Open proofreading issues, once a checker asked to warn about them.
const proofreadingConfirmedRef = useRef(false);
const [placeholderWarningMatches, setPlaceholderWarningMatches] = useState<string[]>([]);
const [placeholderWarningDelayedUntil, setPlaceholderWarningDelayedUntil] = useState<string | undefined>();
const [placeholderDontAskAgain, setPlaceholderDontAskAgain] = useState(false);
const [showScheduleDialog, setShowScheduleDialog] = useState(false);
// Asks before sending at once when the account can't hold a message back.
const { dialogProps: noDelayDialogProps, confirm: confirmNoDelay } = useConfirmDialog();
@@ -1243,6 +1245,7 @@ export function EmailComposer({
if (!(await confirmDiscardTemplate(saveTemplateDirty))) return;
setSaveTemplateDirty(false);
setShowSaveAsTemplate(false);
setSaveTemplateDetails(null);
};
const { client: activeClient } = useAuthStore();
@@ -1786,6 +1789,9 @@ export function EmailComposer({
// Whether a Sent mailbox is known so the on-demand server search is worth
// offering (falls back to hiding the "search the server" row otherwise).
const canSearchServer = useContactStore((s) => s.sentMailboxId != null);
// The recipient picker: the user's contacts, and the server's directory.
const hasContacts = useContactStore((s) => (s.contacts?.length ?? 0) > 0);
const tSend = useTranslations('compose_send');
const addToTrustedSendersBook = useContactStore((s) => s.addToTrustedSendersBook);
const addTrustedSender = useSettingsStore((s) => s.addTrustedSender);
const trustedSendersAddressBook = useSettingsStore((s) => s.trustedSendersAddressBook);
@@ -2810,6 +2816,13 @@ export function EmailComposer({
// Plugin entries make the Send and Attach buttons menus even where the app
// itself has nothing else to put there.
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
// With nothing but "Schedule send" in Send's menu, the arrow opens the
// schedule dialog itself: a menu with one item was one click too many.
const sendArrowSchedules = !hasSendMenuActions && !!composerClient?.hasDelayedSend();
const handleSendArrow = (e: React.MouseEvent<HTMLElement>) => {
if (sendArrowSchedules) openScheduleDialog();
else toggleSendMenu(e);
};
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
// Ticked plugin options count in the Options button's badge like the app's own.
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
@@ -3604,14 +3617,14 @@ export function EmailComposer({
const bodyPlainText = plainTextMode ? body.trim() : htmlToPlainText(body).trim();
const hasContent = bodyPlainText || attachments.some(att => att.blobId && !att.uploading);
// A missing subject no longer blocks Send (#684) - users hit the disabled
// button without understanding why. It is confirmed in a dialog instead.
const canSend = toAddresses.length > 0 && hasContent;
// button without understanding why. It is confirmed in a dialog instead,
// and so is a message with no text and no file.
const canSend = toAddresses.length > 0;
const getSendTooltip = (): string | undefined => {
if (isWaitingForUploads) return t('validation.attachments_uploading');
if (canSend) return undefined;
if (toAddresses.length === 0) return t('validation.recipient_required');
if (!hasContent) return t('validation.body_required');
return undefined;
};
@@ -3706,6 +3719,8 @@ export function EmailComposer({
const confirmingSendRef = useRef(false);
// The user confirmed sending without a subject for this message.
const emptySubjectConfirmedRef = useRef(false);
// ... and without text.
const emptyTextConfirmedRef = useRef(false);
// Placeholders the user chose to send as they are. The warning comes back
// only for one that is not in here.
const confirmedPlaceholdersRef = useRef<Set<string>>(new Set());
@@ -3750,9 +3765,9 @@ export function EmailComposer({
else editor.commands.focus();
};
const handleSend = async ({ skipAttachmentCheck = false, skipSubjectCheck = false, delayedUntil }: {
skipAttachmentCheck?: boolean;
skipSubjectCheck?: boolean;
const handleSend = async ({ warningsConfirmed = false, delayedUntil }: {
/** "Send anyway" in the warnings dialog: don't check those again. */
warningsConfirmed?: boolean;
delayedUntil?: string;
} = {}) => {
if (isSendingRef.current) return;
@@ -3800,7 +3815,6 @@ export function EmailComposer({
if (!canSend) {
const errors: { to?: boolean; body?: boolean } = {};
if (toAddresses.length === 0) errors.to = true;
if (!hasContent) errors.body = true;
setValidationErrors(errors);
if (errors.to) {
@@ -3839,69 +3853,69 @@ export function EmailComposer({
return;
}
// Empty subject confirmation (#684). Asked once per message: after "Send
// anyway" and a trip back to add an attachment, it asked again.
if (!skipSubjectCheck && !emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
setEmptySubjectDontAskAgain(false);
setEmptySubjectDelayedUntil(delayedUntil);
setShowEmptySubjectWarning(true);
return;
}
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
// [TIME] from AI drafts, and markers like TODO. The signature is left
// out, or a placeholder in it would stop every message.
if (placeholderWarningEnabled) {
const authored = plainTextMode
? plainTextBodyWithoutSignature(body, signatureIdentity)
: stripEmbeddedSignatureHtml(body);
const bodyText = extractUserAuthoredText(authored, {
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
// The fill-in words of the UI language, and of English: AI drafts and
// templates are often English whatever the UI language is.
const found = findUnfilledPlaceholders(subject, bodyText, [
enMessages.email_composer.placeholder_words,
t.raw('placeholder_words'),
]);
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
setPlaceholderWarningMatches(found);
setPlaceholderDontAskAgain(false);
setPlaceholderWarningDelayedUntil(delayedUntil);
setShowPlaceholderWarning(true);
return;
// What the user may have missed, asked about in one dialog (two in a
// row used to ask about each in turn). Answered with "Send anyway", the
// subject, the text, the placeholders listed and the proofreading issues
// aren't asked about again for this message.
if (!warningsConfirmed) {
const warnings: SendWarning[] = [];
// Empty subject confirmation (#684).
if (!emptySubjectConfirmedRef.current && emptySubjectWarningEnabled && !subject.trim()) {
warnings.push({ kind: 'subject' });
}
// No text and no file: Send stays enabled and asks instead.
if (!emptyTextConfirmedRef.current && !hasContent) {
warnings.push({ kind: 'text' });
}
}
// Attachment reminder check
if (!skipAttachmentCheck && attachmentReminderEnabled) {
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
// Scan only the user-authored text: the quoted original of a
// reply/forward often mentions an attachment itself, which used to fire
// the reminder even when the user typed no keyword and added nothing (#570).
const bodyText = extractUserAuthoredText(body, {
// Unfilled placeholders: template fields left as {{name}}, fill-ins like
// [TIME] from AI drafts, and markers like TODO. The signature is left
// out, or a placeholder in it would stop every message.
if (placeholderWarningEnabled) {
const authored = plainTextMode
? plainTextBodyWithoutSignature(body, signatureIdentity)
: stripEmbeddedSignatureHtml(body);
const bodyText = extractUserAuthoredText(authored, {
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) {
setAttachmentWarningKeyword(matched);
setAttachmentWarningDelayedUntil(delayedUntil);
setShowAttachmentWarning(true);
return;
// The fill-in words of the UI language, and of English: AI drafts and
// templates are often English whatever the UI language is.
const found = findUnfilledPlaceholders(subject, bodyText, [
enMessages.email_composer.placeholder_words,
t.raw('placeholder_words'),
]);
if (found.some(p => !confirmedPlaceholdersRef.current.has(p))) {
warnings.push({ kind: 'placeholders', matches: found });
}
}
}
// Open proofreading issues, when a checker asked for the warning (the
// LanguageTool plugin's "warn before sending" option). Once per message.
if (!proofreadingConfirmedRef.current) {
const open = proofreading.sendWarningIssues().length;
if (open > 0) {
setProofreadingWarning({ count: open, delayedUntil });
// Attachment reminder check
if (attachmentReminderEnabled) {
const hasAttachments = attachmentsRef.current.some(att => att.blobId && !att.uploading && !att.error);
if (!hasAttachments) {
// Scan only the user-authored text: the quoted original of a
// reply/forward often mentions an attachment itself, which used to fire
// the reminder even when the user typed no keyword and added nothing (#570).
const bodyText = extractUserAuthoredText(body, {
plainTextMode,
forwardedSeparator: tQuote('forwarded_separator'),
});
const searchText = `${subject} ${bodyText}`.toLowerCase();
const matched = attachmentReminderKeywords.find(kw => searchText.includes(kw.toLowerCase()));
if (matched) warnings.push({ kind: 'attachment', keyword: matched });
}
}
// Open proofreading issues, when a checker asked for the warning (the
// LanguageTool plugin's "warn before sending" option).
if (!proofreadingConfirmedRef.current) {
const open = proofreading.sendWarningIssues().length;
if (open > 0) warnings.push({ kind: 'proofreading', count: open });
}
if (warnings.length > 0) {
setSendWarnings({ items: warnings, delayedUntil });
return;
}
}
@@ -4061,6 +4075,9 @@ export function EmailComposer({
const lastStage = (html: string | undefined, text: string) =>
finalizeOutgoing({ html: html || undefined, text }, outgoingContext, 'last');
const inlineAttachments = rewritten?.attachments ?? [];
// The message as it is now, once onSend has it: the host may close the
// composer before the send is through.
let handedToHost: ComposerDraftData | null = null;
try {
const effectiveDelayedUntil = await resolveDelayedUntil(delayedUntil);
@@ -4260,6 +4277,13 @@ export function EmailComposer({
// Read when the close is reported: the host tears the composer down
// while onSend is still running. Reset below if the send fails.
sentRef.current = true;
handedToHost = {
...snapshotStateRef.current(),
// The save before the send may have moved the draft to a new id.
draftId: draftIdRef.current,
draftAccountId: draftAccountIdRef.current,
draftLocalAccountId: draftLocalAccountIdRef.current,
};
const outcome = await onSend?.({
to: outgoing.to,
cc: outgoing.cc,
@@ -4335,6 +4359,8 @@ export function EmailComposer({
explicitCloseRef.current = false;
sentRef.current = false;
debug.error('Failed to send email:', err);
// A host that closed the composer at Send opens it again with the text.
if (handedToHost) onSendFailed?.(handedToHost);
if (isLabelRequiredError(err)) {
setLabelInvalid(true);
toast.error(tClassification('required_toast'));
@@ -4660,9 +4686,7 @@ export function EmailComposer({
filesPicker ||
showSaveAsTemplate ||
showScheduleDialog ||
showAttachmentWarning ||
showEmptySubjectWarning ||
showPlaceholderWarning ||
sendWarnings ||
showCloseDialog
) return;
@@ -4868,14 +4892,14 @@ export function EmailComposer({
<Button
type="button"
onMouseDown={(e) => { if (showSendMenu) e.stopPropagation(); }}
onClick={toggleSendMenu}
onClick={handleSendArrow}
disabled={!canSend || isSending || isSavingAndClosing}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
size="sm"
className="h-11 min-w-11 rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
>
<ChevronDown className="w-4 h-4" />
</Button>
@@ -5084,15 +5108,15 @@ export function EmailComposer({
onMoveChip={handleMoveChip}
/>
<div className="flex gap-0.5 shrink-0">
{activeClient?.supportsPrincipals?.() && (
{(hasContacts || activeClient?.supportsPrincipals?.()) && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowDirectoryPicker(true)}
className="h-7 w-7 p-0"
title={t('select_from_directory')}
aria-label={t('select_from_directory')}
title={tSend('picker_button')}
aria-label={tSend('picker_button')}
data-testid="composer-directory-picker"
>
<BookUser className="w-4 h-4" />
@@ -5750,13 +5774,13 @@ export function EmailComposer({
<Button
type="button"
onMouseDown={(e) => { if (showSendMenu) e.stopPropagation(); }}
onClick={toggleSendMenu}
onClick={handleSendArrow}
disabled={!canSend || isSending || isSavingAndClosing}
title={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
aria-label={composerClient?.hasDelayedSend() ? t('schedule_send') : tViewer('more_actions')}
className="rounded-s-none px-2"
aria-haspopup="menu"
aria-expanded={showSendMenu}
aria-haspopup={sendArrowSchedules ? 'dialog' : 'menu'}
aria-expanded={sendArrowSchedules ? undefined : showSendMenu}
>
<ChevronDown className="w-4 h-4" />
</Button>
@@ -5802,7 +5826,18 @@ export function EmailComposer({
/>
)}
{showSaveAsTemplate && (
{showSaveAsTemplate && saveTemplateDetails === null && (
<QuickSaveTemplateDialog
defaultName={subject.trim()}
onClose={() => setShowSaveAsTemplate(false)}
onEditDetails={(name) => setSaveTemplateDetails(name)}
onSave={(name) => {
addTemplate({ name, subject, body, isHTML: !plainTextMode, category: '', isFavorite: false });
setShowSaveAsTemplate(false);
}}
/>
)}
{showSaveAsTemplate && saveTemplateDetails !== null && (
<Dialog
isOpen
onClose={() => void closeSaveAsTemplate()}
@@ -5815,6 +5850,7 @@ export function EmailComposer({
<TemplateForm
categorySource={composingPersonalTemplates}
initialData={{
name: saveTemplateDetails,
subject,
body,
isHTML: !plainTextMode,
@@ -5826,6 +5862,7 @@ export function EmailComposer({
addTemplate(data);
setSaveTemplateDirty(false);
setShowSaveAsTemplate(false);
setSaveTemplateDetails(null);
}}
onCancel={() => void closeSaveAsTemplate()}
onDirtyChange={setSaveTemplateDirty}
@@ -5856,163 +5893,45 @@ export function EmailComposer({
disabled={!canSend || isSending || isSavingAndClosing}
/>
<Dialog
isOpen={showAttachmentWarning}
onClose={() => setShowAttachmentWarning(false)}
role="alertdialog"
showClose={false}
title={t('forgot_attachment.title')}
description={t('forgot_attachment.message', { keyword: attachmentWarningKeyword })}
footer={
<>
<Button variant="outline" onClick={() => setShowAttachmentWarning(false)}>
{t('forgot_attachment.back')}
</Button>
<Button onClick={() => { setShowAttachmentWarning(false); handleSend({ skipAttachmentCheck: true, skipSubjectCheck: true, delayedUntil: attachmentWarningDelayedUntil }); setAttachmentWarningDelayedUntil(undefined); }}>
{t('forgot_attachment.send_anyway')}
</Button>
</>
}
/>
<Dialog
isOpen={showEmptySubjectWarning}
onClose={() => setShowEmptySubjectWarning(false)}
role="alertdialog"
showClose={false}
title={t('empty_subject.title')}
description={t('empty_subject.message')}
footer={
<>
<Button
variant="outline"
onClick={() => {
setShowEmptySubjectWarning(false);
setEmptySubjectDelayedUntil(undefined);
// After the focus trap tears down it restores focus to the
// Send button, so claim the subject field on the next tick.
setTimeout(() => subjectInputRef.current?.focus(), 0);
}}
>
{t('empty_subject.back')}
</Button>
<Button
onClick={() => {
if (emptySubjectDontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
emptySubjectConfirmedRef.current = true;
setShowEmptySubjectWarning(false);
handleSend({ skipSubjectCheck: true, delayedUntil: emptySubjectDelayedUntil });
setEmptySubjectDelayedUntil(undefined);
}}
>
{t('empty_subject.send_anyway')}
</Button>
</>
}
>
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={emptySubjectDontAskAgain}
onChange={(e) => setEmptySubjectDontAskAgain(e.target.checked)}
className="rounded border-input"
/>
{t('empty_subject.dont_ask_again')}
</label>
</Dialog>
<ConfirmDialog {...noDelayDialogProps} />
<ConfirmDialog {...sendConfirmDialogProps} />
<Dialog
isOpen={showPlaceholderWarning}
onClose={() => setShowPlaceholderWarning(false)}
role="alertdialog"
showClose={false}
title={t('placeholder_warning.title')}
description={t('placeholder_warning.message')}
footer={
<>
<Button
variant="outline"
onClick={() => {
setShowPlaceholderWarning(false);
setPlaceholderWarningDelayedUntil(undefined);
// The focus trap hands focus back to the Send button as it
// tears down, so select on the next tick.
const first = placeholderWarningMatches[0];
setTimeout(() => { if (first) selectPlaceholder(first); }, 0);
}}
>
{t('placeholder_warning.back')}
</Button>
<Button
onClick={() => {
if (placeholderDontAskAgain) updateSetting('placeholderWarningEnabled', false);
placeholderWarningMatches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
setShowPlaceholderWarning(false);
handleSend({ skipSubjectCheck: true, delayedUntil: placeholderWarningDelayedUntil });
setPlaceholderWarningDelayedUntil(undefined);
}}
>
{t('placeholder_warning.send_anyway')}
</Button>
</>
}
>
<ul className="mt-3 flex flex-wrap items-center gap-1.5" aria-label={t('placeholder_warning.found')}>
{placeholderWarningMatches.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>
))}
{placeholderWarningMatches.length > 5 && (
<li className="text-xs text-muted-foreground">
{t('placeholder_warning.more', { count: placeholderWarningMatches.length - 5 })}
</li>
)}
</ul>
<label className="mt-4 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={placeholderDontAskAgain}
onChange={(e) => setPlaceholderDontAskAgain(e.target.checked)}
className="rounded border-input"
/>
{t('placeholder_warning.dont_ask_again')}
</label>
</Dialog>
<Dialog
isOpen={proofreadingWarning !== null}
onClose={() => setProofreadingWarning(null)}
role="alertdialog"
showClose={false}
title={tProofreading('send_warning.title')}
description={tProofreading('send_warning.body', { count: proofreadingWarning?.count ?? 0 })}
footer={
<>
<Button
variant="outline"
onClick={() => {
setProofreadingWarning(null);
// After the focus trap hands focus back, go to the first issue.
setTimeout(() => proofreading.navigate(1), 0);
}}
>
{tProofreading('send_warning.review')}
</Button>
<Button
data-testid="proofreading-send-anyway"
onClick={() => {
const delayed = proofreadingWarning?.delayedUntil;
proofreadingConfirmedRef.current = true;
setProofreadingWarning(null);
handleSend({ skipSubjectCheck: true, skipAttachmentCheck: true, delayedUntil: delayed });
}}
>
{tProofreading('send_warning.send')}
</Button>
</>
}
<SendWarningsDialog
warnings={sendWarnings?.items ?? null}
onBack={() => {
const first = sendWarnings?.items[0];
setSendWarnings(null);
// The focus trap hands focus back to the Send button as it tears
// down: go to what the first warning is about on the next tick.
setTimeout(() => {
if (first?.kind === 'subject') subjectInputRef.current?.focus();
else if (first?.kind === 'placeholders') selectPlaceholder(first.matches[0]);
else if (first?.kind === 'proofreading') proofreading.navigate(1);
else if (first?.kind === 'text') {
if (plainTextModeRef.current) bodyRef.current?.focus();
else editorRef.current?.commands.focus();
}
}, 0);
}}
onSendAnyway={(dontAskAgain) => {
if (!sendWarnings) return;
const { items, delayedUntil } = sendWarnings;
for (const warning of items) {
if (warning.kind === 'subject') {
emptySubjectConfirmedRef.current = true;
if (dontAskAgain) updateSetting('emptySubjectWarningEnabled', false);
} else if (warning.kind === 'text') {
emptyTextConfirmedRef.current = true;
} else if (warning.kind === 'placeholders') {
warning.matches.forEach((p) => confirmedPlaceholdersRef.current.add(p));
if (dontAskAgain) updateSetting('placeholderWarningEnabled', false);
} else if (warning.kind === 'proofreading') {
proofreadingConfirmedRef.current = true;
}
}
setSendWarnings(null);
handleSend({ warningsConfirmed: true, delayedUntil });
}}
/>
<Dialog
+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>
);
}
@@ -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(() => {});
+34 -8
View File
@@ -44,6 +44,7 @@ import { groupSearchScopeFolders, SEARCH_SCOPE_ALL_FOLDERS } from "@/lib/search-
import { toast } from "@/stores/toast-store";
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";
@@ -2547,6 +2548,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;
@@ -2554,7 +2566,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
@@ -2639,11 +2661,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);
@@ -2651,6 +2668,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;
@@ -2941,9 +2960,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)
@@ -2985,7 +3006,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 () => {
@@ -5248,6 +5269,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);
@@ -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>;
+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);
},
};
}
+25
View File
@@ -3896,6 +3896,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": "المؤسسة",
+25
View File
@@ -3896,6 +3896,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ó",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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": "سازمان",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3793,6 +3793,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": "ארגון",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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",
+25
View File
@@ -3896,6 +3896,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": "組織",
+25
View File
@@ -3896,6 +3896,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": "조직",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Atpakaļ pie rediģēšanas",
"send_anyway": "Sūtīt tik un tā",
"warnings_title": "Vai tomēr sūtīt šo vēstuli?",
"warning_subject": "Nav temata",
"warning_text": "Nav teksta",
"warning_attachment": "Piemin “{keyword}”, bet neviens fails nav pievienots",
"warning_placeholders": "Vietturu teksts, ko redzēs saņēmēji:",
"warning_proofreading": "Neatrisinātas pareizrakstības vai gramatikas kļūdas: {count}",
"empty_text_title": "Sūtīt bez teksta?",
"empty_text_message": "Šai vēstulei nav ne teksta, ne pielikuma. Vai tomēr to sūtīt?",
"scheduled_for": "Ieplānots: {when}",
"sending": "Sūta…",
"sent": "Nosūtīts",
"view": "Skatīt",
"picker_button": "Pievienot no kontaktiem",
"picker_title": "Pievienot saņēmējus",
"tab_contacts": "Kontakti",
"tab_directory": "Katalogs",
"contacts_empty_title": "Vēl nav kontaktu",
"contacts_empty_hint": "Šeit redzami cilvēki, kurus saglabājat kontaktos.",
"contacts_no_match": "Meklēšanai neatbilst neviens kontakts",
"template_name": "Veidnes nosaukums",
"template_edit_details": "Rediģēt detaļas…"
},
"org_templates": {
"section_personal": "Manas veidnes",
"section_organisation": "Organizācija",
+25
View File
@@ -3896,6 +3896,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": "Байгууллага",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Tilbake til redigering",
"send_anyway": "Send likevel",
"warnings_title": "Sende denne meldingen likevel?",
"warning_subject": "Ingen emne",
"warning_text": "Ingen tekst",
"warning_attachment": "Nevner «{keyword}», men ingen fil er lagt ved",
"warning_placeholders": "Plassholdertekst som mottakerne vil se:",
"warning_proofreading": "Åpne stave- eller grammatikkfeil: {count}",
"empty_text_title": "Sende uten tekst?",
"empty_text_message": "Denne meldingen har verken tekst eller vedlegg. Sende den likevel?",
"scheduled_for": "Planlagt til {when}",
"sending": "Sender…",
"sent": "Sendt",
"view": "Vis",
"picker_button": "Legg til fra kontakter",
"picker_title": "Legg til mottakere",
"tab_contacts": "Kontakter",
"tab_directory": "Katalog",
"contacts_empty_title": "Ingen kontakter ennå",
"contacts_empty_hint": "Personer du lagrer i Kontakter, vises her.",
"contacts_no_match": "Ingen kontakter samsvarer med søket",
"template_name": "Navn på mal",
"template_edit_details": "Rediger detaljer…"
},
"org_templates": {
"section_personal": "Mine maler",
"section_organisation": "Organisasjon",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Terug naar bewerken",
"send_anyway": "Toch verzenden",
"warnings_title": "Dit bericht toch verzenden?",
"warning_subject": "Geen onderwerp",
"warning_text": "Geen tekst",
"warning_attachment": "Noemt ‘{keyword}’, maar er is geen bestand bijgevoegd",
"warning_placeholders": "Tijdelijke tekst die ontvangers zien:",
"warning_proofreading": "Openstaande spel- of grammaticafouten: {count}",
"empty_text_title": "Verzenden zonder tekst?",
"empty_text_message": "Dit bericht heeft geen tekst en geen bijlage. Toch verzenden?",
"scheduled_for": "Gepland voor {when}",
"sending": "Bezig met verzenden…",
"sent": "Verzonden",
"view": "Bekijken",
"picker_button": "Toevoegen uit contacten",
"picker_title": "Ontvangers toevoegen",
"tab_contacts": "Contacten",
"tab_directory": "Adresboek van de organisatie",
"contacts_empty_title": "Nog geen contacten",
"contacts_empty_hint": "Mensen die u in Contacten opslaat, verschijnen hier.",
"contacts_no_match": "Geen contact komt overeen met uw zoekopdracht",
"template_name": "Naam van sjabloon",
"template_edit_details": "Details bewerken…"
},
"org_templates": {
"section_personal": "Mijn sjablonen",
"section_organisation": "Organisatie",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Wróć do edycji",
"send_anyway": "Wyślij mimo to",
"warnings_title": "Wysłać tę wiadomość mimo to?",
"warning_subject": "Brak tematu",
"warning_text": "Brak treści",
"warning_attachment": "Wspomina „{keyword}”, ale nie dołączono pliku",
"warning_placeholders": "Tekst zastępczy, który zobaczą odbiorcy:",
"warning_proofreading": "Nierozwiązane błędy pisowni lub gramatyki: {count}",
"empty_text_title": "Wysłać bez treści?",
"empty_text_message": "Ta wiadomość nie ma treści ani załącznika. Wysłać ją mimo to?",
"scheduled_for": "Zaplanowano na {when}",
"sending": "Wysyłanie…",
"sent": "Wysłano",
"view": "Pokaż",
"picker_button": "Dodaj z kontaktów",
"picker_title": "Dodaj odbiorców",
"tab_contacts": "Kontakty",
"tab_directory": "Katalog",
"contacts_empty_title": "Brak kontaktów",
"contacts_empty_hint": "Tutaj pojawią się osoby zapisane w Kontaktach.",
"contacts_no_match": "Żaden kontakt nie pasuje do wyszukiwania",
"template_name": "Nazwa szablonu",
"template_edit_details": "Edytuj szczegóły…"
},
"org_templates": {
"section_personal": "Moje szablony",
"section_organisation": "Organizacja",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Voltar à edição",
"send_anyway": "Enviar mesmo assim",
"warnings_title": "Enviar esta mensagem mesmo assim?",
"warning_subject": "Sem assunto",
"warning_text": "Sem texto",
"warning_attachment": "Menciona \"{keyword}\", mas nenhum arquivo foi anexado",
"warning_placeholders": "Texto de espaço reservado que os destinatários verão:",
"warning_proofreading": "Erros de ortografia ou gramática em aberto: {count}",
"empty_text_title": "Enviar sem texto?",
"empty_text_message": "Esta mensagem não tem texto nem anexo. Enviar mesmo assim?",
"scheduled_for": "Agendado para {when}",
"sending": "Enviando…",
"sent": "Enviado",
"view": "Ver",
"picker_button": "Adicionar dos contatos",
"picker_title": "Adicionar destinatários",
"tab_contacts": "Contatos",
"tab_directory": "Diretório",
"contacts_empty_title": "Nenhum contato ainda",
"contacts_empty_hint": "As pessoas que você salvar em Contatos aparecem aqui.",
"contacts_no_match": "Nenhum contato corresponde à sua pesquisa",
"template_name": "Nome do modelo",
"template_edit_details": "Editar detalhes…"
},
"org_templates": {
"section_personal": "Meus modelos",
"section_organisation": "Organização",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Înapoi la editare",
"send_anyway": "Trimite oricum",
"warnings_title": "Trimiteți totuși acest mesaj?",
"warning_subject": "Fără subiect",
"warning_text": "Fără text",
"warning_attachment": "Menționează „{keyword}”, dar niciun fișier nu este atașat",
"warning_placeholders": "Text substituent pe care îl vor vedea destinatarii:",
"warning_proofreading": "Greșeli de ortografie sau gramatică nerezolvate: {count}",
"empty_text_title": "Trimiteți fără text?",
"empty_text_message": "Acest mesaj nu are text și nici atașament. Îl trimiteți totuși?",
"scheduled_for": "Programat pentru {when}",
"sending": "Se trimite…",
"sent": "Trimis",
"view": "Vezi",
"picker_button": "Adaugă din contacte",
"picker_title": "Adaugă destinatari",
"tab_contacts": "Contacte",
"tab_directory": "Director",
"contacts_empty_title": "Încă nu există contacte",
"contacts_empty_hint": "Aici apar persoanele pe care le salvați în Contacte.",
"contacts_no_match": "Niciun contact nu corespunde căutării",
"template_name": "Numele șablonului",
"template_edit_details": "Editează detaliile…"
},
"org_templates": {
"section_personal": "Șabloanele mele",
"section_organisation": "Organizație",
+25
View File
@@ -3896,6 +3896,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": "Организация",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Späť na úpravu",
"send_anyway": "Odoslať aj tak",
"warnings_title": "Odoslať túto správu aj tak?",
"warning_subject": "Bez predmetu",
"warning_text": "Bez textu",
"warning_attachment": "Spomína „{keyword}“, ale nie je pripojený žiadny súbor",
"warning_placeholders": "Zástupný text, ktorý uvidia príjemcovia:",
"warning_proofreading": "Neopravené pravopisné alebo gramatické chyby: {count}",
"empty_text_title": "Odoslať bez textu?",
"empty_text_message": "Táto správa nemá text ani prílohu. Odoslať ju aj tak?",
"scheduled_for": "Naplánované na {when}",
"sending": "Odosielanie…",
"sent": "Odoslané",
"view": "Zobraziť",
"picker_button": "Pridať z kontaktov",
"picker_title": "Pridať príjemcov",
"tab_contacts": "Kontakty",
"tab_directory": "Adresár organizácie",
"contacts_empty_title": "Zatiaľ žiadne kontakty",
"contacts_empty_hint": "Tu sa zobrazia ľudia, ktorých uložíte do Kontaktov.",
"contacts_no_match": "Vyhľadávaniu nezodpovedá žiadny kontakt",
"template_name": "Názov šablóny",
"template_edit_details": "Upraviť podrobnosti…"
},
"org_templates": {
"section_personal": "Moje šablóny",
"section_organisation": "Organizácia",
+25
View File
@@ -1147,6 +1147,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": "องค์กร",
+25
View File
@@ -3896,6 +3896,31 @@
}
}
},
"compose_send": {
"back": "Düzenlemeye dön",
"send_anyway": "Yine de gönder",
"warnings_title": "Bu ileti yine de gönderilsin mi?",
"warning_subject": "Konu yok",
"warning_text": "Metin yok",
"warning_attachment": "\"{keyword}\" geçiyor ama hiç dosya eklenmemiş",
"warning_placeholders": "Alıcıların göreceği yer tutucu metin:",
"warning_proofreading": "Açık yazım veya dilbilgisi sorunları: {count}",
"empty_text_title": "Metin olmadan gönderilsin mi?",
"empty_text_message": "Bu iletide metin ve ek yok. Yine de gönderilsin mi?",
"scheduled_for": "{when} için zamanlandı",
"sending": "Gönderiliyor…",
"sent": "Gönderildi",
"view": "Görüntüle",
"picker_button": "Kişilerden ekle",
"picker_title": "Alıcı ekle",
"tab_contacts": "Kişiler",
"tab_directory": "Dizin",
"contacts_empty_title": "Henüz kişi yok",
"contacts_empty_hint": "Kişiler'e kaydettiğiniz kişiler burada görünür.",
"contacts_no_match": "Aramanızla eşleşen kişi yok",
"template_name": "Şablon adı",
"template_edit_details": "Ayrıntıları düzenle…"
},
"org_templates": {
"section_personal": "Şablonlarım",
"section_organisation": "Kuruluş",
+25
View File
@@ -3896,6 +3896,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": "Організація",
+25
View File
@@ -3908,6 +3908,31 @@
}
}
},
"compose_send": {
"back": "Quay lại chỉnh sửa",
"send_anyway": "Vẫn gửi",
"warnings_title": "Vẫn gửi thư này?",
"warning_subject": "Không có tiêu đề",
"warning_text": "Không có nội dung",
"warning_attachment": "Có nhắc đến \"{keyword}\" nhưng chưa đính kèm tệp nào",
"warning_placeholders": "Văn bản giữ chỗ mà người nhận sẽ thấy:",
"warning_proofreading": "Lỗi chính tả hoặc ngữ pháp chưa sửa: {count}",
"empty_text_title": "Gửi mà không có nội dung?",
"empty_text_message": "Thư này không có nội dung và không có tệp đính kèm. Vẫn gửi?",
"scheduled_for": "Đã lên lịch gửi lúc {when}",
"sending": "Đang gửi…",
"sent": "Đã gửi",
"view": "Xem",
"picker_button": "Thêm từ danh bạ",
"picker_title": "Thêm người nhận",
"tab_contacts": "Danh bạ",
"tab_directory": "Danh bạ tổ chức",
"contacts_empty_title": "Chưa có liên hệ nào",
"contacts_empty_hint": "Những người bạn lưu trong Danh bạ sẽ hiện ở đây.",
"contacts_no_match": "Không có liên hệ nào khớp với tìm kiếm",
"template_name": "Tên mẫu",
"template_edit_details": "Sửa chi tiết…"
},
"org_templates": {
"section_personal": "Mẫu của tôi",
"section_organisation": "Tổ chức",
+25
View File
@@ -3896,6 +3896,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": "組織",
+25
View File
@@ -3896,6 +3896,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": "组织",
@@ -0,0 +1,43 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useEmailStore } from '../email-store';
import { useAuthStore } from '../auth-store';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
// isLoading is the message list's: while it is set, the list fades under
// "Loading emails…". A send set it too, so every send reloaded the list on
// screen until the message was out (compose-24).
describe('the message list during a send', () => {
let client: IJMAPClient;
let finishSend: (value: unknown) => void;
beforeEach(() => {
client = {
getAccountId: () => 'acc-1',
pruneEmailSubmissions: vi.fn().mockResolvedValue(0),
sendEmail: vi.fn(() => new Promise((resolve) => { finishSend = resolve; })),
getAllMailboxesWithState: vi.fn().mockResolvedValue({ mailboxes: [], states: {} }),
} as unknown as IJMAPClient;
useAuthStore.setState({ client: null });
useEmailStore.setState({ isLoading: false, pendingUndoSend: null });
});
it('is not put in its loading state while the message goes out', async () => {
const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
expect(useEmailStore.getState().isLoading).toBe(false);
finishSend({ scheduled: false, emailId: 'sent-1' });
await sending;
expect(useEmailStore.getState().isLoading).toBe(false);
});
it('keeps loading a folder that a send finished in the middle of', async () => {
useEmailStore.setState({ isLoading: true });
const sending = useEmailStore.getState().sendEmail(client, ['bob@example.net'], 'Subject', 'body');
finishSend({ scheduled: false, emailId: 'sent-1' });
await sending;
expect(useEmailStore.getState().isLoading).toBe(true);
});
});
+5 -7
View File
@@ -4399,7 +4399,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const outbox = options?.outbox ? getOutboxHooks() : null;
const queueArgs = () => [to, subject, body, cc, bcc, identityId, fromEmail, draftId, fromName, htmlBody, attachments, inReplyTo, references, delayedUntil, envelopeMailFrom, options];
if (outbox?.shouldQueue(client)) return outbox.queueEmail(client, queueArgs());
set({ isLoading: true, error: null });
// Not isLoading: that is the message list's own, and a send put the list
// under "Loading emails…" until the message was out.
set({ error: null });
try {
// Files attached while offline are uploaded now that the server answers.
const sendAttachments = attachments && getOutboxHooks() ? await getOutboxHooks()!.resolveHeldAttachments(client, attachments) : attachments;
@@ -4414,7 +4416,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
if (useAuthStore.getState().client === client) void get().fetchMailboxes(client);
// Refresh handled by UI layer for immediate feedback
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? {
submissionId: result.emailSubmissionId,
@@ -4435,12 +4436,10 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// (the client says so: SendNotStartedError): keep it. Any other
// failure may have come after the server took the message.
if (outbox && error instanceof Error && error.name === 'SendNotStartedError' && outbox.shouldQueue(client)) {
set({ isLoading: false });
return outbox.queueEmail(client, queueArgs());
}
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false
});
throw error;
}
@@ -4451,7 +4450,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
// encrypted with the user present, so the finished message can wait.
const outbox = options?.outbox ? getOutboxHooks() : null;
if (outbox?.shouldQueue(client)) return outbox.queueRaw(client, rawMimeBlob, { identityId, delayedUntil, envelopeRecipients });
set({ isLoading: true, error: null });
// Not isLoading, as in sendEmail.
set({ error: null });
try {
const mailboxes = await client.getMailboxes();
const sentMailbox = mailboxes.find(mb => mb.role === 'sent');
@@ -4460,7 +4460,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
const result = await client.sendRawEmail(rawMimeBlob, identityId, sentMailbox.id, draftsMailbox?.id, delayedUntil, envelopeRecipients);
void client.pruneEmailSubmissions();
set({
isLoading: false,
pendingUndoSend: result.scheduled && result.emailSubmissionId && result.sendAt
? { submissionId: result.emailSubmissionId, emailId: result.emailId, identityId, sendAt: result.sendAt, isSmime: true, submissionAccountId: result.submissionAccountId }
: get().pendingUndoSend,
@@ -4469,7 +4468,6 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
} catch (error) {
set({
error: error instanceof Error ? error.message : "Failed to send email",
isLoading: false,
});
throw error;
}