diff --git a/components/email/__tests__/compose-format.test.ts b/components/email/__tests__/compose-format.test.ts index 5837505f8..b53a558c4 100644 --- a/components/email/__tests__/compose-format.test.ts +++ b/components/email/__tests__/compose-format.test.ts @@ -83,4 +83,10 @@ describe('compose-format (#1022): HTML <-> plain text switch', () => { const back = plainComposeBodyToHtml(text, identity, { separator: true }); expect(back).toBe(start); }); + + it('adds no separator paragraph in front of a signature that has its own delimiter', () => { + const html = buildEmbeddedSignatureHtml({ textSignature: '-- \nUser A' }, { embed: true, separator: true }); + expect(html).not.toContain(`${SIGNATURE_RANGE_MARKER}="separator"`); + expect(count(html, '-- ')).toBe(1); + }); }); diff --git a/components/email/compose-format.ts b/components/email/compose-format.ts index 4d4e918a6..0cc0a9f98 100644 --- a/components/email/compose-format.ts +++ b/components/email/compose-format.ts @@ -6,6 +6,7 @@ import { plainTextBodyHasSignature, plainTextBodyWithoutSignature, } from "@/lib/signature-utils"; +import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { buildSignatureBlock, SIGNATURE_RANGE_MARKER } from "@/components/email/signature-block"; /** @@ -36,7 +37,7 @@ export function buildEmbeddedSignatureHtml( options: { embed: boolean; separator: boolean } ): string { if (!options.embed) return ''; - const startMarker = options.separator + const startMarker = wantsSignatureSeparator(options.separator, identity) ? `

--

` : `

`; const endMarker = `

`; diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 1d6a3d68c..509b6e5bc 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -47,6 +47,7 @@ import { TemplatePicker } from "@/components/templates/template-picker"; import { TemplateForm } from "@/components/templates/template-form"; import type { EmailTemplate } from "@/lib/template-types"; import { appendPlainTextSignature, getPlainTextSignature, plainTextBodyHasSignature, plainTextBodyWithoutSignature } from "@/lib/signature-utils"; +import { wantsSignatureSeparator } from "@/lib/signature-delimiter"; import { findComposeIdentityId, findDraftIdentityId, findReplyIdentityId, resolveReplyFrom } from "@/lib/reply-identity"; import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients"; import { computeReplyThreadingHeaders, type ReplyThreadingHeaders } from "@/lib/email-threading"; @@ -438,7 +439,7 @@ export function EmailComposer({ // editable. Fixes #329 (A,B). if (mode === 'compose') { if (!shouldEmbedSignatureInNewMail) return prefix; - const sep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; + const sep = wantsSignatureSeparator(signatureSeparatorEnabled, initialSignatureIdentity) ? '\n\n-- \n' : '\n\n'; return `${prefix}${sep}${getPlainTextSignature(initialSignatureIdentity)}`; } if (!replyTo?.body && !replyTo?.htmlBody) return prefix; @@ -460,7 +461,7 @@ export function EmailComposer({ // drafting area and the quoted content so it reads naturally as a // closing for the reply body. Send-time append is skipped - see // shouldEmbedSignatureAboveQuote. - const plainSep = signatureSeparatorEnabled ? '\n\n-- \n' : '\n\n'; + const plainSep = wantsSignatureSeparator(signatureSeparatorEnabled, initialSignatureIdentity) ? '\n\n-- \n' : '\n\n'; const signatureBlock = shouldEmbedSignatureAboveQuote ? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}` : ''; @@ -2481,7 +2482,7 @@ export function EmailComposer({ // Build HTML signature block (used only in rich text mode) const buildSignatureHtml = (): string => { if (signatureAlreadyInBody) return ''; - const sep = signatureSeparatorEnabled ? `

--
` : `

`; + const sep = wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? `

--
` : `

`; if (signatureIdentity?.htmlSignature) { return `${sep}${sanitizeSignatureHtml(signatureIdentity.htmlSignature)}`; } @@ -3342,13 +3343,13 @@ export function EmailComposer({ : plainTextMode ? ( getPlainTextSignature(signatureIdentity) ? (
- {signatureSeparatorEnabled ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)} + {wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '-- \n' : ''}{getPlainTextSignature(signatureIdentity)}
) : null ) : composerSignatureHtml ? (
--
' : ''}${composerSignatureHtml}` }} + dangerouslySetInnerHTML={{ __html: `${wantsSignatureSeparator(signatureSeparatorEnabled, signatureIdentity) ? '
--
' : ''}${composerSignatureHtml}` }} /> ) : null} diff --git a/lib/__tests__/html-to-text.test.ts b/lib/__tests__/html-to-text.test.ts index 4f3bcee27..8473fcdaf 100644 --- a/lib/__tests__/html-to-text.test.ts +++ b/lib/__tests__/html-to-text.test.ts @@ -54,4 +54,10 @@ describe('htmlToPlainText', () => { it('handles nested lists as separate lines', () => { expect(htmlToPlainText('')).toBe('One\nTwo'); }); + + it('keeps the trailing space of a "-- " signature delimiter', () => { + // Without it the text part's delimiter was "--", which clients do not + // recognise as the start of a signature. + expect(htmlToPlainText('

Hi

--

User A

')).toBe('Hi\n-- \nUser A'); + }); }); diff --git a/lib/__tests__/signature-utils.test.ts b/lib/__tests__/signature-utils.test.ts index 795e57419..d8e717456 100644 --- a/lib/__tests__/signature-utils.test.ts +++ b/lib/__tests__/signature-utils.test.ts @@ -116,4 +116,23 @@ describe('signature-utils', () => { expect(hasMeaningfulHtmlBody('
Hello world
')).toBe(false); }); }); + + describe('signature delimiter', () => { + // Identities often carry their own "-- " line; the composer's separator on + // top of it showed two delimiter lines. + const own = { textSignature: '-- \nUser A\nstw-test' }; + + it('keeps the space of the "-- " delimiter', () => { + expect(getPlainTextSignature(own)).toBe('-- \nUser A\nstw-test'); + }); + + it('adds no second delimiter when the signature has its own', () => { + expect(appendPlainTextSignature('Hello', own, { separator: true })).toBe('Hello\n\n-- \nUser A\nstw-test'); + expect(appendHtmlSignature('

Hello

', own, { separator: true })).toBe('

Hello



--
User A
stw-test'); + }); + + it('still adds the delimiter for a signature without one', () => { + expect(appendPlainTextSignature('Hello', { textSignature: 'User A' }, { separator: true })).toBe('Hello\n\n-- \nUser A'); + }); + }); }); \ No newline at end of file diff --git a/lib/html-to-text.ts b/lib/html-to-text.ts index 941bbbcb0..64dfb9a3a 100644 --- a/lib/html-to-text.ts +++ b/lib/html-to-text.ts @@ -61,7 +61,11 @@ function normalizeLineBreaks(value: string): string { return value .replace(/\r\n?/g, '\n') .replace(new RegExp(String.fromCharCode(160), 'g'), ' ') - .replace(/[ \t]+\n/g, '\n') + // Trims trailing spaces, but keeps the one that makes "-- " the RFC 3676 + // signature delimiter, so the text part still marks the signature. + .split('\n') + .map((line) => (/^--[ \t]*$/.test(line) ? '-- ' : line.replace(/[ \t]+$/, ''))) + .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } diff --git a/lib/signature-delimiter.ts b/lib/signature-delimiter.ts new file mode 100644 index 000000000..76a513852 --- /dev/null +++ b/lib/signature-delimiter.ts @@ -0,0 +1,27 @@ +import { sanitizeSignatureHtml } from '@/lib/email-sanitization'; +import { htmlToPlainText } from '@/lib/html-to-text'; + +type SignatureSource = { + textSignature?: string; + htmlSignature?: string; +}; + +/** + * Whether the signature starts with its own "-- " delimiter line. Many do + * (imported from other clients, or set on the server), and the composer's own + * separator on top of it showed two delimiter lines. + */ +export function signatureHasOwnDelimiter(signature?: SignatureSource | null): boolean { + const text = signature?.textSignature?.trim() + ? signature.textSignature + : signature?.htmlSignature?.trim() + ? htmlToPlainText(sanitizeSignatureHtml(signature.htmlSignature)) + : ''; + const firstLine = text.replace(/\r\n?/g, '\n').replace(/^\s*\n/, '').split('\n')[0]; + return /^--[ \t\u00a0]*$/.test(firstLine); +} + +/** Whether to add the "-- " separator line in front of this signature. */ +export function wantsSignatureSeparator(enabled: boolean | undefined, signature?: SignatureSource | null): boolean { + return enabled !== false && !signatureHasOwnDelimiter(signature); +} diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index 180ae7854..d130c0d11 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -1,5 +1,6 @@ import { parseHtmlSafely, sanitizeSignatureHtml } from '@/lib/email-sanitization'; import { htmlToPlainText } from '@/lib/html-to-text'; +import { wantsSignatureSeparator } from '@/lib/signature-delimiter'; type SignatureSource = { textSignature?: string; @@ -10,7 +11,11 @@ function normalizeSignatureLineBreaks(value: string): string { return value .replace(/\r\n?/g, '\n') .replace(/\u00a0/g, ' ') - .replace(/[ \t]+\n/g, '\n') + // Trims trailing spaces, but keeps the one that makes "-- " the RFC 3676 + // signature delimiter. + .split('\n') + .map((line) => (/^--[ \t]*$/.test(line) ? '-- ' : line.replace(/[ \t]+$/, ''))) + .join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } @@ -37,7 +42,7 @@ export function appendPlainTextSignature( return body; } - const sep = options.separator === false ? '\n\n' : '\n\n-- \n'; + const sep = wantsSignatureSeparator(options.separator, signature) ? '\n\n-- \n' : '\n\n'; return `${body}${sep}${plainTextSignature}`; } @@ -95,7 +100,7 @@ export function appendHtmlSignature( signature?: SignatureSource | null, options: { separator?: boolean } = {}, ): string { - const sep = options.separator === false ? '

' : '

--
'; + const sep = wantsSignatureSeparator(options.separator, signature) ? '

--
' : '

'; if (signature?.htmlSignature?.trim()) { return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;