From b5f2f9b12d145f40bcee317c5eed665a51180d74 Mon Sep 17 00:00:00 2001 From: ProgrammerIn-wonderland <3838shah@gmail.com> Date: Fri, 11 Sep 2026 21:05:32 -0400 Subject: [PATCH] tests and validation checks --- src/puter-js/package.json | 1 + src/puter-js/src/modules/Email/ComposerLib.js | 27 +- .../src/modules/Email/ComposerLib.test.js | 598 ++++++++++++++++++ 3 files changed, 616 insertions(+), 10 deletions(-) create mode 100644 src/puter-js/src/modules/Email/ComposerLib.test.js diff --git a/src/puter-js/package.json b/src/puter-js/package.json index b29c598c8..9e60eaa1e 100644 --- a/src/puter-js/package.json +++ b/src/puter-js/package.json @@ -54,6 +54,7 @@ "@heyputer/kv.js": "^0.2.1", "open": "^10.2.0", "path-browserify": "1.0.1", + "postal-mime": "^3.0.0", "socket.io-client": "^4.8.3" } } diff --git a/src/puter-js/src/modules/Email/ComposerLib.js b/src/puter-js/src/modules/Email/ComposerLib.js index dd7f4e16d..e6a83dbc9 100644 --- a/src/puter-js/src/modules/Email/ComposerLib.js +++ b/src/puter-js/src/modules/Email/ComposerLib.js @@ -33,12 +33,12 @@ */ -function getRFC822DateUTC(date = new Date()) { +export function getRFC822DateUTC(date = new Date()) { // .toUTCString() returns: "Fri, 11 Sep 2026 21:30:00 GMT" return date.toUTCString().replace('GMT', '+0000'); } -function emlHeader(key, value) { +export function emlHeader(key, value) { if (!isASCII(key)) { throw new Error("eml header key " + key + " is not valid ASCII"); } @@ -53,13 +53,13 @@ function emlHeader(key, value) { return `${key}: ${value.join(', ')}\r\n`; } else { if (!isASCII(value)) { - throw new Error("eml header value " + val + " is not valid ASCII"); + throw new Error("eml header value " + value + " is not valid ASCII"); } return `${key}: ${value}\r\n`; } } -function determineTopLevelMimeType({ attachments = [], text, html }) { +export function determineTopLevelMimeType({ attachments = [], text, html }) { const inlineAttachments = attachments.filter(a => a.cid); const regularAttachments = attachments.filter(a => !a.cid); @@ -94,7 +94,7 @@ async function blobToBase64(blob) { return btoa(binary); } -const isASCII = (str) => /^[\x20-\x7E]*$/.test(str); +export const isASCII = (str) => /^[\x20-\x7E]*$/.test(str); @@ -103,10 +103,16 @@ const isASCII = (str) => /^[\x20-\x7E]*$/.test(str); * * @param {EmailAttachment} attachment */ -async function composeAttachment({ cid, content, contentType, filename, path, uid }) { +export async function composeAttachment({ cid, content, contentType, filename, path, uid }) { if (path && content) { throw new Error("Path and Content are mutually exclusive"); } + if (!path && !content && !uid) { + throw new Error("Attachment requires content, path, or uid"); + } + if (uid) { + throw new Error("uid attachments are not supported yet"); + } if (contentType && !filename) { throw new Error("filename parameter required for contentType"); } @@ -155,15 +161,17 @@ async function composeAttachment({ cid, content, contentType, filename, path, ui return headerLines + b64emailContent; } -function combineParts(parts, boundary) { - return parts.map(part => `--${boundary}\r\n${part}`).join('') + `--${boundary}--\r\n`; +export function combineParts(parts, boundary) { + // The CRLF before each delimiter belongs to the delimiter, not the part, + // so parts never need a trailing line break of their own. + return parts.map(part => `--${boundary}\r\n${part}\r\n`).join('') + `--${boundary}--\r\n`; } /** * * @param {EmailComposeOptions} param0 */ -export async function compose({ from, to, cc, bcc, subject, replyTo, attachments, text, html }) { +export async function compose({ from, to, cc, bcc, subject, replyTo, attachments = [], text, html }) { // --- Header constructing logic --- let headerLines = "MIME-Version: 1.0\r\n"; @@ -240,7 +248,6 @@ export async function compose({ from, to, cc, bcc, subject, replyTo, attachments // Inline (cid) attachments: wrap content + inline images in multipart/related. // If there are no regular attachments, related IS the top level (reuse boundary1). // Otherwise it nests one level inside multipart/mixed with its own boundary. - // Otherwise it nests one level inside multipart/mixed with its own boundary. let nextLevelPart; if (inlineAttachments.length > 0) { const relatedChildren = [contentPart, ...inlineParts].filter(Boolean); diff --git a/src/puter-js/src/modules/Email/ComposerLib.test.js b/src/puter-js/src/modules/Email/ComposerLib.test.js new file mode 100644 index 000000000..db73e119a --- /dev/null +++ b/src/puter-js/src/modules/Email/ComposerLib.test.js @@ -0,0 +1,598 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import PostalMime from 'postal-mime'; +import { + combineParts, + compose, + composeAttachment, + determineTopLevelMimeType, + emlHeader, + getRFC822DateUTC, + isASCII, +} from './ComposerLib.js'; + +/** + * ComposerLib hand-rolls RFC 822 output with no MIME library, so these pin + * both the structure of each MIME shape and that a real parser recovers the + * bodies and attachments from what it produces. + */ + +const origPuter = globalThis.puter; + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a]); +const b64 = (str) => btoa(str); +const bytesToB64 = (bytes) => btoa(String.fromCharCode(...bytes)); + +let fsRead; +let getUser; + +beforeEach(() => { + fsRead = vi.fn(async () => new Blob([PNG_BYTES], { type: 'image/png' })); + getUser = vi.fn(async () => ({ username: 'alice' })); + globalThis.puter = { fs: { read: fsRead }, getUser }; +}); + +afterEach(() => { + globalThis.puter = origPuter; + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +// -- EML structure helpers -- + +/** Split one message or part into its header block and body at the first blank line. */ +const splitEml = (eml) => { + const i = eml.indexOf('\r\n\r\n'); + expect(i, 'blank line between headers and body').toBeGreaterThan(-1); + return { head: eml.slice(0, i), body: eml.slice(i + 4) }; +}; + +/** Header block -> Map of lower-cased name -> every value seen for it. */ +const parseHeaders = (head) => { + const headers = new Map(); + for ( const line of head.split('\r\n') ) { + const i = line.indexOf(': '); + expect(i, `header line "${line}"`).toBeGreaterThan(0); + const key = line.slice(0, i).toLowerCase(); + if ( !headers.has(key) ) headers.set(key, []); + headers.get(key).push(line.slice(i + 2)); + } + return headers; +}; + +/** The single value of a header, failing if it is missing or repeated. */ +const header = (headers, name) => { + const values = headers.get(name.toLowerCase()); + expect(values, `header ${name}`).toHaveLength(1); + return values[0]; +}; + +const boundaryOf = (contentType) => { + const match = contentType.match(/^multipart\/\w+; boundary="([^"]+)"$/); + expect(match, `multipart content-type "${contentType}"`).not.toBeNull(); + return match[1]; +}; + +/** Body of a multipart entity -> the raw parts between its delimiters. */ +const splitParts = (body, boundary) => { + const closing = `--${boundary}--\r\n`; + expect(body.endsWith(closing), 'closing delimiter').toBe(true); + const chunks = body.slice(0, -closing.length).split(`--${boundary}\r\n`); + expect(chunks[0], 'nothing before the first delimiter').toBe(''); + // The CRLF before each delimiter belongs to the delimiter. + return chunks.slice(1).map((chunk) => { + expect(chunk.endsWith('\r\n')).toBe(true); + return chunk.slice(0, -2); + }); +}; + +/** A raw part -> its parsed headers and body. */ +const parsePart = (part) => { + const { head, body } = splitEml(part); + return { headers: parseHeaders(head), body }; +}; + +/** Parse a message or part and, when multipart, its children. */ +const parseEntity = (raw) => { + const entity = parsePart(raw); + const contentType = header(entity.headers, 'Content-Type'); + if ( contentType.startsWith('multipart/') ) { + entity.boundary = boundaryOf(contentType); + entity.children = splitParts(entity.body, entity.boundary).map(parseEntity); + } + return entity; +}; + +const contentTypeOf = (entity) => header(entity.headers, 'Content-Type'); +const mediaTypeOf = (entity) => contentTypeOf(entity).split(';')[0]; + +const baseOptions = { to: 'bob@example.com', subject: 'hi' }; +const regular = { filename: 'a.txt', contentType: 'text/plain', content: b64('AAA') }; +const inline = { path: '~/pics/x.png', cid: 'img1' }; + +describe('determineTopLevelMimeType', () => { + it('picks text/plain for text alone', () => { + expect(determineTopLevelMimeType({ text: 'hi' })) + .toEqual({ isMultiPart: false, mimeType: 'text/plain; charset=UTF-8' }); + }); + + it('picks text/html for html alone', () => { + expect(determineTopLevelMimeType({ html: '

hi

' })) + .toEqual({ isMultiPart: false, mimeType: 'text/html; charset=UTF-8' }); + }); + + it('picks multipart/alternative for text plus html with no attachments', () => { + expect(determineTopLevelMimeType({ text: 'hi', html: '

hi

', attachments: [] })) + .toEqual({ isMultiPart: true, mimeType: 'multipart/alternative' }); + }); + + it('picks multipart/mixed whenever a regular attachment is present', () => { + expect(determineTopLevelMimeType({ text: 'hi', attachments: [regular] }).mimeType) + .toBe('multipart/mixed'); + expect(determineTopLevelMimeType({ text: 'hi', html: '

hi

', attachments: [regular] }).mimeType) + .toBe('multipart/mixed'); + }); + + it('picks multipart/related when every attachment is inline', () => { + expect(determineTopLevelMimeType({ html: '

hi

', attachments: [inline] })) + .toEqual({ isMultiPart: true, mimeType: 'multipart/related' }); + }); + + it('lets a regular attachment win over inline ones', () => { + expect(determineTopLevelMimeType({ html: '

hi

', attachments: [inline, regular] }).mimeType) + .toBe('multipart/mixed'); + }); + + it('throws when there is no text, html, or attachment', () => { + expect(() => determineTopLevelMimeType({})).toThrow(/text, html, or an attachment/); + expect(() => determineTopLevelMimeType({ attachments: [] })).toThrow(/text, html, or an attachment/); + }); + + it('treats an omitted attachments list as empty', () => { + expect(() => determineTopLevelMimeType({ text: 'hi' })).not.toThrow(); + }); +}); + +describe('emlHeader', () => { + it('formats a scalar value as one CRLF-terminated line', () => { + expect(emlHeader('Subject', 'hello')).toBe('Subject: hello\r\n'); + }); + + it('joins array values with a comma and space', () => { + expect(emlHeader('To', ['a@x.com', 'b@x.com'])).toBe('To: a@x.com, b@x.com\r\n'); + }); + + it('rejects a non-ASCII key', () => { + expect(() => emlHeader('Sujét', 'x')).toThrow(/key .* is not valid ASCII/); + }); + + it('rejects a non-ASCII scalar value with a message naming it', () => { + expect(() => emlHeader('Subject', 'héllo')).toThrow(Error); + expect(() => emlHeader('Subject', 'héllo')).toThrow(/value héllo is not valid ASCII/); + }); + + it('rejects a non-ASCII value inside an array', () => { + expect(() => emlHeader('To', ['a@x.com', 'ü@x.com'])).toThrow(/value ü@x.com is not valid ASCII/); + }); + + it('allows an empty value', () => { + expect(emlHeader('Subject', '')).toBe('Subject: \r\n'); + }); +}); + +describe('isASCII', () => { + it('accepts printable ASCII', () => { + expect(isASCII('Hello, World! ~')).toBe(true); + }); + + it('accepts the empty string', () => { + expect(isASCII('')).toBe(true); + }); + + it('rejects accented and non-Latin characters', () => { + expect(isASCII('héllo')).toBe(false); + expect(isASCII('日本語')).toBe(false); + expect(isASCII('hi 👋')).toBe(false); + }); + + it('rejects control characters', () => { + expect(isASCII('a\nb')).toBe(false); + expect(isASCII('a\tb')).toBe(false); + }); +}); + +describe('getRFC822DateUTC', () => { + it('formats a fixed date with a numeric UTC offset', () => { + const date = new Date(Date.UTC(2026, 8, 11, 21, 30, 0)); + expect(getRFC822DateUTC(date)).toBe('Fri, 11 Sep 2026 21:30:00 +0000'); + }); + + it('defaults to now', () => { + vi.useFakeTimers({ now: new Date(Date.UTC(2026, 0, 2, 3, 4, 5)) }); + expect(getRFC822DateUTC()).toBe('Fri, 02 Jan 2026 03:04:05 +0000'); + }); +}); + +describe('composeAttachment', () => { + it('builds a base64 attachment part from inline content', async () => { + const part = await composeAttachment(regular); + expect(part).toBe( + 'Content-Type: text/plain;name="a.txt"\r\n' + + 'Content-Transfer-Encoding: base64\r\n' + + 'Content-Disposition: attachment;filename="a.txt"\r\n' + + '\r\n' + + b64('AAA'), + ); + }); + + it('separates the Content-Type key and value with exactly one colon', async () => { + const { headers } = parsePart(await composeAttachment(regular)); + expect(header(headers, 'Content-Type')).toBe('text/plain;name="a.txt"'); + expect(header(headers, 'Content-Type')).not.toMatch(/^:/); + }); + + it('marks a cid attachment inline and carries the cid in Content-ID', async () => { + const { headers } = parsePart(await composeAttachment({ ...regular, cid: 'logo@puter' })); + expect(header(headers, 'Content-ID')).toBe(''); + expect(header(headers, 'Content-Disposition')).toBe('inline;filename="a.txt"'); + }); + + it('reads a path from the filesystem and infers the type from the blob', async () => { + const { headers, body } = parsePart(await composeAttachment({ path: '~/docs/report.pdf' })); + expect(fsRead).toHaveBeenCalledWith('~/docs/report.pdf'); + expect(header(headers, 'Content-Type')).toBe('image/png;name="report.pdf"'); + expect(body).toBe(bytesToB64(PNG_BYTES)); + }); + + it('lets an explicit contentType override the blob type', async () => { + const { headers } = parsePart(await composeAttachment({ + path: '~/docs/report.pdf', + filename: 'report.pdf', + contentType: 'application/pdf', + })); + expect(header(headers, 'Content-Type')).toBe('application/pdf;name="report.pdf"'); + }); + + it('derives the filename from the last path segment', async () => { + const { headers } = parsePart(await composeAttachment({ path: '~/docs/report.pdf' })); + expect(header(headers, 'Content-Disposition')).toBe('attachment;filename="report.pdf"'); + }); + + it('escapes quotes and backslashes in the filename', async () => { + const { headers } = parsePart(await composeAttachment({ + ...regular, + filename: 'a"b\\c.txt', + })); + expect(header(headers, 'Content-Type')).toBe('text/plain;name="a\\"b\\\\c.txt"'); + expect(header(headers, 'Content-Disposition')).toBe('attachment;filename="a\\"b\\\\c.txt"'); + }); + + it('rejects content without a filename', async () => { + await expect(composeAttachment({ content: b64('AAA') })) + .rejects.toThrow(/filename cannot be determined automatically/); + }); + + it('rejects content with a filename but no contentType', async () => { + await expect(composeAttachment({ content: b64('AAA'), filename: 'a.txt' })) + .rejects.toThrow(/mimetype cannot be determined automatically/); + }); + + it('rejects contentType without a filename', async () => { + await expect(composeAttachment({ content: b64('AAA'), contentType: 'text/plain' })) + .rejects.toThrow(/filename parameter required for contentType/); + }); + + it('rejects path together with content', async () => { + await expect(composeAttachment({ ...regular, path: '~/a.txt' })) + .rejects.toThrow(/mutually exclusive/); + }); + + it('rejects an attachment with no content, path, or uid', async () => { + await expect(composeAttachment({ filename: 'a.txt', contentType: 'text/plain' })) + .rejects.toThrow(/requires content, path, or uid/); + }); + + it('rejects a non-ASCII filename', async () => { + await expect(composeAttachment({ ...regular, filename: 'résumé.txt' })) + .rejects.toThrow(/filename must be ascii/); + }); + + it('rejects a uid attachment until that branch exists', async () => { + await expect(composeAttachment({ uid: 'uid-1' })).rejects.toThrow(/not supported yet/); + }); + + // The FS-uid branch is declared in the typedef but not implemented. + it.todo('reads a uid attachment from the filesystem'); +}); + +describe('combineParts', () => { + it('fences each part with the boundary and closes the body', () => { + expect(combineParts(['partA', 'partB'], 'B')) + .toBe('--B\r\npartA\r\n--B\r\npartB\r\n--B--\r\n'); + }); + + it('supplies the CRLF before every delimiter itself', () => { + // A part ending without a line break still starts the next delimiter on its own line. + const body = combineParts(['no trailing newline'], 'B'); + expect(body).toBe('--B\r\nno trailing newline\r\n--B--\r\n'); + }); + + it('fences a single part', () => { + expect(splitParts(combineParts(['only'], 'B'), 'B')).toEqual(['only']); + }); + + it('produces only the closing delimiter for no parts', () => { + expect(combineParts([], 'B')).toBe('--B--\r\n'); + }); +}); + +describe('compose', () => { + const composeAndParse = async (options) => { + const eml = await compose({ ...baseOptions, ...options }); + return { eml, ...parseEntity(eml) }; + }; + + it('writes MIME-Version and a Date before the other headers', async () => { + vi.useFakeTimers({ now: new Date(Date.UTC(2026, 8, 11, 21, 30, 0)) }); + const { eml, headers } = await composeAndParse({ text: 'hi' }); + expect(eml.startsWith('MIME-Version: 1.0\r\n')).toBe(true); + expect(header(headers, 'Date')).toBe('Fri, 11 Sep 2026 21:30:00 +0000'); + expect(header(headers, 'Subject')).toBe('hi'); + expect(header(headers, 'To')).toBe('bob@example.com'); + }); + + it('sends plain text as a single-part text/plain message', async () => { + const entity = await composeAndParse({ text: 'hello there' }); + expect(contentTypeOf(entity)).toBe('text/plain; charset=UTF-8'); + expect(entity.body).toBe('hello there'); + expect(entity.children).toBeUndefined(); + }); + + it('sends html as a single-part text/html message', async () => { + const entity = await composeAndParse({ html: '

hello

' }); + expect(contentTypeOf(entity)).toBe('text/html; charset=UTF-8'); + expect(entity.body).toBe('

hello

'); + }); + + it('sends text plus html as multipart/alternative with no attachments key at all', async () => { + const entity = await composeAndParse({ text: 'hello', html: '

hello

' }); + expect(mediaTypeOf(entity)).toBe('multipart/alternative'); + expect(entity.children.map(contentTypeOf)) + .toEqual(['text/plain; charset=UTF-8', 'text/html; charset=UTF-8']); + expect(entity.children.map((c) => c.body)).toEqual(['hello', '

hello

']); + }); + + it('nests alternative inside related when only inline attachments are present', async () => { + const entity = await composeAndParse({ text: 'hello', html: '

hello

', attachments: [inline] }); + expect(mediaTypeOf(entity)).toBe('multipart/related'); + expect(entity.children).toHaveLength(2); + + const [alternative, image] = entity.children; + expect(mediaTypeOf(alternative)).toBe('multipart/alternative'); + expect(alternative.children.map(mediaTypeOf)).toEqual(['text/plain', 'text/html']); + expect(alternative.boundary).not.toBe(entity.boundary); + + expect(header(image.headers, 'Content-ID')).toBe(''); + expect(header(image.headers, 'Content-Disposition')).toBe('inline;filename="x.png"'); + expect(image.body).toBe(bytesToB64(PNG_BYTES)); + }); + + it('nests alternative inside mixed when only regular attachments are present', async () => { + const entity = await composeAndParse({ text: 'hello', html: '

hello

', attachments: [regular] }); + expect(mediaTypeOf(entity)).toBe('multipart/mixed'); + expect(entity.children).toHaveLength(2); + + const [alternative, attachment] = entity.children; + expect(mediaTypeOf(alternative)).toBe('multipart/alternative'); + expect(alternative.children.map(mediaTypeOf)).toEqual(['text/plain', 'text/html']); + expect(header(attachment.headers, 'Content-Disposition')).toBe('attachment;filename="a.txt"'); + expect(attachment.body).toBe(b64('AAA')); + }); + + it('nests mixed > related > alternative with distinct boundaries when everything is present', async () => { + const entity = await composeAndParse({ + text: 'hello', + html: '

hello

', + attachments: [regular, inline], + }); + expect(mediaTypeOf(entity)).toBe('multipart/mixed'); + expect(entity.children).toHaveLength(2); + + const [related, attachment] = entity.children; + expect(mediaTypeOf(related)).toBe('multipart/related'); + expect(related.children).toHaveLength(2); + + const [alternative, image] = related.children; + expect(mediaTypeOf(alternative)).toBe('multipart/alternative'); + expect(alternative.children.map(mediaTypeOf)).toEqual(['text/plain', 'text/html']); + expect(header(image.headers, 'Content-ID')).toBe(''); + expect(header(attachment.headers, 'Content-Disposition')).toBe('attachment;filename="a.txt"'); + + const boundaries = [entity.boundary, related.boundary, alternative.boundary]; + expect(new Set(boundaries).size).toBe(3); + }); + + it('puts a bare text leaf under mixed when there is no html', async () => { + const entity = await composeAndParse({ text: 'hello', attachments: [regular] }); + expect(mediaTypeOf(entity)).toBe('multipart/mixed'); + expect(entity.children.map(mediaTypeOf)).toEqual(['text/plain', 'text/plain']); + expect(entity.children[0].body).toBe('hello'); + expect(entity.children[0].headers.has('content-disposition')).toBe(false); + }); + + // Documents current behavior: an attachment-only message is accepted and + // carries no body part. Whether it should be rejected is an open question. + it('sends an attachment-only message as mixed with just the attachment', async () => { + const entity = await composeAndParse({ attachments: [regular] }); + expect(mediaTypeOf(entity)).toBe('multipart/mixed'); + expect(entity.children).toHaveLength(1); + expect(header(entity.children[0].headers, 'Content-Disposition')).toBe('attachment;filename="a.txt"'); + }); + + it('keeps every attachment when several share a kind', async () => { + const regular2 = { ...regular, filename: 'b.txt', content: b64('BBB') }; + const inline2 = { path: '~/pics/y.png', cid: 'img2' }; + const entity = await composeAndParse({ + html: '

hello

', + attachments: [regular, inline, regular2, inline2], + }); + + const [related, ...regulars] = entity.children; + expect(regulars.map((r) => header(r.headers, 'Content-Disposition'))) + .toEqual(['attachment;filename="a.txt"', 'attachment;filename="b.txt"']); + expect(regulars.map((r) => r.body)).toEqual([b64('AAA'), b64('BBB')]); + + const [htmlLeaf, ...inlines] = related.children; + expect(mediaTypeOf(htmlLeaf)).toBe('text/html'); + expect(inlines.map((i) => header(i.headers, 'Content-ID'))).toEqual(['', '']); + expect(fsRead.mock.calls.map(([path]) => path)).toEqual(['~/pics/x.png', '~/pics/y.png']); + }); + + it('writes cc, bcc, Reply-To, and From once each when given', async () => { + const { headers } = await composeAndParse({ + text: 'hi', + cc: ['c1@example.com', 'c2@example.com'], + bcc: 'hidden@example.com', + replyTo: 'reply@example.com', + from: 'sender@example.com', + }); + expect(header(headers, 'cc')).toBe('c1@example.com, c2@example.com'); + expect(header(headers, 'bcc')).toBe('hidden@example.com'); + expect(header(headers, 'Reply-To')).toBe('reply@example.com'); + expect(header(headers, 'From')).toBe('sender@example.com'); + expect(getUser).not.toHaveBeenCalled(); + }); + + it('omits cc, bcc, and Reply-To when not given', async () => { + const { headers } = await composeAndParse({ text: 'hi' }); + expect(headers.has('cc')).toBe(false); + expect(headers.has('bcc')).toBe(false); + expect(headers.has('reply-to')).toBe(false); + }); + + it('falls back to the signed-in user for From', async () => { + const { headers } = await composeAndParse({ text: 'hi' }); + expect(getUser).toHaveBeenCalledTimes(1); + expect(header(headers, 'From')).toBe('alice@puter.email'); + }); + + it('accepts a list of recipients', async () => { + const { headers } = await composeAndParse({ to: ['a@example.com', 'b@example.com'], text: 'hi' }); + expect(header(headers, 'To')).toBe('a@example.com, b@example.com'); + }); + + it('writes exactly one Content-Type header for every shape', async () => { + for ( const options of [ + { text: 'hi' }, + { text: 'hi', html: '

hi

' }, + { text: 'hi', html: '

hi

', attachments: [regular, inline] }, + ] ) { + const { headers } = await composeAndParse(options); + expect(headers.get('content-type')).toHaveLength(1); + expect(headers.get('mime-version')).toEqual(['1.0']); + } + }); + + it('rejects a message with no recipient before looking up the sender', async () => { + await expect(compose({ subject: 'hi', text: 'hi' })).rejects.toThrow(/without recipient/); + expect(getUser).not.toHaveBeenCalled(); + }); + + it('rejects a message with no text, html, or attachments', async () => { + await expect(compose({ ...baseOptions })).rejects.toThrow(/text, html, or an attachment/); + await expect(compose({ ...baseOptions, attachments: [] })).rejects.toThrow(/text, html, or an attachment/); + }); + + it('rejects a non-ASCII subject', async () => { + await expect(compose({ ...baseOptions, subject: 'héllo', text: 'hi' })).rejects.toThrow(/not valid ASCII/); + }); +}); + +describe('compose round-trips through a MIME parser', () => { + const parse = async (options) => PostalMime.parse(await compose({ ...baseOptions, ...options })); + const bytesOf = (attachment) => Array.from(new Uint8Array(attachment.content)); + // The parser terminates every text body with a newline of its own. + const bodyOf = (str) => str?.replace(/\n$/, ''); + + it('recovers a plain text body', async () => { + const parsed = await parse({ text: 'hello there' }); + expect(bodyOf(parsed.text)).toBe('hello there'); + expect(parsed.html).toBeUndefined(); + expect(parsed.subject).toBe('hi'); + expect(parsed.to).toEqual([{ address: 'bob@example.com', name: '' }]); + expect(parsed.from).toEqual({ address: 'alice@puter.email', name: '' }); + }); + + it('recovers an html body', async () => { + const parsed = await parse({ html: '

hello

' }); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.text).toBeUndefined(); + }); + + it('recovers both alternatives', async () => { + const parsed = await parse({ text: 'hello', html: '

hello

' }); + expect(bodyOf(parsed.text)).toBe('hello'); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.attachments).toEqual([]); + }); + + it('recovers both alternatives and an inline image by cid', async () => { + const parsed = await parse({ text: 'hello', html: '

hello

', attachments: [inline] }); + expect(bodyOf(parsed.text)).toBe('hello'); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.attachments).toHaveLength(1); + const [image] = parsed.attachments; + expect(image).toMatchObject({ + filename: 'x.png', + mimeType: 'image/png', + disposition: 'inline', + related: true, + contentId: '', + }); + expect(bytesOf(image)).toEqual(Array.from(PNG_BYTES)); + }); + + it('recovers both alternatives and a regular attachment', async () => { + const parsed = await parse({ text: 'hello', html: '

hello

', attachments: [regular] }); + expect(bodyOf(parsed.text)).toBe('hello'); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.attachments).toHaveLength(1); + expect(parsed.attachments[0]).toMatchObject({ + filename: 'a.txt', + mimeType: 'text/plain', + disposition: 'attachment', + }); + expect(new TextDecoder().decode(parsed.attachments[0].content)).toBe('AAA'); + }); + + it('recovers everything from the fully nested shape', async () => { + const parsed = await parse({ + text: 'hello', + html: '

hello

', + cc: 'c1@example.com', + attachments: [regular, inline], + }); + expect(bodyOf(parsed.text)).toBe('hello'); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.cc).toEqual([{ address: 'c1@example.com', name: '' }]); + + const byName = Object.fromEntries(parsed.attachments.map((a) => [a.filename, a])); + expect(Object.keys(byName).sort()).toEqual(['a.txt', 'x.png']); + expect(byName['x.png']).toMatchObject({ disposition: 'inline', related: true, contentId: '' }); + expect(bytesOf(byName['x.png'])).toEqual(Array.from(PNG_BYTES)); + expect(byName['a.txt']).toMatchObject({ disposition: 'attachment', mimeType: 'text/plain' }); + expect(new TextDecoder().decode(byName['a.txt'].content)).toBe('AAA'); + }); + + it('recovers every attachment when several share a kind', async () => { + const regular2 = { ...regular, filename: 'b.txt', content: b64('BBB') }; + const inline2 = { path: '~/pics/y.png', cid: 'img2' }; + const parsed = await parse({ + html: '

hello

', + attachments: [regular, inline, regular2, inline2], + }); + expect(bodyOf(parsed.html)).toBe('

hello

'); + expect(parsed.attachments.map((a) => a.filename).sort()).toEqual(['a.txt', 'b.txt', 'x.png', 'y.png']); + expect(parsed.attachments.filter((a) => a.related).map((a) => a.contentId).sort()) + .toEqual(['', '']); + }); +});