340 lines
12 KiB
TypeScript
340 lines
12 KiB
TypeScript
/**
|
|
* Minimal, deterministic MIME builder for outgoing S/MIME messages.
|
|
*
|
|
* Produces canonical text suitable for CMS signing/encryption.
|
|
* All line endings are CRLF per RFC 5322.
|
|
*/
|
|
|
|
import { generateUUID } from '@/lib/utils';
|
|
|
|
const CRLF = '\r\n';
|
|
|
|
export interface MimeAttachment {
|
|
filename: string;
|
|
contentType: string;
|
|
content: ArrayBuffer;
|
|
cid?: string; // for inline images
|
|
}
|
|
|
|
export interface MimeMessageInput {
|
|
from: { name?: string; email: string };
|
|
to: { name?: string; email: string }[];
|
|
cc?: { name?: string; email: string }[];
|
|
bcc?: { name?: string; email: string }[];
|
|
subject: string;
|
|
date?: Date;
|
|
messageId?: string;
|
|
inReplyTo?: string;
|
|
references?: string[];
|
|
textBody?: string;
|
|
htmlBody?: string;
|
|
attachments?: MimeAttachment[];
|
|
}
|
|
|
|
/** Build a complete MIME message and return it as a Uint8Array (UTF-8). */
|
|
export function buildMimeMessage(input: MimeMessageInput): Uint8Array {
|
|
const boundary = generateBoundary();
|
|
const lines: string[] = [];
|
|
|
|
// Headers
|
|
lines.push(formatHeader('From', formatAddress(input.from)));
|
|
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
|
if (input.cc?.length) {
|
|
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
|
}
|
|
// BCC is intentionally omitted from the MIME headers per RFC 5322
|
|
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
|
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
|
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
|
if (input.inReplyTo) {
|
|
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
|
}
|
|
if (input.references?.length) {
|
|
lines.push(formatHeader('References', input.references.join(' ')));
|
|
}
|
|
lines.push('MIME-Version: 1.0');
|
|
|
|
const hasText = !!input.textBody;
|
|
const hasHtml = !!input.htmlBody;
|
|
const hasAttachments = !!input.attachments?.length;
|
|
|
|
if (!hasAttachments && hasText && !hasHtml) {
|
|
// text/plain only
|
|
lines.push('Content-Type: text/plain; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.textBody!));
|
|
} else if (!hasAttachments && hasText && hasHtml) {
|
|
// multipart/alternative
|
|
const altBoundary = generateBoundary();
|
|
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
|
lines.push('');
|
|
lines.push(`--${altBoundary}`);
|
|
lines.push('Content-Type: text/plain; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.textBody!));
|
|
lines.push(`--${altBoundary}`);
|
|
lines.push('Content-Type: text/html; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.htmlBody!));
|
|
lines.push(`--${altBoundary}--`);
|
|
} else if (!hasAttachments && !hasText && hasHtml) {
|
|
// html only
|
|
lines.push('Content-Type: text/html; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.htmlBody!));
|
|
} else if (hasAttachments) {
|
|
// multipart/mixed
|
|
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`);
|
|
lines.push('');
|
|
|
|
// Body part
|
|
if (hasText && hasHtml) {
|
|
const altBoundary = generateBoundary();
|
|
lines.push(`--${boundary}`);
|
|
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`);
|
|
lines.push('');
|
|
lines.push(`--${altBoundary}`);
|
|
lines.push('Content-Type: text/plain; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.textBody!));
|
|
lines.push(`--${altBoundary}`);
|
|
lines.push('Content-Type: text/html; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.htmlBody!));
|
|
lines.push(`--${altBoundary}--`);
|
|
} else if (hasText) {
|
|
lines.push(`--${boundary}`);
|
|
lines.push('Content-Type: text/plain; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.textBody!));
|
|
} else if (hasHtml) {
|
|
lines.push(`--${boundary}`);
|
|
lines.push('Content-Type: text/html; charset=utf-8');
|
|
lines.push('Content-Transfer-Encoding: quoted-printable');
|
|
lines.push('');
|
|
lines.push(quotedPrintableEncode(input.htmlBody!));
|
|
}
|
|
|
|
// Attachments
|
|
for (const att of input.attachments!) {
|
|
lines.push(`--${boundary}`);
|
|
const disposition = att.cid ? 'inline' : 'attachment';
|
|
lines.push(`Content-Type: ${att.contentType}; name="${encodeHeaderValue(att.filename)}"`);
|
|
lines.push(`Content-Disposition: ${disposition}; filename="${encodeHeaderValue(att.filename)}"`);
|
|
lines.push('Content-Transfer-Encoding: base64');
|
|
if (att.cid) {
|
|
lines.push(`Content-ID: <${att.cid}>`);
|
|
}
|
|
lines.push('');
|
|
lines.push(base64Encode(att.content));
|
|
}
|
|
lines.push(`--${boundary}--`);
|
|
} else {
|
|
// Empty body
|
|
lines.push('Content-Type: text/plain; charset=utf-8');
|
|
lines.push('');
|
|
}
|
|
|
|
const raw = lines.join(CRLF);
|
|
return new TextEncoder().encode(raw);
|
|
}
|
|
|
|
// ── Helpers ──────────────────────────────────────────────────────────
|
|
|
|
function generateBoundary(): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
const hex = Array.from(bytes)
|
|
.map((b) => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
return `----=_Part_${hex}`;
|
|
}
|
|
|
|
function formatAddress(addr: { name?: string; email: string }): string {
|
|
if (addr.name) {
|
|
// RFC 5322 quoted-string for display name
|
|
const escaped = addr.name.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
return `"${escaped}" <${addr.email}>`;
|
|
}
|
|
return addr.email;
|
|
}
|
|
|
|
function formatHeader(name: string, value: string): string {
|
|
const full = `${name}: ${value}`;
|
|
// RFC 5322 line length limit: fold at 76 chars
|
|
if (full.length <= 76) return full;
|
|
const parts: string[] = [];
|
|
let remaining = full;
|
|
let first = true;
|
|
while (remaining.length > 76) {
|
|
let breakAt = 76;
|
|
// Find a space to break at
|
|
const spaceIdx = remaining.lastIndexOf(' ', 76);
|
|
if (spaceIdx > (first ? name.length + 2 : 1)) {
|
|
breakAt = spaceIdx;
|
|
}
|
|
parts.push(remaining.slice(0, breakAt));
|
|
remaining = ' ' + remaining.slice(breakAt).trimStart();
|
|
first = false;
|
|
}
|
|
parts.push(remaining);
|
|
return parts.join(CRLF);
|
|
}
|
|
|
|
function encodeHeaderValue(value: string): string {
|
|
// Use RFC 2047 encoded-word if non-ASCII
|
|
if (/^[\x20-\x7e]*$/.test(value)) return value;
|
|
const encoded = Array.from(new TextEncoder().encode(value))
|
|
.map((b) => {
|
|
if (
|
|
(b >= 0x30 && b <= 0x39) || // 0-9
|
|
(b >= 0x41 && b <= 0x5a) || // A-Z
|
|
(b >= 0x61 && b <= 0x7a) // a-z
|
|
) {
|
|
return String.fromCharCode(b);
|
|
}
|
|
return '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
|
})
|
|
.join('');
|
|
return `=?UTF-8?Q?${encoded}?=`;
|
|
}
|
|
|
|
function formatDate(date: Date): string {
|
|
// RFC 5322 date format
|
|
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
const d = days[date.getUTCDay()];
|
|
const dd = date.getUTCDate();
|
|
const m = months[date.getUTCMonth()];
|
|
const y = date.getUTCFullYear();
|
|
const hh = date.getUTCHours().toString().padStart(2, '0');
|
|
const mm = date.getUTCMinutes().toString().padStart(2, '0');
|
|
const ss = date.getUTCSeconds().toString().padStart(2, '0');
|
|
return `${d}, ${dd} ${m} ${y} ${hh}:${mm}:${ss} +0000`;
|
|
}
|
|
|
|
export interface SmimeWrapInput {
|
|
from: { name?: string; email: string };
|
|
to: { name?: string; email: string }[];
|
|
cc?: { name?: string; email: string }[];
|
|
subject: string;
|
|
date?: Date;
|
|
messageId?: string;
|
|
inReplyTo?: string;
|
|
references?: string[];
|
|
smimeType: 'signed-data' | 'enveloped-data';
|
|
}
|
|
|
|
/**
|
|
* Wrap a CMS binary blob in a proper RFC 5322 / S/MIME message.
|
|
*
|
|
* The server needs RFC 5322 headers (From, To, Subject, etc.) to route
|
|
* the message; the CMS blob becomes the base64-encoded body.
|
|
*/
|
|
export function wrapCmsAsSmimeMessage(cmsBlob: Blob | ArrayBuffer | Uint8Array, input: SmimeWrapInput): Blob {
|
|
const lines: string[] = [];
|
|
|
|
lines.push(formatHeader('From', formatAddress(input.from)));
|
|
lines.push(formatHeader('To', input.to.map(formatAddress).join(', ')));
|
|
if (input.cc?.length) {
|
|
lines.push(formatHeader('Cc', input.cc.map(formatAddress).join(', ')));
|
|
}
|
|
lines.push(formatHeader('Subject', encodeHeaderValue(input.subject)));
|
|
lines.push(formatHeader('Date', formatDate(input.date ?? new Date())));
|
|
lines.push(formatHeader('Message-ID', input.messageId ?? `<${generateUUID()}@smime.local>`));
|
|
if (input.inReplyTo) {
|
|
lines.push(formatHeader('In-Reply-To', input.inReplyTo));
|
|
}
|
|
if (input.references?.length) {
|
|
lines.push(formatHeader('References', input.references.join(' ')));
|
|
}
|
|
lines.push('MIME-Version: 1.0');
|
|
lines.push(`Content-Type: application/pkcs7-mime; smime-type=${input.smimeType}; name="smime.p7m"`);
|
|
lines.push('Content-Transfer-Encoding: base64');
|
|
lines.push('Content-Disposition: attachment; filename="smime.p7m"');
|
|
lines.push('');
|
|
|
|
const headerPart = lines.join(CRLF);
|
|
|
|
// We'll combine header bytes + base64 body
|
|
const headerBytes = new TextEncoder().encode(headerPart);
|
|
|
|
return new Blob([headerBytes, cmsToBase64Blob(cmsBlob)], { type: 'message/rfc822' });
|
|
}
|
|
|
|
function cmsToBase64Blob(data: Blob | ArrayBuffer | Uint8Array): Blob {
|
|
let bytes: Uint8Array;
|
|
if (data instanceof Uint8Array) {
|
|
bytes = data;
|
|
} else if (data instanceof ArrayBuffer) {
|
|
bytes = new Uint8Array(data);
|
|
} else {
|
|
// Blob - we need sync; caller should have converted. Fallback to empty.
|
|
bytes = new Uint8Array(0);
|
|
}
|
|
const b64 = base64Encode(bytes.buffer as ArrayBuffer);
|
|
return new Blob([new TextEncoder().encode(b64 + CRLF)]);
|
|
}
|
|
|
|
/** Encode string as quoted-printable (RFC 2045). */
|
|
export function quotedPrintableEncode(input: string): string {
|
|
const bytes = new TextEncoder().encode(input);
|
|
const lines: string[] = [];
|
|
let line = '';
|
|
|
|
for (const b of bytes) {
|
|
let encoded: string;
|
|
if (b === 0x0d || b === 0x0a) {
|
|
// Pass through CRLF as-is (handled below)
|
|
encoded = String.fromCharCode(b);
|
|
} else if (
|
|
b === 0x09 || // tab
|
|
(b >= 0x20 && b <= 0x7e && b !== 0x3d) // printable, not '='
|
|
) {
|
|
encoded = String.fromCharCode(b);
|
|
} else {
|
|
encoded = '=' + b.toString(16).toUpperCase().padStart(2, '0');
|
|
}
|
|
|
|
if (b === 0x0a) {
|
|
// End current line (strip any trailing \r already added)
|
|
if (line.endsWith('\r')) {
|
|
line = line.slice(0, -1);
|
|
}
|
|
lines.push(line);
|
|
line = '';
|
|
continue;
|
|
}
|
|
|
|
if (line.length + encoded.length > 75) {
|
|
lines.push(line + '=');
|
|
line = encoded;
|
|
} else {
|
|
line += encoded;
|
|
}
|
|
}
|
|
lines.push(line);
|
|
return lines.join(CRLF);
|
|
}
|
|
|
|
/** Encode ArrayBuffer as base64 with line breaks at 76 chars. */
|
|
export function base64Encode(data: ArrayBuffer): string {
|
|
const bytes = new Uint8Array(data);
|
|
let binary = '';
|
|
for (const b of bytes) {
|
|
binary += String.fromCharCode(b);
|
|
}
|
|
const b64 = btoa(binary);
|
|
const lines: string[] = [];
|
|
for (let i = 0; i < b64.length; i += 76) {
|
|
lines.push(b64.slice(i, i + 76));
|
|
}
|
|
return lines.join(CRLF);
|
|
}
|