From 94b9fe880333df5f09a8a02de64bc72fab01944d Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 4 Nov 2026 06:29:53 +0100
Subject: [PATCH 1/2] fix: start out of office with real text and never switch
on a blank reply
Out of office used grey placeholder text, so switching the reply on sent
an empty message. A reply never written now opens with an editable subject
and message in the user's language; the switch stays off while the message
is empty and says "Write a message first", and a reply that is on can't be
saved with its text deleted. Forwarding won't switch on without an address,
and the address field shows while it is off. The page puts the switch
first, then "Reply to" with plain choices, then "From/Until (optional)",
then one message editor with formatting instead of a text box plus a
second "Formatted message (HTML)" box. A message nobody edited is sent as
the server has it. A failed save is no longer retried once at once when
the last change was a click.
Test changes: the forward tests type the address before switching
forwarding on (it no longer switches on without one) and check the new
order and option labels; the HTML-body and template tests use the one
editor instead of the text box and HTML switch, which are gone. The
search index names the new rows.
---
.../__tests__/vacation-forward.test.tsx | 62 ++--
.../__tests__/vacation-html-body.test.tsx | 108 -------
.../__tests__/vacation-message.test.tsx | 138 +++++++++
.../__tests__/vacation-templates.test.tsx | 18 +-
components/settings/vacation-settings.tsx | 276 ++++++++++--------
lib/settings-search.ts | 8 +-
lib/text-to-paragraph-html.ts | 15 +
locales/ar/common.json | 24 ++
locales/ca/common.json | 24 ++
locales/cs/common.json | 24 ++
locales/da/common.json | 24 ++
locales/de/common.json | 24 ++
locales/en/common.json | 24 ++
locales/es/common.json | 24 ++
locales/et/common.json | 24 ++
locales/fa/common.json | 24 ++
locales/fr/common.json | 24 ++
locales/he/common.json | 24 ++
locales/hu/common.json | 24 ++
locales/it/common.json | 24 ++
locales/ja/common.json | 24 ++
locales/ko/common.json | 24 ++
locales/lv/common.json | 24 ++
locales/mn/common.json | 24 ++
locales/nb/common.json | 24 ++
locales/nl/common.json | 24 ++
locales/pl/common.json | 24 ++
locales/pt/common.json | 24 ++
locales/ro/common.json | 24 ++
locales/ru/common.json | 24 ++
locales/sk/common.json | 24 ++
locales/th/common.json | 24 ++
locales/tr/common.json | 24 ++
locales/uk/common.json | 24 ++
locales/vi/common.json | 24 ++
locales/zh-TW/common.json | 24 ++
locales/zh/common.json | 24 ++
37 files changed, 1091 insertions(+), 254 deletions(-)
delete mode 100644 components/settings/__tests__/vacation-html-body.test.tsx
create mode 100644 components/settings/__tests__/vacation-message.test.tsx
create mode 100644 lib/text-to-paragraph-html.ts
diff --git a/components/settings/__tests__/vacation-forward.test.tsx b/components/settings/__tests__/vacation-forward.test.tsx
index 95ba8a002..bf2620166 100644
--- a/components/settings/__tests__/vacation-forward.test.tsx
+++ b/components/settings/__tests__/vacation-forward.test.tsx
@@ -112,8 +112,8 @@ describe('forwarding in the vacation card', () => {
expect(screen.getByText('forward.title')).toBeInTheDocument();
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
await save(server);
expect(saved()).toBe(true);
@@ -136,8 +136,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
// The auto-reply stays off; only forwarding is switched on.
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
await save(server);
expect(saved()).toBe(true);
@@ -153,8 +153,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
await save(server);
expect(server.active()).toBe('filters');
@@ -170,27 +170,46 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
fireEvent.click(keepSwitch());
await save(server);
expect(server.content('filters')).toContain(' redirect :copy "kollege@example.com";');
expect(server.content('filters')).not.toContain(' stop;');
});
- it('will not save forwarding without a usable address', async () => {
+ it('will not switch forwarding on without a usable address, and says to enter one', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
+ // The address field is there before forwarding is on.
+ expect(addressInput().value).toBe('');
fireEvent.click(forwardSwitch());
+ expect(forwardSwitch().getAttribute('aria-checked')).toBe('false');
+ expect(screen.getByText('enter_address_first')).toBeInTheDocument();
fireEvent.change(addressInput(), { target: { value: 'kollege' } });
- expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
+ fireEvent.click(forwardSwitch());
+ expect(forwardSwitch().getAttribute('aria-checked')).toBe('false');
await settle();
expect(server.client.setVacationResponse).not.toHaveBeenCalled();
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
- expect(screen.queryByText('warnings.forward_address')).toBeNull();
+ expect(screen.queryByText('enter_address_first')).toBeNull();
+ fireEvent.click(forwardSwitch());
+ expect(forwardSwitch().getAttribute('aria-checked')).toBe('true');
await save(server);
});
+ it('will not save forwarding that is on once its address is made unusable', async () => {
+ const server = stalwart([rule('a')]);
+ await openCard(server);
+ fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
+ await save(server);
+ fireEvent.change(addressInput(), { target: { value: 'kollege' } });
+ expect(screen.getByText('warnings.forward_address')).toBeInTheDocument();
+ await settle();
+ expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1);
+ });
+
it('does not count an address typed and switched off again as a change', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
@@ -205,8 +224,8 @@ describe('forwarding in the vacation card', () => {
it('warns that a forwarding rule is skipped when a copy is kept and the server allows one redirect', async () => {
const server = stalwart([rule('f', { actions: [{ type: 'forward', value: 'chef@example.com' }] })], { maxNumberRedirects: 1 });
await openCard(server);
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
expect(screen.queryByText(/^forward_limit/)).toBeNull();
fireEvent.click(keepSwitch());
expect(screen.getByText('forward_limit 1')).toBeInTheDocument();
@@ -216,8 +235,8 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
+ fireEvent.click(forwardSwitch());
await save(server);
// Back as stored after the save, as the server has it now.
@@ -256,9 +275,9 @@ describe('forwarding in the vacation card', () => {
const server = stalwart([rule('a')]);
await openCard(server);
server.client.updateSieveScript.mockRejectedValueOnce(new Error('server said no'));
+ fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(screen.getByRole('switch', { name: 'status.label' }));
fireEvent.click(forwardSwitch());
- fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
await save(server);
expect(toast.error).toHaveBeenCalledWith('vacation_filters_save_failed');
expect(saved()).toBe(false);
@@ -318,8 +337,8 @@ describe('forwarding in the vacation card', () => {
it('starts over on another account, even where both store the same', async () => {
const server = stalwart([rule('a')]);
await openCard(server);
- fireEvent.click(forwardSwitch());
fireEvent.change(addressInput(), { target: { value: 'chef@a.example' } });
+ fireEvent.click(forwardSwitch());
// Neither account has forwarding stored, so no stored value changes.
act(() => { useAuthStore.setState({ activeAccountId: 'other-login' }); });
@@ -406,7 +425,7 @@ describe('who gets the auto-reply, in the vacation card', () => {
withIdentities();
await openCard(stalwart([rule('a')]));
expect(audienceSelect().value).toBe('all');
- expect(screen.getByText('audience.description example.com, example.org')).toBeInTheDocument();
+ expect(screen.getByRole('option', { name: 'audience_internal example.com, example.org' })).toBeInTheDocument();
});
it('is not offered without identities to tell the own domains', async () => {
@@ -511,20 +530,25 @@ describe('saving the out-of-office card', () => {
describe('layout of the out-of-office card', () => {
const follows = (a: Node, b: Node) => (a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
- it('puts the period first, then the auto-reply and forwarding, each with its own switch and state', async () => {
+ it('puts the switch first, then the optional dates, the message and forwarding, each with its own switch and state', async () => {
await openCard(stalwart([rule('a')]));
- const [page, reply, forwarding] = ['title', 'auto_reply.title', 'forward.title'].map((key) => screen.getByText(key));
- const start = screen.getByText('date_range.start');
- expect(follows(page, start)).toBe(true);
- expect(follows(start, reply)).toBe(true);
- expect(follows(reply, forwarding)).toBe(true);
- expect(screen.queryByText('date_range.title')).toBeNull();
+ const [page, message, forwarding] = ['title', 'message_title', 'forward.title'].map((key) => screen.getByText(key));
+ const replySwitch = screen.getByRole('switch', { name: 'status.label' });
+ const [from, until] = ['from_label', 'until_label'].map((key) => screen.getByText(key));
+ expect(follows(page, replySwitch)).toBe(true);
+ expect(follows(replySwitch, from)).toBe(true);
+ expect(follows(from, until)).toBe(true);
+ expect(follows(until, message)).toBe(true);
+ expect(follows(message, forwarding)).toBe(true);
+ // One message editor, no second "formatted message" box.
+ expect(screen.queryByText('message.html_label')).toBeNull();
// Two switches, each saying whether it is on, independent of the other.
const replyRow = screen.getByRole('switch', { name: 'status.label' }).parentElement!;
const forwardRow = forwardSwitch().parentElement!;
expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument();
expect(within(forwardRow).getByText('status.inactive')).toBeInTheDocument();
+ fireEvent.change(addressInput(), { target: { value: 'kollege@example.com' } });
fireEvent.click(forwardSwitch());
expect(within(forwardRow).getByText('status.active')).toBeInTheDocument();
expect(within(replyRow).getByText('status.inactive')).toBeInTheDocument();
diff --git a/components/settings/__tests__/vacation-html-body.test.tsx b/components/settings/__tests__/vacation-html-body.test.tsx
deleted file mode 100644
index 46035ede3..000000000
--- a/components/settings/__tests__/vacation-html-body.test.tsx
+++ /dev/null
@@ -1,108 +0,0 @@
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { afterEach, describe, expect, it, vi } from 'vitest';
-import React from 'react';
-import { mockStalwartAccount } from '@/lib/filters/__tests__/sieve-mock';
-import { useAuthStore } from '@/stores/auth-store';
-import { useVacationStore } from '@/stores/vacation-store';
-import { VacationSettings } from '../vacation-settings';
-
-// The HTML reply and its plain-text part in the real vacation card: a save
-// derives the text from the HTML when the Body is left blank, and what the
-// server then holds must not look like a change still waiting to be saved.
-
-const toast = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn(), info: vi.fn() }));
-vi.mock('@/stores/toast-store', () => ({ toast }));
-vi.mock('next-intl', () => {
- const t = (key: string, values?: Record
Ann
'); + fireEvent.change(source, { target: { value: 'Ann Lee
' } }); + submit(); + await waitFor(() => expect(onSave).toHaveBeenCalledTimes(1)); + expect(onSave.mock.calls[0][0]).toMatchObject({ htmlSignature: 'Ann Lee
', textSignature: expect.stringMatching(/^Ann\s+Lee$/) }); + }); + + it('shows the size only near the server limit, and will not save a signature that is too long', async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + render(Ann
' } }); + expect(screen.queryByTestId('signature-size')).toBeNull(); + + fireEvent.change(editor(), { target: { value: `${'a'.repeat(1900)}
` } }); + expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_size/); + + fireEvent.change(editor(), { target: { value: `${'a'.repeat(2100)}
` } }); + expect(screen.getByTestId('signature-size').textContent).toMatch(/^signature_too_long/); + submit(); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('is a plain text box for someone who writes plain text only, and keeps the HTML version', async () => { + useSettingsStore.setState({ plainTextMode: true }); + const onSave = vi.fn().mockResolvedValue(undefined); + render(Ann
' }); + }); +}); diff --git a/components/identity/identity-form.tsx b/components/identity/identity-form.tsx index f8ba2acc3..e48c83d54 100644 --- a/components/identity/identity-form.tsx +++ b/components/identity/identity-form.tsx @@ -10,31 +10,8 @@ import { getEmailValidationError, validateEmailList } from '@/lib/validation'; import { getPlainTextSignature } from '@/lib/signature-utils'; import { useOrgSignatureSetup } from '@/hooks/use-org-signature'; import { OrgLockedNote } from '@/components/settings/org-locked-note'; - -// Stalwarts JMAP Identity/set caps signature fields at 2047 UTF-8 bytes -const SIGNATURE_MAX_BYTES = 2047; -const utf8Encoder = new TextEncoder(); - -function utf8ByteLength(s: string): number { - return utf8Encoder.encode(s).length; -} - -function truncateToUtf8Bytes(s: string, maxBytes: number): string { - if (utf8ByteLength(s) <= maxBytes) return s; - let lo = 0; - let hi = s.length; - while (lo < hi) { - const mid = (lo + hi + 1) >>> 1; - if (utf8ByteLength(s.slice(0, mid)) <= maxBytes) lo = mid; - else hi = mid - 1; - } - // Don't split a surrogate pair: if we landed right after a high surrogate, back off one code unit. - if (lo > 0) { - const prev = s.charCodeAt(lo - 1); - if (prev >= 0xD800 && prev <= 0xDBFF) lo -= 1; - } - return s.slice(0, lo); -} +import { useSettingsStore } from '@/stores/settings-store'; +import { SignatureEditor, SIGNATURE_MAX_BYTES, signatureBytes } from './signature-editor'; interface IdentityFormData { name: string; @@ -56,9 +33,10 @@ interface IdentityFormProps { export function IdentityForm({ identity, onSave, onCancel, onDirtyChange }: IdentityFormProps) { const t = useTranslations('identities.form'); const tValidation = useTranslations('identities.validation_errors'); - const tDisplay = useTranslations('identities.display'); const tOrg = useTranslations('org_signature'); const isEditing = !!identity; + // Composing > Plain text only: the signature is plain text too. + const plainTextMode = useSettingsStore((s) => s.plainTextMode); const [formData, setFormData] = useState+ {tOrg('identity_default')} +
+ ) : null} /> -- {tOrg('identity_default')} -
- )} -- {t('signature_byte_counter', { bytes, max: SIGNATURE_MAX_BYTES })} - {atLimit && {t('signature_byte_limit_reached')}} -
- ); -} diff --git a/components/identity/signature-editor.tsx b/components/identity/signature-editor.tsx new file mode 100644 index 000000000..a3b8938b1 --- /dev/null +++ b/components/identity/signature-editor.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useId, useState, type ReactNode } from 'react'; +import { useTranslations } from 'next-intl'; +import { RichTextEditor } from '@/components/email/rich-text-editor'; +import { sanitizeSignatureHtml, sanitizeSignatureHtmlForDisplay } from '@/lib/email-sanitization'; +import { htmlToPlainText } from '@/lib/html-to-text'; +import { textToParagraphHtml } from '@/lib/text-to-paragraph-html'; +import { useSettingsStore } from '@/stores/settings-store'; +import { cn } from '@/lib/utils'; + +/** Stalwart's JMAP Identity/set caps each signature field at 2047 UTF-8 bytes. */ +export const SIGNATURE_MAX_BYTES = 2047; +/** The size shows from here on, so it is there before the limit is reached. */ +const SHOW_SIZE_FROM = Math.floor(SIGNATURE_MAX_BYTES * 0.9); + +const utf8Encoder = new TextEncoder(); +const utf8ByteLength = (value: string) => utf8Encoder.encode(value).length; + +/** The plain-text part a save sends with a formatted signature. */ +export function textFromSignatureHtml(html: string): string { + const clean = sanitizeSignatureHtml(html); + return clean ? htmlToPlainText(clean).trim() : ''; +} + +/** Whether a formatted signature holds anything: text, or a picture. */ +function hasSignatureContent(html: string): boolean { + const clean = sanitizeSignatureHtml(html); + return !!clean && (!!htmlToPlainText(clean).trim() || /{t('signature_plain_description')}
+ > + ) : source ? ( + <> ++ {tooLong + ? t('signature_too_long', { bytes, max: SIGNATURE_MAX_BYTES }) + : t('signature_size', { bytes, max: SIGNATURE_MAX_BYTES })} +
+ )} + {note} +