Files
AriWeb-Suite/modules/arimail/server/mime.mjs

118 lines
7.5 KiB
JavaScript

/**
* RFC 5322/2045 메시지 생성. Resend 가 원본(raw)을 내려주지 못한 경우, 저장한 본문·헤더·첨부로
* POP 클라이언트에 줄 수 있는 완전한 메시지를 만듭니다. 원본이 있으면 이 코드는 쓰지 않습니다.
*/
const CRLF = '\r\n';
const isAscii = (s) => /^[\x20-\x7e]*$/.test(s);
export function encodeHeaderWord(value) {
const s = String(value ?? '');
if (isAscii(s)) return s;
// RFC 2047 encoded-word, base64, split to keep each word under 75 characters.
const bytes = Buffer.from(s, 'utf8'); const words = []; const chunk = 45;
for (let i = 0; i < bytes.length; i += chunk) {
// avoid splitting inside a multi-byte sequence
let end = Math.min(bytes.length, i + chunk);
while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) end--;
words.push(`=?UTF-8?B?${bytes.subarray(i, end).toString('base64')}?=`);
i = end - chunk;
}
return words.join(CRLF + ' ');
}
export function formatAddress(value) {
const s = String(value || '').trim();
const m = s.match(/^(.*?)\s*<([^<>]+)>$/);
if (!m) return s;
const name = m[1].replace(/^"|"$/g, '').trim();
if (!name) return `<${m[2]}>`;
return `${isAscii(name) ? `"${name.replace(/"/g, '\\"')}"` : encodeHeaderWord(name)} <${m[2]}>`;
}
export function foldHeader(name, value) {
const line = `${name}: ${value}`;
if (line.length <= 78 || /\r\n/.test(value)) return line;
// fold at commas or spaces
const out = []; let current = '';
for (const piece of line.split(/(?<=,)\s+|\s+/)) {
if (current && (current + ' ' + piece).length > 76) { out.push(current); current = ' ' + piece; } else current = current ? current + ' ' + piece : piece;
}
out.push(current); return out.join(CRLF);
}
export function base64Lines(buffer) { return Buffer.from(buffer).toString('base64').replace(/(.{76})/g, `$1${CRLF}`).replace(/\r\n$/, ''); }
function boundary(tag) { return `----=_ari_${tag}_${crypto.randomUUID().replace(/-/g, '')}`; }
function part(headers, body) { return headers.join(CRLF) + CRLF + CRLF + body; }
/**
* @param {object} m
* @param {string} m.from @param {string[]} m.to @param {string[]} [m.cc] @param {string[]} [m.bcc] @param {string[]} [m.replyTo]
* @param {string} m.subject @param {string|Date} [m.date] @param {string} [m.messageId]
* @param {Record<string,string>} [m.headers] @param {string} [m.text] @param {string} [m.html]
* @param {{filename:string,contentType:string,bytes:Buffer,contentId?:string,disposition?:string}[]} [m.attachments]
*/
export function buildEml(m) {
const date = m.date ? new Date(m.date) : new Date();
const headers = [];
const custom = Object.fromEntries(Object.entries(m.headers || {}).map(([k, v]) => [k.toLowerCase(), [k, v]]));
// 표준 헤더는 현재 값으로만 씁니다. 값이 비어 있어도 custom(headers_json 에 남은 옛 값)에서 지워, IMAP 으로 올린 초안의 Cc·Reply-To 를 웹에서 지운 뒤
// 재구성한 원본에 옛 받는 사람이 되살아나지 않게 합니다(0.9.0 수정). Bcc 도 재구성 원본에는 넣지 않습니다.
const push = (name, value) => { delete custom[name.toLowerCase()]; if (value === undefined || value === null || value === '') return; headers.push(foldHeader(name, value)); };
push('Date', date.toUTCString().replace('GMT', '+0000'));
push('From', formatAddress(m.from));
push('To', (m.to || []).map(formatAddress).join(', '));
push('Cc', (m.cc || []).map(formatAddress).join(', '));
push('Reply-To', (m.replyTo || []).map(formatAddress).join(', '));
// Bcc 는 현재 값(m.bcc, 초안의 bcc_json)으로만 씁니다. headers_json 에 남은 옛 Bcc 는 쓰지 않습니다.
push('Bcc', (m.bcc || []).map(formatAddress).join(', '));
push('Subject', encodeHeaderWord(m.subject || ''));
// Message-ID 의 도메인은 이 게이트웨이의 메일 도메인(MAILBOX 의 @ 뒤)입니다. 다른 사이트에 독립 설치해도 남의 도메인이 찍히지 않게 합니다.
push('Message-ID', m.messageId || `<${crypto.randomUUID()}@${String(process.env.MAILBOX || 'admin@arihakdang.com').split('@')[1] || 'arihakdang.com'}>`);
push('MIME-Version', '1.0');
for (const [k, v] of Object.values(custom)) {
if (/^(content-|mime-version|received$|return-path$|delivered-to$)/i.test(k)) continue;
if (!/^[!-9;-~]+$/.test(k) || /[\r\n]/.test(String(v))) continue;
headers.push(foldHeader(k, isAscii(String(v)) ? String(v) : encodeHeaderWord(String(v))));
}
const text = m.text ?? '', html = m.html ?? '';
const textPart = part(['Content-Type: text/plain; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(text || ' ', 'utf8')));
const htmlPart = html ? part(['Content-Type: text/html; charset=UTF-8', 'Content-Transfer-Encoding: base64'], base64Lines(Buffer.from(html, 'utf8'))) : null;
const inline = (m.attachments || []).filter((a) => a.contentId && (a.disposition || 'inline') === 'inline');
const files = (m.attachments || []).filter((a) => !inline.includes(a));
const attachmentPart = (a) => part([
`Content-Type: ${a.contentType || 'application/octet-stream'}; name="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
'Content-Transfer-Encoding: base64',
`Content-Disposition: ${a.contentId && inline.includes(a) ? 'inline' : 'attachment'}; filename="${encodeHeaderWord(a.filename || 'attachment').replace(/"/g, '')}"`,
...(a.contentId ? [`Content-ID: <${String(a.contentId).replace(/^<|>$/g, '')}>`] : []),
], base64Lines(a.bytes));
let body, contentType;
let alternative;
if (htmlPart) {
const b = boundary('alt');
alternative = { contentType: `multipart/alternative; boundary="${b}"`, body: `--${b}${CRLF}${textPart}${CRLF}--${b}${CRLF}${htmlPart}${CRLF}--${b}--` };
} else alternative = { contentType: 'text/plain; charset=UTF-8', body: base64Lines(Buffer.from(text || ' ', 'utf8')), encoding: 'base64' };
let main = alternative;
if (inline.length) {
const b = boundary('rel');
main = { contentType: `multipart/related; boundary="${b}"`, body: `--${b}${CRLF}${part([`Content-Type: ${alternative.contentType}`, ...(alternative.encoding ? ['Content-Transfer-Encoding: base64'] : [])], alternative.body)}${inline.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--` };
}
if (files.length) {
const b = boundary('mix');
contentType = `multipart/mixed; boundary="${b}"`;
body = `--${b}${CRLF}${part([`Content-Type: ${main.contentType}`, ...(main.encoding ? ['Content-Transfer-Encoding: base64'] : [])], main.body)}${files.map((a) => `${CRLF}--${b}${CRLF}${attachmentPart(a)}`).join('')}${CRLF}--${b}--`;
} else { contentType = main.contentType; body = main.body; if (main.encoding) headers.push('Content-Transfer-Encoding: base64'); }
headers.push(foldHeader('Content-Type', contentType));
return Buffer.from(headers.join(CRLF) + CRLF + CRLF + body + CRLF, 'utf8');
}
/** POP 전송용: CRLF 정규화와 점 스터핑(RFC 1939 §3). */
export function normalizeCrlf(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/\r?\n/g, '\r\n');
return Buffer.from(s, 'latin1');
}
export function dotStuff(buffer) {
const s = Buffer.from(buffer).toString('latin1').replace(/(^|\r\n)\./g, '$1..');
return Buffer.from(s, 'latin1');
}
/** 헤더와 본문을 분리합니다(TOP 명령용). */
export function splitHeader(buffer) {
const s = Buffer.from(buffer).toString('latin1');
const i = s.indexOf('\r\n\r\n');
if (i < 0) return { header: Buffer.from(s, 'latin1'), body: Buffer.alloc(0) };
return { header: Buffer.from(s.slice(0, i + 2), 'latin1'), body: Buffer.from(s.slice(i + 4), 'latin1') };
}