fix: keep the vacation reply's plain-text part in step with its HTML after a reload

This commit is contained in:
Linus Rath
2026-11-02 22:52:03 +01:00
parent 54c9c2f353
commit 4bf676dec1
2 changed files with 46 additions and 6 deletions
@@ -78,4 +78,31 @@ describe('HTML auto-reply and its plain-text part', () => {
await settle();
expect(sends(server)).toBe(1);
}, 15_000);
it('derives the text again after a reload, and keeps it when HTML is switched off', async () => {
const server = mockStalwartAccount('b', [], ['vacation'], {
vacation: { htmlBody: '<p>Back on 5 May.</p>', textBody: 'Back on 5 May.' },
});
await openCard(server);
expect(body().value).toBe('');
fireEvent.change(editor(), { target: { value: '<p>Back on 12 May.</p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Back on 12 May.</p>', 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: '<p>Back on 5 May.</p>', textBody: 'Back soon.' },
});
await openCard(server);
expect(body().value).toBe('Back soon.');
fireEvent.change(editor(), { target: { value: '<p>Back on 12 May.</p>' } });
await saved(server, 1);
expect(lastSent(server)).toMatchObject({ htmlBody: '<p>Back on 12 May.</p>', textBody: 'Back soon.' });
}, 15_000);
});
+19 -6
View File
@@ -33,6 +33,11 @@ const STALWART_VACATION_LIMITS = { subject: 511, body: 2047 };
const SAVE_AT_ONCE = 0;
const SAVE_AFTER_TYPING = 800;
/** The plain-text part a save derives from the sanitized HTML. */
function textFromHtml(sanitizedHtml: string): string {
return htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
}
function byteLength(value: string): number {
return new TextEncoder().encode(value).length;
}
@@ -210,7 +215,11 @@ export function VacationSettings() {
setInputZone(timeZone);
setHalfFilled({ from: false, to: false });
setLocalSubject(stored.subject);
setLocalTextBody(stored.textBody);
// 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 || '');
}
@@ -296,10 +305,7 @@ export function VacationSettings() {
htmlEnabled && htmlToPlainText(localHtmlBody).trim()
? sanitizeEmailHtml(localHtmlBody)
: null;
const text =
localTextBody.trim() || !sanitizedHtml
? localTextBody
: htmlToPlainText(sanitizedHtml, { paragraphSpacing: true });
const text = localTextBody.trim() || !sanitizedHtml ? localTextBody : textFromHtml(sanitizedHtml);
return { sanitizedHtml, textBody: text };
}, [htmlEnabled, localHtmlBody, localTextBody]);
@@ -588,7 +594,14 @@ export function VacationSettings() {
label={t('message.html_label')}
description={t('message.html_description')}
>
<ToggleSwitch checked={htmlEnabled} onChange={changeNow(setHtmlEnabled)} />
<ToggleSwitch
checked={htmlEnabled}
onChange={changeNow((on: boolean) => {
// Switched off, a blank Body keeps the text derived from the HTML.
if (!on && !localTextBody.trim()) setLocalTextBody(payload.textBody);
setHtmlEnabled(on);
})}
/>
</SettingItem>
{htmlEnabled && (
<div className="pb-3">