mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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:
@@ -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(
|
||||
'<script>alert("x & y's")</script>'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
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('<b>bold</b>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlToPlainText', () => {
|
||||
it('strips tags, decodes entities, and collapses whitespace', () => {
|
||||
const result = htmlToPlainText('<p>Hi & 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 &lt;', () => {
|
||||
expect(htmlToPlainText('<p>&lt; is the literal < entity</p>')).toBe(
|
||||
'< is the literal < entity'
|
||||
)
|
||||
})
|
||||
|
||||
it('decodes decimal and hexadecimal numeric entities', () => {
|
||||
expect(htmlToPlainText('<p>“hi”  and’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
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(/ /g, ' ')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/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(/&/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')
|
||||
|
||||
Reference in New Issue
Block a user