fix(gmail): send emails as multipart/alternative so they render full-width (#4611)

* fix(gmail): send emails as multipart/alternative so they render full-width

* fix(gmail): decode & last in htmlToPlainText to avoid double-decoding compound entities

* fix(gmail): encode body parts as base64 and decode numeric HTML entities
This commit is contained in:
Waleed
2026-05-14 19:26:14 -07:00
committed by GitHub
parent 922de38d93
commit e23c20e1eb
2 changed files with 279 additions and 22 deletions
+156 -1
View File
@@ -2,7 +2,32 @@
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { encodeRfc2047 } from './utils'
import {
buildMimeMessage,
buildSimpleEmailMessage,
encodeRfc2047,
escapeHtml,
htmlToPlainText,
plainTextToHtml,
} from './utils'
function decodeSimpleMessage(encoded: string): string {
return Buffer.from(encoded, 'base64url').toString('utf-8')
}
/**
* Extract and base64-decode the body of a specific MIME part identified by its
* Content-Type prefix (e.g. `text/plain`, `text/html`). Returns the decoded
* UTF-8 string.
*/
function decodePart(mime: string, contentTypePrefix: string): string {
const partRegex = new RegExp(
`Content-Type: ${contentTypePrefix}[^\\n]*\\nContent-Transfer-Encoding: base64\\n\\n([\\s\\S]*?)\\n\\n--`
)
const match = mime.match(partRegex)
if (!match) throw new Error(`No ${contentTypePrefix} part found`)
return Buffer.from(match[1].replace(/\n/g, ''), 'base64').toString('utf-8')
}
describe('encodeRfc2047', () => {
it('returns ASCII text unchanged', () => {
@@ -34,3 +59,133 @@ describe('encodeRfc2047', () => {
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
})
})
describe('escapeHtml', () => {
it('escapes the five HTML special characters', () => {
expect(escapeHtml(`<script>alert("x & y's")</script>`)).toBe(
'&lt;script&gt;alert(&quot;x &amp; y&#39;s&quot;)&lt;/script&gt;'
)
})
})
describe('plainTextToHtml', () => {
it('renders blank lines as paragraph breaks and single newlines as <br>', () => {
const html = plainTextToHtml('Hi Janice,\n\nHope you are well.\nSecond line.')
expect(html).toContain('<p>Hi Janice,</p>')
expect(html).toContain('<p>Hope you are well.<br>Second line.</p>')
})
it('escapes HTML in the source text', () => {
expect(plainTextToHtml('<b>bold</b>')).toContain('&lt;b&gt;bold&lt;/b&gt;')
})
})
describe('htmlToPlainText', () => {
it('strips tags, decodes entities, and collapses whitespace', () => {
const result = htmlToPlainText('<p>Hi &amp; bye</p><p>Line<br>break</p>')
expect(result).toBe('Hi & bye\nLine\nbreak')
})
it('drops <style> and <script> contents', () => {
expect(htmlToPlainText('<style>p{}</style><p>Hi</p>')).toBe('Hi')
})
it('does not double-decode compound entities like &amp;lt;', () => {
expect(htmlToPlainText('<p>&amp;lt; is the literal &lt; entity</p>')).toBe(
'&lt; is the literal < entity'
)
})
it('decodes decimal and hexadecimal numeric entities', () => {
expect(htmlToPlainText('<p>&#8220;hi&#8221; &#160;and&#x2019;s</p>')).toBe(
'\u201chi\u201d \u00a0and\u2019s'
)
})
})
describe('buildSimpleEmailMessage', () => {
it('emits multipart/alternative with text/plain then text/html for plain-text input', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hi Janice,\n\nQuick question.',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
const plainIdx = decoded.indexOf('text/plain')
const htmlIdx = decoded.indexOf('text/html')
expect(plainIdx).toBeGreaterThan(-1)
expect(htmlIdx).toBeGreaterThan(plainIdx)
expect(decodePart(decoded, 'text/plain')).toBe('Hi Janice,\n\nQuick question.')
expect(decodePart(decoded, 'text/html')).toContain('<p>Hi Janice,</p>')
})
it('encodes bodies as base64 so UTF-8 (emoji, accents) round-trips cleanly', () => {
const body = 'Café 🎉 — résumé'
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body,
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('Content-Transfer-Encoding: base64')
expect(decodePart(decoded, 'text/plain')).toBe(body)
expect(decodePart(decoded, 'text/html')).toContain('Café 🎉 — résumé')
})
it('uses the supplied HTML body and derives a plain-text fallback when contentType is html', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
subject: 'Hi',
body: '<p>Hello <b>there</b></p>',
contentType: 'html',
})
const decoded = decodeSimpleMessage(encoded)
expect(decodePart(decoded, 'text/html')).toBe('<p>Hello <b>there</b></p>')
expect(decodePart(decoded, 'text/plain')).toBe('Hello there')
})
it('includes threading headers when replying', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com',
body: 'reply',
inReplyTo: '<msg-1@example.com>',
references: '<root@example.com>',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('In-Reply-To: <msg-1@example.com>')
expect(decoded).toContain('References: <root@example.com> <msg-1@example.com>')
})
})
describe('buildMimeMessage', () => {
it('nests multipart/alternative inside multipart/mixed when attachments are present', () => {
const message = buildMimeMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hello',
attachments: [
{
filename: 'note.txt',
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
expect(message).toMatch(/Content-Type: multipart\/mixed; boundary="([^"]+)"/)
expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
expect(message).toContain('Content-Disposition: attachment; filename="note.txt"')
expect(decodePart(message, 'text/plain')).toBe('Hello')
expect(decodePart(message, 'text/html')).toContain('<p>Hello</p>')
})
it('emits multipart/alternative without multipart/mixed when no attachments', () => {
const message = buildMimeMessage({
to: 'a@example.com',
subject: 'Hi',
body: 'Hello',
})
expect(message).toMatch(/Content-Type: multipart\/alternative; boundary="([^"]+)"/)
expect(message).not.toContain('multipart/mixed')
})
})
+123 -21
View File
@@ -317,8 +317,108 @@ export function base64UrlEncode(data: string | Buffer): string {
}
/**
* Build a simple text email message (without attachments)
* @param params Email parameters including recipients, subject, body, and threading info
* Escape HTML special characters so user-supplied text renders safely inside an HTML body.
*/
export function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
/**
* Convert a plain-text body to an HTML body that flows naturally in Gmail.
* Blank lines become paragraph breaks; single newlines become `<br>`.
* This avoids the narrow hard-wrapped rendering Gmail uses for `text/plain`.
*/
export function plainTextToHtml(body: string): string {
const normalized = body.replace(/\r\n/g, '\n').replace(/\r/g, '\n')
const paragraphs = normalized.split(/\n{2,}/)
const htmlParagraphs = paragraphs.map((paragraph) => {
const escaped = escapeHtml(paragraph).replace(/\n/g, '<br>')
return `<p>${escaped}</p>`
})
return `<!DOCTYPE html><html><body>${htmlParagraphs.join('')}</body></html>`
}
/**
* Best-effort conversion of an HTML body to a plain-text fallback. Strips tags
* and decodes the common entities. Used so we always include a plain-text part
* alongside HTML for clients that don't render HTML.
*/
export function htmlToPlainText(html: string): string {
return html
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<\/(p|div|h[1-6]|li|tr)>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, ' ')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
.replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number.parseInt(dec, 10)))
.replace(/&amp;/g, '&')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/**
* Produce the plain-text and HTML representations of a body so we can emit a
* `multipart/alternative` section. Gmail renders the HTML version full-width
* (matching how a manually-composed email looks); the plain-text part is the
* fallback for clients that don't render HTML.
*/
export function buildBodyAlternatives(
body: string,
contentType: 'text' | 'html' | undefined
): { plain: string; html: string } {
if (contentType === 'html') {
return { plain: htmlToPlainText(body) || body, html: body }
}
return { plain: body, html: plainTextToHtml(body) }
}
/**
* Encode a text body as base64 with RFC 2045 line wrapping (max 76 chars).
* Using base64 lets us safely transport arbitrary UTF-8 (emoji, accented
* characters, etc.) — `7bit` is only valid for strict 7-bit ASCII.
*/
function encodeBodyBase64(content: string): string[] {
const base64 = Buffer.from(content, 'utf-8').toString('base64')
return base64.match(/.{1,76}/g) || ['']
}
/**
* Render the inner part of a `multipart/alternative` section (text/plain
* followed by text/html, per RFC 2046 — clients pick the last format they
* understand).
*/
function renderAlternativeParts(plain: string, html: string, boundary: string): string[] {
return [
`--${boundary}`,
'Content-Type: text/plain; charset="UTF-8"',
'Content-Transfer-Encoding: base64',
'',
...encodeBodyBase64(plain),
'',
`--${boundary}`,
'Content-Type: text/html; charset="UTF-8"',
'Content-Transfer-Encoding: base64',
'',
...encodeBodyBase64(html),
'',
`--${boundary}--`,
]
}
/**
* Build a `multipart/alternative` email (without attachments). Always emits
* both text/plain and text/html parts so Gmail renders messages full-width
* like a hand-composed email.
* @returns Base64url encoded raw message
*/
export function buildSimpleEmailMessage(params: {
@@ -332,12 +432,10 @@ export function buildSimpleEmailMessage(params: {
references?: string
}): string {
const { to, cc, bcc, subject, body, contentType, inReplyTo, references } = params
const mimeContentType = contentType === 'html' ? 'text/html' : 'text/plain'
const emailHeaders = [
`Content-Type: ${mimeContentType}; charset="UTF-8"`,
'MIME-Version: 1.0',
`To: ${to}`,
]
const boundary = generateBoundary()
const { plain, html } = buildBodyAlternatives(body, contentType)
const emailHeaders = ['MIME-Version: 1.0', `To: ${to}`]
if (cc) {
emailHeaders.push(`Cc: ${cc}`)
@@ -354,7 +452,10 @@ export function buildSimpleEmailMessage(params: {
emailHeaders.push(`References: ${referencesChain}`)
}
emailHeaders.push('', body)
emailHeaders.push(`Content-Type: multipart/alternative; boundary="${boundary}"`)
emailHeaders.push('')
emailHeaders.push(...renderAlternativeParts(plain, html, boundary))
const email = emailHeaders.join('\n')
return Buffer.from(email).toString('base64url')
}
@@ -382,9 +483,8 @@ export interface BuildMimeMessageParams {
export function buildMimeMessage(params: BuildMimeMessageParams): string {
const { to, cc, bcc, subject, body, contentType, inReplyTo, references, attachments } = params
const boundary = generateBoundary()
const messageParts: string[] = []
const mimeContentType = contentType === 'html' ? 'text/html' : 'text/plain'
const { plain, html } = buildBodyAlternatives(body, contentType)
messageParts.push(`To: ${to}`)
if (cc) {
@@ -408,17 +508,19 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
messageParts.push('MIME-Version: 1.0')
if (attachments && attachments.length > 0) {
messageParts.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
const mixedBoundary = generateBoundary()
const altBoundary = generateBoundary()
messageParts.push(`Content-Type: multipart/mixed; boundary="${mixedBoundary}"`)
messageParts.push('')
messageParts.push(`--${boundary}`)
messageParts.push(`Content-Type: ${mimeContentType}; charset="UTF-8"`)
messageParts.push('Content-Transfer-Encoding: 7bit')
messageParts.push(`--${mixedBoundary}`)
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
messageParts.push('')
messageParts.push(body)
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
messageParts.push('')
for (const attachment of attachments) {
messageParts.push(`--${boundary}`)
messageParts.push(`--${mixedBoundary}`)
messageParts.push(`Content-Type: ${attachment.mimeType}`)
messageParts.push(`Content-Disposition: attachment; filename="${attachment.filename}"`)
messageParts.push('Content-Transfer-Encoding: base64')
@@ -430,12 +532,12 @@ export function buildMimeMessage(params: BuildMimeMessageParams): string {
messageParts.push('')
}
messageParts.push(`--${boundary}--`)
messageParts.push(`--${mixedBoundary}--`)
} else {
messageParts.push(`Content-Type: ${mimeContentType}; charset="UTF-8"`)
messageParts.push('MIME-Version: 1.0')
const altBoundary = generateBoundary()
messageParts.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
messageParts.push('')
messageParts.push(body)
messageParts.push(...renderAlternativeParts(plain, html, altBoundary))
}
return messageParts.join('\n')