diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 6d8b4a6c..586eb886 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 31591c77..3d95ad5f 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 a6cc5067..13e9c2de 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(/
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 b8b4beed..ccb1c2cb 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.