diff --git a/components/identity/__tests__/identity-form-org-signature.test.tsx b/components/identity/__tests__/identity-form-org-signature.test.tsx
index c83a580a9..9e27c7fb4 100644
--- a/components/identity/__tests__/identity-form-org-signature.test.tsx
+++ b/components/identity/__tests__/identity-form-org-signature.test.tsx
@@ -44,8 +44,7 @@ describe('identity signature editor and the organisation signature', () => {
const locked = screen.getByTestId('identity-locked-signature');
expect(locked.innerHTML).toContain('Alice
Phone: +49 1');
expect(locked.querySelector('[data-org-locked]')).not.toBeNull();
- expect(screen.queryByLabelText('text_signature_label')).toBeNull();
- expect(screen.queryByLabelText('html_signature_label')).toBeNull();
+ expect(screen.queryByText('signature_label')).toBeNull();
// Saving leaves the identity's own signature as it is.
fireEvent.submit(locked.closest('form')!);
@@ -58,13 +57,13 @@ describe('identity signature editor and the organisation signature', () => {
setPolicy({ hidden: true });
render(
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- {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() || /Away until Monday.
' } }); - await saved(server, 1); - expect(lastSent(server)).toMatchObject({ htmlBody: 'Away until Monday.
', textBody: 'Away until Monday.' }); - - await settle(); - expect(sends(server)).toBe(1); - expect(screen.getByRole('status').textContent).toMatch(/saved/); - }, 15_000); - - it('saves HTML the sanitizer rewrites once', async () => { - const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: '' } }); - await openCard(server); - fireEvent.change(body(), { target: { value: 'Away.' } }); - fireEvent.click(htmlSwitch()); - fireEvent.change(editor(), { target: { value: 'Away
until Monday.
Away
until Monday.
Back on 5 May.
', textBody: 'Back on 5 May.' }, - }); - await openCard(server); - expect(body().value).toBe(''); - fireEvent.change(editor(), { target: { value: 'Back on 12 May.
' } }); - await saved(server, 1); - expect(lastSent(server)).toMatchObject({ htmlBody: 'Back on 12 May.
', textBody: 'Back on 12 May.' }); - - fireEvent.click(htmlSwitch()); - expect(body().value).toBe('Back on 12 May.'); - await saved(server, 2); - expect(lastSent(server)).toMatchObject({ htmlBody: null, textBody: 'Back on 12 May.' }); - }, 15_000); - - it('keeps a plain-text part the user wrote', async () => { - const server = mockStalwartAccount('b', [], ['vacation'], { - vacation: { htmlBody: 'Back on 5 May.
', textBody: 'Back soon.' }, - }); - await openCard(server); - expect(body().value).toBe('Back soon.'); - fireEvent.change(editor(), { target: { value: 'Back on 12 May.
' } }); - await saved(server, 1); - expect(lastSent(server)).toMatchObject({ htmlBody: 'Back on 12 May.
', textBody: 'Back soon.' }); - }, 15_000); -}); diff --git a/components/settings/__tests__/vacation-message.test.tsx b/components/settings/__tests__/vacation-message.test.tsx new file mode 100644 index 000000000..49b6443d9 --- /dev/null +++ b/components/settings/__tests__/vacation-message.test.tsx @@ -0,0 +1,138 @@ +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 out-of-office message in the real vacation card: one editor with +// formatting, whose plain-text part a save derives; real starting text for a +// reply never written; and no way to switch on (or keep on) a blank reply. + +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?: Recorddefault_message
'); + // Opening the page saves nothing. + await settle(); + expect(sends(server)).toBe(0); + + fireEvent.click(replySwitch()); + await saved(server, 1); + expect(lastSent(server)).toMatchObject({ + isEnabled: true, + subject: 'default_subject', + htmlBody: 'default_message
', + textBody: 'default_message', + }); + }, 15_000); + + it('keeps the switch off while the message is empty, and says to write one', async () => { + const server = blank(); + await openCard(server); + fireEvent.change(editor(), { target: { value: '' } }); + fireEvent.click(replySwitch()); + expect(replySwitch().getAttribute('aria-checked')).toBe('false'); + expect(screen.getByText('write_message_first')).toBeInTheDocument(); + await settle(); + expect(server.client.setVacationResponse.mock.calls.some(([sent]) => (sent as { isEnabled: boolean }).isEnabled)).toBe(false); + + fireEvent.change(editor(), { target: { value: 'Back Monday.
' } }); + expect(screen.queryByText('write_message_first')).toBeNull(); + fireEvent.click(replySwitch()); + expect(replySwitch().getAttribute('aria-checked')).toBe('true'); + }, 15_000); + + it('does not save a reply that is on once its text is deleted', async () => { + const server = blank(); + await openCard(server); + fireEvent.click(replySwitch()); + await saved(server, 1); + fireEvent.change(editor(), { target: { value: '' } }); + expect(screen.getByText('write_message_first')).toBeInTheDocument(); + await settle(); + expect(sends(server)).toBe(1); + }, 15_000); + + it('saves a formatted message once, with the plain-text part derived from it', async () => { + const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: 'away' } }); + await openCard(server); + // A stored text-only reply opens in the editor. + expect(editor().value).toBe('away
'); + fireEvent.change(editor(), { target: { value: 'Away until Monday
' } }); + await saved(server, 1); + expect(lastSent(server)).toMatchObject({ htmlBody: 'Away until Monday
', textBody: 'Away until Monday' }); + + await settle(); + expect(sends(server)).toBe(1); + expect(screen.getByRole('status').textContent).toMatch(/saved/); + }, 15_000); + + it('saves HTML the sanitizer rewrites once', async () => { + const server = mockStalwartAccount('b', [], ['vacation'], { vacation: { textBody: 'away' } }); + await openCard(server); + fireEvent.change(editor(), { target: { value: 'Away
until Monday.
Away
until Monday.
Back on 5 May.
', textBody: 'Back soon.' } }); + await openCard(server); + expect(editor().value).toBe('Back on 5 May.
'); + fireEvent.click(replySwitch()); + await saved(server, 1); + expect(lastSent(server)).toMatchObject({ isEnabled: true, htmlBody: 'Back on 5 May.
', textBody: 'Back soon.' }); + }, 15_000); +}); diff --git a/components/settings/__tests__/vacation-templates.test.tsx b/components/settings/__tests__/vacation-templates.test.tsx index 501af2451..ac9a931f1 100644 --- a/components/settings/__tests__/vacation-templates.test.tsx +++ b/components/settings/__tests__/vacation-templates.test.tsx @@ -22,6 +22,13 @@ vi.mock('next-intl', () => { const t = (key: string, values?: RecordOld text
'); // Applying without a deputy refuses and says why. fireEvent.click(screen.getByTestId('vacation-template-apply')); @@ -92,11 +99,11 @@ describe('out-of-office templates', () => { fireEvent.click(screen.getByTestId('vacation-template-apply')); // ICU versions differ in commas, not in the day. - const filled = (screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement).value; - expect(filled).toMatch(/^I am away until Monday,? 4 January 2027\.\nPlease write to bob@example\.org\.\nAlice Example$/); + // The text template goes into the one message editor, line by line. + expect(editor().value).toMatch(/^I am away until Monday,? 4 January 2027\.
Please write to bob@example\.org\.
Alice Example<\/p>$/);
await waitFor(() => expect(server.client.setVacationResponse).toHaveBeenCalledTimes(1), { timeout: 3000 });
const sent = server.client.setVacationResponse.mock.calls[0][0] as { subject: string; textBody: string };
- expect(sent.textBody).toBe(filled);
+ expect(sent.textBody).toMatch(/^I am away until Monday,? 4 January 2027\.\nPlease write to bob@example\.org\.\nAlice Example$/);
expect(sent.subject).toMatch(/^Away until Monday,? 4 January 2027$/);
});
@@ -122,7 +129,6 @@ describe('out-of-office templates', () => {
expect((inputs[1] as HTMLInputElement).value).toBe('2027-01-04T08:00');
fireEvent.change(screen.getByTestId('vacation-template-select'), { target: { value: 'holiday' } });
fireEvent.click(screen.getByTestId('vacation-template-apply'));
- expect((screen.getByPlaceholderText('message.body_placeholder') as HTMLTextAreaElement).value)
- .toMatch(/^Back Monday,? 4 January 2027 at 0?8:00$/);
+ expect(editor().value).toMatch(/^
Back Monday,? 4 January 2027 at 0?8:00<\/p>$/);
});
});
diff --git a/components/settings/vacation-settings.tsx b/components/settings/vacation-settings.tsx
index 0c89a2e45..b86ad61ed 100644
--- a/components/settings/vacation-settings.tsx
+++ b/components/settings/vacation-settings.tsx
@@ -25,6 +25,7 @@ import { usePolicyStore } from '@/stores/policy-store';
import { useSettingsStore } from '@/stores/settings-store';
import { companyName } from '@/lib/org/signature-state';
import { fromWallClock, getWallClock, parseLocalDateTime, resolveTimeZone } from '@/lib/timezone';
+import { textToParagraphHtml } from '@/lib/text-to-paragraph-html';
const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
@@ -42,6 +43,16 @@ function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
+/** Whether HTML holds any text a recipient would read. */
+function hasReadableText(html: string | null | undefined): boolean {
+ return !!html && !!htmlToPlainText(html).trim();
+}
+
+/** A reply the server holds with nothing in it: off, no subject, no message. */
+function isBlankReply(stored: { isEnabled: boolean; subject: string; textBody: string; htmlBody: string | null }): boolean {
+ return !stored.isEnabled && !stored.subject.trim() && !stored.textBody.trim() && !hasReadableText(stored.htmlBody);
+}
+
/** A switch with its state written out next to it ("Active" / "Inactive"). */
function StatusSwitch({ checked, onChange, activeLabel, inactiveLabel }: {
checked: boolean;
@@ -100,6 +111,7 @@ function sameForward(a: VacationForwardSettings | null, b: VacationForward | nul
export function VacationSettings() {
const t = useTranslations('settings.vacation');
+ const tx = useTranslations('uxs_vacation');
const tFilters = useTranslations('settings.filters');
const tNotifications = useTranslations('notifications');
const locale = useLocale();
@@ -159,9 +171,15 @@ export function VacationSettings() {
}, [timeZone]);
const datesUnusable = localFromDate === null || localToDate === null || halfFilled.from || halfFilled.to;
const [localSubject, setLocalSubject] = useState(subject);
- const [localTextBody, setLocalTextBody] = useState(textBody);
- const [htmlEnabled, setHtmlEnabled] = useState(!!htmlBody);
- const [localHtmlBody, setLocalHtmlBody] = useState(htmlBody || '');
+ // One message editor with formatting. What it holds is HTML; the plain-text
+ // part a save sends is derived from it. Until the message is edited (or
+ // filled with the starting text) a save sends the stored parts unchanged.
+ const [localBody, setLocalBody] = useState(() => htmlBody || textToParagraphHtml(textBody));
+ const [bodyEdited, setBodyEdited] = useState(false);
+ // The user tried to switch the reply on with no message, or forwarding on
+ // with no address: said under the field until it is filled in.
+ const [needMessage, setNeedMessage] = useState(false);
+ const [needAddress, setNeedAddress] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const [forwardEnabled, setForwardEnabled] = useState(forward?.enabled ?? false);
const [forwardTo, setForwardTo] = useState(forward?.to ?? '');
@@ -214,14 +232,19 @@ export function VacationSettings() {
setToInput(stored.toDate ? utcToLocalDatetime(stored.toDate, timeZone) : '');
setInputZone(timeZone);
setHalfFilled({ from: false, to: false });
- setLocalSubject(stored.subject);
- // Text a save derived from the HTML leaves the Body blank, so the next
- // HTML edit derives it again instead of keeping the old wording.
- setLocalTextBody(stored.htmlBody && stored.textBody.trim() === textFromHtml(sanitizeEmailHtml(stored.htmlBody)).trim()
- ? ''
- : stored.textBody);
- setHtmlEnabled(!!stored.htmlBody);
- setLocalHtmlBody(stored.htmlBody || '');
+ setNeedMessage(false);
+ if (isBlankReply(stored)) {
+ // Nothing written yet: start with real text in the user's language,
+ // which they can change, so switching on never sends a blank reply.
+ // Saved with the first change the user makes, not by opening the page.
+ setLocalSubject(tx('default_subject'));
+ setLocalBody(textToParagraphHtml(tx('default_message')));
+ setBodyEdited(true);
+ } else {
+ setLocalSubject(stored.subject);
+ setLocalBody(stored.htmlBody || textToParagraphHtml(stored.textBody));
+ setBodyEdited(false);
+ }
}
// Forwarding a save didn't send takes what the server has now; one it
// sent stays as set here, also when its filters part failed and it
@@ -230,6 +253,7 @@ export function VacationSettings() {
setForwardEnabled(stored.forward?.enabled ?? false);
setForwardTo(stored.forward?.to ?? '');
setForwardKeep(stored.forward?.keepCopy ?? false);
+ setNeedAddress(false);
}
setAudienceOnly(stored.audience?.only ?? 'all');
// The time zone is read when the fields load, not followed while typing.
@@ -253,9 +277,15 @@ export function VacationSettings() {
touched.current = true;
saveDelay.current = SAVE_AFTER_TYPING;
if (filled.subject) setLocalSubject(filled.subject);
- setLocalTextBody(filled.text);
- setHtmlEnabled(!!filled.html);
- setLocalHtmlBody(filled.html);
+ setLocalBody(filled.html || textToParagraphHtml(filled.text));
+ setBodyEdited(true);
+ };
+
+ const editBody = (html: string) => {
+ touched.current = true;
+ saveDelay.current = SAVE_AFTER_TYPING;
+ setLocalBody(html);
+ setBodyEdited(true);
};
// Reads a date field as the browser holds it, half filled in or not.
@@ -283,9 +313,11 @@ export function VacationSettings() {
// Forwarding as a save stores it: an address that is off is kept for next
// time if it is usable, and dropped otherwise.
const forwardInvalid = forwardAvailable && forwardEnabled && !isValidEmail(forwardTo);
+ // The address field shows while forwarding is off too, so typing one
+ // there before switching on is not a change of its own.
const forwardSettings = !forwardAvailable
? undefined
- : forwardEnabled || isValidEmail(forwardTo)
+ : forwardEnabled || (forward && isValidEmail(forwardTo))
? { enabled: forwardEnabled, to: forwardTo, keepCopy: forwardKeep }
: null;
// Only what was changed here is sent: the rest stays as the server has it,
@@ -298,16 +330,15 @@ export function VacationSettings() {
forwardAvailable && forwardEnabled && forwardKeep &&
typeof forwardLimit === 'number' && forwardLimit > 0 && 1 + otherForwards > forwardLimit;
- // What a save sends: the sanitized HTML and a plain-text part, derived
- // from the HTML when the user left it blank (for clients without HTML).
+ // What a save sends: the sanitized HTML and the plain-text part derived
+ // from it (for mail apps without HTML). A message nobody edited goes as
+ // the server has it, so switching the reply on rewrites nothing.
const payload = useMemo(() => {
- const sanitizedHtml =
- htmlEnabled && htmlToPlainText(localHtmlBody).trim()
- ? sanitizeEmailHtml(localHtmlBody)
- : null;
- const text = localTextBody.trim() || !sanitizedHtml ? localTextBody : textFromHtml(sanitizedHtml);
- return { sanitizedHtml, textBody: text };
- }, [htmlEnabled, localHtmlBody, localTextBody]);
+ if (!bodyEdited) return { sanitizedHtml: htmlBody || null, textBody };
+ const sanitizedHtml = hasReadableText(localBody) ? sanitizeEmailHtml(localBody) : null;
+ return { sanitizedHtml, textBody: sanitizedHtml ? textFromHtml(sanitizedHtml) : '' };
+ }, [bodyEdited, localBody, htmlBody, textBody]);
+ const bodyEmpty = !payload.textBody.trim() && !hasReadableText(payload.sanitizedHtml);
// Stalwart refuses a subject of 512 bytes or more and a text or HTML body
// of 2048 bytes or more, answering only "Field could not be set."
@@ -333,10 +364,11 @@ export function VacationSettings() {
const fromWarning = !fromError && localFromDate && localFromDate !== fromDate && new Date(localFromDate) < todayStart
? t('warnings.start_in_past')
: null;
- const hasHtmlContent = htmlEnabled && !!htmlToPlainText(localHtmlBody).trim();
- const bodyWarning = oversize.body && sizeLimits
+ // An empty message never goes live: the switch won't turn on, and a reply
+ // that is on can't be saved with its text deleted.
+ const bodyError = oversize.body && sizeLimits
? t('warnings.body_too_long', { max: sizeLimits.body })
- : localEnabled && !localTextBody.trim() && !hasHtmlContent ? t('warnings.empty_body') : null;
+ : (localEnabled || needMessage) && bodyEmpty ? tx('write_message_first') : null;
// The bodies are compared as a save sends them: the server keeps the
// sanitized HTML and the text derived from it, never what was typed.
@@ -353,7 +385,24 @@ export function VacationSettings() {
const hasBlockingError =
datesUnusable ||
!!(localFromDate && localToDate && new Date(localToDate) <= new Date(localFromDate)) ||
- oversize.subject || oversize.body || forwardInvalid;
+ oversize.subject || oversize.body || forwardInvalid || (localEnabled && bodyEmpty);
+
+ const switchReply = (on: boolean) => {
+ if (on && bodyEmpty) {
+ setNeedMessage(true);
+ return;
+ }
+ setNeedMessage(false);
+ changeNow(setLocalEnabled)(on);
+ };
+ const switchForward = (on: boolean) => {
+ if (on && !isValidEmail(forwardTo)) {
+ setNeedAddress(true);
+ return;
+ }
+ setNeedAddress(false);
+ changeNow(setForwardEnabled)(on);
+ };
// What a save would store, to tell one change from the next.
const snapshot = JSON.stringify([
@@ -361,6 +410,9 @@ export function VacationSettings() {
]);
// A save that failed or was stopped isn't tried again until something changes.
const [refusedSnapshot, setRefusedSnapshot] = useState