From a7de8d19c2d6657a17546fc5db5751c6d8fca4e9 Mon Sep 17 00:00:00 2001 From: r1mo9 Date: Mon, 17 Aug 2026 12:16:02 +0530 Subject: [PATCH] Add plaintext part to multipart emails (#398) --- apps/api/src/jobs/email-processor.ts | 1 + apps/api/src/services/CampaignService.ts | 1 + apps/api/src/services/EmailService.ts | 56 ++++++++++++++- apps/api/src/services/SESService.ts | 23 +++++-- .../services/__tests__/EmailService.test.ts | 69 +++++++++++++++++++ .../docs/concepts/transactional-emails.mdx | 4 ++ 6 files changed, 149 insertions(+), 5 deletions(-) diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 6d8b4a6c9..586eb8861 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -237,6 +237,7 @@ export async function createEmailWorker() { content: { subject: formattedEmail.subject, html: compiledHtml, + text: EmailService.htmlToText(compiledHtml), }, reply: email.replyTo || undefined, headers: outboundHeaders, diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 31591c771..3d95ad5f5 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -817,6 +817,7 @@ export class CampaignService { content: { subject: `[TEST] ${campaign.subject}`, html: campaign.body, + text: EmailService.htmlToText(campaign.body), }, reply: campaign.replyTo || undefined, headers: buildEmailHeaders({ diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index a6cc50670..13e9c2dec 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -427,6 +427,7 @@ export class EmailService { content: { subject: formattedEmail.subject, html: compiledHtml, + text: this.htmlToText(compiledHtml), }, reply: email.replyTo || undefined, headers: outboundHeaders, @@ -677,7 +678,60 @@ export class EmailService { } /** - * Detects if HTML contains custom patterns that indicate it was written in the HTML editor + * Convert compiled email HTML into a plaintext alternative part. + * + * Plunk compiles emails into full HTML documents (prose wrapper, unsubscribe + * footer, badge), so the plaintext is derived from the *compiled* HTML rather + * than the raw body — the text/plain part then carries the same unsubscribe + * link the HTML part does, keeping marketing sends compliant in text-only + * clients. Dependency-free by design: the markup Plunk emits is small and + * known, so a hand-rolled pass is more predictable than a full html-to-text + * dependency. + */ + public static htmlToText(html: string): string { + if (!html) return ''; + + let text = html; + + // Drop style/script blocks entirely (CSS and JS are noise in plaintext). + text = text.replace(//gi, ''); + text = text.replace(//gi, ''); + + // Keep link destinations next to their anchor text so URLs (unsubscribe + // links, article links) survive the conversion. Skip mailto: and fragment + // links, which read as noise in a text client. + text = text.replace(/]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/gi, (match, href: string, inner: string) => { + return /^https?:/i.test(href) ? `${inner} (${href})` : inner; + }); + + // Block-level elements become line breaks. + text = text.replace(/<\/(?:p|div|h[1-6]|li|tr|table|ul|ol|blockquote|pre|section|article|header|footer)>/gi, '\n'); + text = text.replace(/<(?:br|hr)\s*\/?>/gi, '\n'); + text = text.replace(/<\/(?:td|th)>/gi, '\t'); + + // Strip any remaining tags. + text = text.replace(/<[^>]+>/g, ''); + + // Decode common HTML entities. + text = text + .replace(/ /gi, ' ') + .replace(/&/gi, '&') + .replace(/</gi, '<') + .replace(/>/gi, '>') + .replace(/"/gi, '"') + .replace(/'|'/gi, "'"); + + // Normalize whitespace: collapse runs of spaces/tabs and keep single blank lines. + text = text.replace(/[ \t]+/g, ' '); + text = text.replace(/[ \t]*\n[ \t]*/g, '\n'); + text = text.replace(/\n{3,}/g, '\n\n'); + text = text.trim(); + + return text; + } + + /** + * Detect if HTML contains custom patterns that indicate it was written in the HTML editor * rather than the visual editor. Mirrors the same logic in apps/web/src/lib/emailStyles.ts. * * The TipTap editor loads StarterKit + TextAlign + Color + TextStyle + Link + diff --git a/apps/api/src/services/SESService.ts b/apps/api/src/services/SESService.ts index c73c9ad9b..5aba643ab 100644 --- a/apps/api/src/services/SESService.ts +++ b/apps/api/src/services/SESService.ts @@ -32,6 +32,8 @@ interface SendRawEmailParams { content: { subject: string; html: string; + /** Plaintext alternative part. When omitted, the email is HTML-only. */ + text?: string; }; reply?: string; headers?: Record | null; @@ -156,14 +158,27 @@ Content-Type: ${rootContentType}${extraHeaders} rawMessage += `Content-Type: multipart/alternative; boundary="${altBoundary}"\n\n`; } - // The alternative part content (always contains HTML) - rawMessage += `--${altBoundary} + // The alternative part content. Per RFC 2046, the plaintext part must come + // first inside multipart/alternative so clients pick the last part they support + // (HTML when available, plaintext otherwise). + const altParts: string[] = []; + + if (content.text !== undefined && content.text.trim() !== '') { + altParts.push(`--${altBoundary} +Content-Type: text/plain; charset=utf-8 +Content-Transfer-Encoding: 7bit + +${breakLongLines(content.text, 500)}`); + } + + altParts.push(`--${altBoundary} Content-Type: text/html; charset=utf-8 Content-Transfer-Encoding: 7bit ${breakLongLines(content.html, 500)} ---${altBoundary}-- -`; +--${altBoundary}--`); + + rawMessage += altParts.join('\n') + '\n'; // Add inline attachments to the related container if (relatedBoundary) { diff --git a/apps/api/src/services/__tests__/EmailService.test.ts b/apps/api/src/services/__tests__/EmailService.test.ts index 1d5a1e0fa..a64439dfc 100644 --- a/apps/api/src/services/__tests__/EmailService.test.ts +++ b/apps/api/src/services/__tests__/EmailService.test.ts @@ -1100,6 +1100,75 @@ describe('SES MIME Boundary Structure', () => { expect(rawMessage).toContain(`--${relatedBoundary}--`); expect(rawMessage).toContain(`--${mixedBoundary}--`); }); + + it('should emit a plaintext part before the HTML part inside multipart/alternative', async () => { + const {sendRawEmail: realSendRawEmail, ses} = await vi.importActual('../SESService'); + + const params = { + from: {name: 'Sender', email: 'sender@example.com'}, + to: ['recipient@example.com'], + content: {subject: 'Test Subject', html: '

Hello world

', text: 'Hello world'}, + }; + + await realSendRawEmail(params); + + const callArgs = (ses.sendRawEmail as Mock).mock.calls[0][0]; + const rawMessage = new TextDecoder().decode(callArgs.RawMessage.Data); + + // Plaintext part comes first, then the HTML part, both inside alternative. + const plainTextIndex = rawMessage.indexOf('Content-Type: text/plain; charset=utf-8'); + const htmlIndex = rawMessage.indexOf('Content-Type: text/html; charset=utf-8'); + + expect(plainTextIndex).toBeGreaterThan(-1); + expect(htmlIndex).toBeGreaterThan(-1); + expect(plainTextIndex).toBeLessThan(htmlIndex); + expect(rawMessage).toContain('Hello world'); + + // The plaintext part is NOT emitted when no text is provided. + const {sendRawEmail: htmlOnlySendRawEmail} = await vi.importActual('../SESService'); + await htmlOnlySendRawEmail({ + from: {name: 'Sender', email: 'sender@example.com'}, + to: ['recipient@example.com'], + content: {subject: 'Test Subject', html: '

Hello world

'}, + }); + + const htmlOnlyCallArgs = (ses.sendRawEmail as Mock).mock.calls[1][0]; + const htmlOnlyRawMessage = new TextDecoder().decode(htmlOnlyCallArgs.RawMessage.Data); + + expect(htmlOnlyRawMessage).not.toContain('Content-Type: text/plain; charset=utf-8'); + expect(htmlOnlyRawMessage).toContain('Content-Type: text/html; charset=utf-8'); + }); +}); + +describe('EmailService.htmlToText', () => { + it('should convert HTML to readable plaintext', () => { + const html = '

Hello

This is bold text with a link.

'; + const text = EmailService.htmlToText(html); + + expect(text).toContain('Hello'); + expect(text).toContain('This is bold text with a link (https://example.com).'); + }); + + it('should drop style and script blocks', () => { + const html = '

Visible content

'; + const text = EmailService.htmlToText(html); + + expect(text).toContain('Visible content'); + expect(text).not.toContain('color: red'); + expect(text).not.toContain('alert(1)'); + }); + + it('should preserve unsubscribe links', () => { + const html = '

Unsubscribe: here

'; + const text = EmailService.htmlToText(html); + + expect(text).toContain('here (https://app.useplunk.com/unsubscribe/123)'); + }); + + it('should handle empty input', () => { + expect(EmailService.htmlToText('')).toBe(''); + expect(EmailService.htmlToText('

')).toBe(''); + }); }); describe('SES header serialization', () => { diff --git a/apps/wiki/content/docs/concepts/transactional-emails.mdx b/apps/wiki/content/docs/concepts/transactional-emails.mdx index b8b4beed3..ccb1c2cbb 100644 --- a/apps/wiki/content/docs/concepts/transactional-emails.mdx +++ b/apps/wiki/content/docs/concepts/transactional-emails.mdx @@ -11,6 +11,10 @@ Plunk supports sending attachments with transactional emails. By default, you ca The total message size cannot exceed 40 MB. Self-hosters can adjust the defaults — see [Environment variables](/self-hosting/environment-variables). +## Plaintext and HTML in one email + +Every email Plunk sends is a multipart message containing both a plaintext and an HTML part. The plaintext version is generated automatically from the email's HTML at send time, so recipients whose clients prefer plaintext (or that can't render HTML) still get the full message — including the unsubscribe link on marketing emails. You don't need to provide a text version yourself. + ## Sending from a template You can also send transactional emails using a [template](/concepts/templates) you have created in the dashboard. This allows you to reuse the same design and content for multiple emails, while still personalizing them with contact data.