Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/api/src/jobs/email-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ export async function createEmailWorker() {
content: {
subject: formattedEmail.subject,
html: compiledHtml,
text: EmailService.htmlToText(compiledHtml),
},
reply: email.replyTo || undefined,
headers: outboundHeaders,
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/services/CampaignService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
56 changes: 55 additions & 1 deletion apps/api/src/services/EmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,7 @@ export class EmailService {
content: {
subject: formattedEmail.subject,
html: compiledHtml,
text: this.htmlToText(compiledHtml),
},
reply: email.replyTo || undefined,
headers: outboundHeaders,
Expand Down Expand Up @@ -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(/<style[\s\S]*?<\/style>/gi, '');
text = text.replace(/<script[\s\S]*?<\/script>/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(/<a\s+[^>]*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(/&nbsp;/gi, ' ')
.replace(/&amp;/gi, '&')
.replace(/&lt;/gi, '<')
.replace(/&gt;/gi, '>')
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/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 +
Expand Down
23 changes: 19 additions & 4 deletions apps/api/src/services/SESService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> | null;
Expand Down Expand Up @@ -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) {
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/services/__tests__/EmailService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('../SESService')>('../SESService');

const params = {
from: {name: 'Sender', email: 'sender@example.com'},
to: ['recipient@example.com'],
content: {subject: 'Test Subject', html: '<p>Hello world</p>', 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<typeof import('../SESService')>('../SESService');
await htmlOnlySendRawEmail({
from: {name: 'Sender', email: 'sender@example.com'},
to: ['recipient@example.com'],
content: {subject: 'Test Subject', html: '<p>Hello world</p>'},
});

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 = '<h1>Hello</h1><p>This is <strong>bold</strong> text with a <a href="https://example.com">link</a>.</p>';
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 = '<style>.prose { color: red; }</style><p>Visible content</p><script>alert(1)</script>';
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 = '<p>Unsubscribe: <a href="https://app.useplunk.com/unsubscribe/123">here</a></p>';
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('<p></p>')).toBe('');
});
});

describe('SES header serialization', () => {
Expand Down
4 changes: 4 additions & 0 deletions apps/wiki/content/docs/concepts/transactional-emails.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down