mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-16 08:39:36 +00:00
tests and validation checks
This commit is contained in:
1 parent
1302c4254d
commit
b5f2f9b12d
3 files changed
+616
-10
No files matched your search
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '<p>hi</p>' }))
|
||||
.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: '<p>hi</p>', 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: '<p>hi</p>', attachments: [regular] }).mimeType)
|
||||
.toBe('multipart/mixed');
|
||||
});
|
||||
|
||||
it('picks multipart/related when every attachment is inline', () => {
|
||||
expect(determineTopLevelMimeType({ html: '<p>hi</p>', attachments: [inline] }))
|
||||
.toEqual({ isMultiPart: true, mimeType: 'multipart/related' });
|
||||
});
|
||||
|
||||
it('lets a regular attachment win over inline ones', () => {
|
||||
expect(determineTopLevelMimeType({ html: '<p>hi</p>', 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('<logo@puter>');
|
||||
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: '<p>hello</p>' });
|
||||
expect(contentTypeOf(entity)).toBe('text/html; charset=UTF-8');
|
||||
expect(entity.body).toBe('<p>hello</p>');
|
||||
});
|
||||
|
||||
it('sends text plus html as multipart/alternative with no attachments key at all', async () => {
|
||||
const entity = await composeAndParse({ text: 'hello', html: '<p>hello</p>' });
|
||||
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', '<p>hello</p>']);
|
||||
});
|
||||
|
||||
it('nests alternative inside related when only inline attachments are present', async () => {
|
||||
const entity = await composeAndParse({ text: 'hello', html: '<p>hello</p>', 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('<img1>');
|
||||
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: '<p>hello</p>', 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: '<p>hello</p>',
|
||||
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('<img1>');
|
||||
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: '<p>hello</p>',
|
||||
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(['<img1>', '<img2>']);
|
||||
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: '<p>hi</p>' },
|
||||
{ text: 'hi', html: '<p>hi</p>', 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: '<p>hello</p>' });
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
expect(parsed.text).toBeUndefined();
|
||||
});
|
||||
|
||||
it('recovers both alternatives', async () => {
|
||||
const parsed = await parse({ text: 'hello', html: '<p>hello</p>' });
|
||||
expect(bodyOf(parsed.text)).toBe('hello');
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
expect(parsed.attachments).toEqual([]);
|
||||
});
|
||||
|
||||
it('recovers both alternatives and an inline image by cid', async () => {
|
||||
const parsed = await parse({ text: 'hello', html: '<p>hello</p>', attachments: [inline] });
|
||||
expect(bodyOf(parsed.text)).toBe('hello');
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
expect(parsed.attachments).toHaveLength(1);
|
||||
const [image] = parsed.attachments;
|
||||
expect(image).toMatchObject({
|
||||
filename: 'x.png',
|
||||
mimeType: 'image/png',
|
||||
disposition: 'inline',
|
||||
related: true,
|
||||
contentId: '<img1>',
|
||||
});
|
||||
expect(bytesOf(image)).toEqual(Array.from(PNG_BYTES));
|
||||
});
|
||||
|
||||
it('recovers both alternatives and a regular attachment', async () => {
|
||||
const parsed = await parse({ text: 'hello', html: '<p>hello</p>', attachments: [regular] });
|
||||
expect(bodyOf(parsed.text)).toBe('hello');
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
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: '<p>hello</p>',
|
||||
cc: 'c1@example.com',
|
||||
attachments: [regular, inline],
|
||||
});
|
||||
expect(bodyOf(parsed.text)).toBe('hello');
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
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: '<img1>' });
|
||||
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: '<p>hello</p>',
|
||||
attachments: [regular, inline, regular2, inline2],
|
||||
});
|
||||
expect(bodyOf(parsed.html)).toBe('<p>hello</p>');
|
||||
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(['<img1>', '<img2>']);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user