Files
simstudioai--sim/apps/sim/tools/gmail/utils.test.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

325 lines
11 KiB
TypeScript

/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import {
buildMimeMessage,
buildSimpleEmailMessage,
encodeRfc2047,
escapeHtml,
htmlToPlainText,
plainTextToHtml,
sanitizeHeaderValue,
} 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', () => {
expect(encodeRfc2047('Simple ASCII Subject')).toBe('Simple ASCII Subject')
})
it('returns empty string unchanged', () => {
expect(encodeRfc2047('')).toBe('')
})
it('encodes emojis as RFC 2047 base64', () => {
const result = encodeRfc2047('Time to Stretch! 🧘')
expect(result).toBe('=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?=')
})
it('round-trips non-ASCII subjects correctly', () => {
const subjects = ['Hello 世界', 'Café résumé', '🎉🎊🎈 Party!', '今週のミーティング']
for (const subject of subjects) {
const encoded = encodeRfc2047(subject)
const match = encoded.match(/^=\?UTF-8\?B\?(.+)\?=$/)
expect(match).not.toBeNull()
const decoded = Buffer.from(match![1], 'base64').toString('utf-8')
expect(decoded).toBe(subject)
}
})
it('does not double-encode already-encoded subjects', () => {
const alreadyEncoded = '=?UTF-8?B?VGltZSB0byBTdHJldGNoISDwn6eY?='
expect(encodeRfc2047(alreadyEncoded)).toBe(alreadyEncoded)
})
})
describe('sanitizeHeaderValue', () => {
it('collapses embedded CRLF to a single space', () => {
expect(sanitizeHeaderValue('foo\r\nBcc: attacker@example.com')).toBe(
'foo Bcc: attacker@example.com'
)
})
it('collapses embedded bare LF or CR to a single space', () => {
expect(sanitizeHeaderValue('foo\nbar')).toBe('foo bar')
expect(sanitizeHeaderValue('foo\rbar')).toBe('foo bar')
})
it('collapses consecutive newlines to a single space', () => {
expect(sanitizeHeaderValue('foo\r\n\r\nbar')).toBe('foo bar')
})
it('leaves ordinary values unchanged', () => {
expect(sanitizeHeaderValue('a@example.com, b@example.com')).toBe('a@example.com, b@example.com')
})
})
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('converts newlines to <br> without paragraph margins', () => {
const html = plainTextToHtml('Hi Janice,\n\nHope you are well.\nSecond line.')
expect(html).not.toContain('<p>')
expect(html).toContain('Hi Janice,<br><br>Hope you are well.<br>Second line.')
})
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 and decodes entities', () => {
const result = htmlToPlainText('<p>Hi &amp; bye</p><p>Line<br>break</p>')
expect(result).toBe('Hi & bye\n\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; and&#x2019;s</p>')).toBe(
'\u201chi\u201d and\u2019s'
)
})
it('preserves &#160; (non-breaking space) as U+00A0 for fidelity in plain-text output', () => {
expect(htmlToPlainText('<p>a&#160;b</p>')).toBe('a\u00a0b')
})
it('elides anchor URLs that exactly match link text, and drops bare # anchors', () => {
expect(
htmlToPlainText('<p>Visit <a href="https://example.com">https://example.com</a></p>')
).toBe('Visit https://example.com')
expect(htmlToPlainText('<p><a href="#section">Anchor</a></p>')).toBe('Anchor')
})
})
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('Hi Janice,<br><br>Quick question.')
})
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>')
})
it('strips embedded CRLF from to/cc/bcc/subject/inReplyTo/references so no extra header line is produced', () => {
const injected = 'innocuous\r\nBcc: attacker@example.com'
const encoded = buildSimpleEmailMessage({
to: injected,
cc: injected,
bcc: injected,
subject: injected,
body: 'hello',
inReplyTo: injected,
references: injected,
})
const decoded = decodeSimpleMessage(encoded)
const lines = decoded.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
// Exactly one Bcc header line (the legitimate one), holding the sanitized, single-line value.
expect(bccLines).toHaveLength(1)
expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com')
expect(lines).not.toContain('Bcc: attacker@example.com')
})
it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => {
const encoded = buildSimpleEmailMessage({
to: 'a@example.com, b@example.com',
cc: 'c@example.com',
subject: 'Café meeting 🎉',
body: 'hello',
})
const decoded = decodeSimpleMessage(encoded)
expect(decoded).toContain('To: a@example.com, b@example.com')
expect(decoded).toContain('Cc: c@example.com')
expect(decoded).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`)
})
})
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('Hello')
})
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')
})
it('strips embedded CRLF from header fields and the attachment filename', () => {
const injected = 'innocuous\r\nBcc: attacker@example.com'
const message = buildMimeMessage({
to: injected,
cc: injected,
bcc: injected,
subject: injected,
body: 'hello',
inReplyTo: injected,
references: injected,
attachments: [
{
filename: injected,
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
const lines = message.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
expect(bccLines).toHaveLength(1)
expect(bccLines[0]).toBe('Bcc: innocuous Bcc: attacker@example.com')
expect(lines).not.toContain('Bcc: attacker@example.com')
expect(message).toContain(
'Content-Disposition: attachment; filename="innocuous Bcc: attacker@example.com"'
)
})
it('strips embedded CRLF from the attachment mimeType', () => {
const injected = 'text/plain\r\nBcc: attacker@example.com'
const message = buildMimeMessage({
to: 'a@example.com',
body: 'hello',
attachments: [
{
filename: 'note.txt',
mimeType: injected,
content: Buffer.from('hi'),
},
],
})
const lines = message.split('\n')
const bccLines = lines.filter((line) => line.startsWith('Bcc:'))
expect(bccLines).toHaveLength(0)
expect(message).toContain('Content-Type: text/plain Bcc: attacker@example.com')
})
it('preserves legitimate ASCII, Unicode, and multi-recipient values unchanged', () => {
const message = buildMimeMessage({
to: 'a@example.com, b@example.com',
cc: 'c@example.com',
subject: 'Café meeting 🎉',
body: 'hello',
attachments: [
{
filename: 'note.txt',
mimeType: 'text/plain',
content: Buffer.from('hi'),
},
],
})
expect(message).toContain('To: a@example.com, b@example.com')
expect(message).toContain('Cc: c@example.com')
expect(message).toContain(`Subject: ${encodeRfc2047('Café meeting 🎉')}`)
expect(message).toContain('Content-Disposition: attachment; filename="note.txt"')
})
})