Skip to content
Merged
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
157 changes: 156 additions & 1 deletion apps/sim/tools/gmail/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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')
})
})
144 changes: 123 additions & 21 deletions apps/sim/tools/gmail/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
waleedlatif1 marked this conversation as resolved.
}

/**
* 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: {
Expand All @@ -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}`)
Expand All @@ -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')
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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')
Expand All @@ -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')
Expand Down
Loading