Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
229 lines
17 KiB
JavaScript
229 lines
17 KiB
JavaScript
/**
|
|
* IMAP 용 MIME 해석: 헤더·주소·RFC 2047 디코딩, multipart 트리, ENVELOPE / BODYSTRUCTURE / 섹션 추출.
|
|
* 저장한 원본(.eml)을 그대로 해석하며 바이트를 바꾸지 않습니다(부분 추출은 원본 슬라이스).
|
|
*/
|
|
const CRLF = '\r\n';
|
|
const latin1 = (b) => Buffer.from(b).toString('latin1');
|
|
|
|
/**
|
|
* 인코딩되지 않은 8비트 헤더(RFC 6532 식 UTF-8 제목·이름)를 복원합니다(0.9.0). 원본을 latin1 로 읽으므로 UTF-8 바이트가 글자마다 한 글자로
|
|
* 풀려 "íê¸ ì 목" 처럼 저장됐습니다. 0x80 이상 글자가 있고 UTF-8 로 되돌렸을 때 깨진 글자(U+FFFD)가 없으면 그 결과를 씁니다.
|
|
*/
|
|
export function fixUtf8(s) {
|
|
const str = String(s ?? ''); if (!/[\u0080-\u00ff]/.test(str) || /[\u0100-\uffff]/.test(str)) return str;
|
|
const decoded = Buffer.from(str, 'latin1').toString('utf8'); return decoded.includes('\ufffd') ? str : decoded;
|
|
}
|
|
export function decodeWords(value) {
|
|
return fixUtf8(String(value ?? ''))
|
|
.replace(/(=\?[^?]+\?[bBqQ]\?[^?]*\?=)\s+(?==\?)/g, '$1')
|
|
.replace(/=\?([^?]+)\?([bBqQ])\?([^?]*)\?=/g, (m, charset, enc, text) => {
|
|
try {
|
|
const bytes = enc.toLowerCase() === 'b' ? Buffer.from(text, 'base64') : Buffer.from(text.replace(/_/g, ' ').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
|
return new TextDecoder(charset.replace(/\*.*$/, '')).decode(bytes);
|
|
} catch { return m; }
|
|
});
|
|
}
|
|
export function decodeBody(bytes, encoding) {
|
|
let raw = Buffer.from(bytes);
|
|
const enc = String(encoding || '').toLowerCase();
|
|
if (enc === 'base64') raw = Buffer.from(latin1(raw).replace(/[^A-Za-z0-9+/=]/g, ''), 'base64');
|
|
else if (enc === 'quoted-printable') raw = Buffer.from(latin1(raw).replace(/=\r?\n/g, '').replace(/=([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1');
|
|
return raw;
|
|
}
|
|
export function decodeText(bytes, encoding, charset) {
|
|
try { return new TextDecoder(charset || 'utf-8').decode(decodeBody(bytes, encoding)); } catch { return decodeBody(bytes, encoding).toString('utf8'); }
|
|
}
|
|
/** 헤더 블록을 [name, value] 목록으로. 접힌 줄은 펼칩니다. */
|
|
export function parseHeaders(text) {
|
|
const out = [];
|
|
for (const line of String(text).replace(/\r?\n[ \t]+/g, ' ').split(/\r?\n/)) {
|
|
const i = line.indexOf(':'); if (i <= 0) continue;
|
|
out.push([line.slice(0, i).trim(), line.slice(i + 1).trim()]);
|
|
}
|
|
return out;
|
|
}
|
|
export const header = (headers, name) => headers.find(([k]) => k.toLowerCase() === name.toLowerCase())?.[1] ?? null;
|
|
export function parseContentType(value) {
|
|
const [type, ...rest] = String(value || 'text/plain').split(';');
|
|
const [main = 'text', sub = 'plain'] = type.trim().toLowerCase().split('/');
|
|
return { type: main, subtype: sub || 'plain', params: parseParams(rest.join(';')) };
|
|
}
|
|
/**
|
|
* 헤더 파라미터(name="값"; filename*=UTF-8''%ED%95%9C.pdf …)를 객체로 바꿉니다. RFC 2231 을 지원합니다.
|
|
* - name*=charset'언어'%XX… : 퍼센트 인코딩을 풀고 지정한 문자 집합으로 해독합니다.
|
|
* - name*0=…; name*1*=… : 이어 쓰기 조각을 번호 순으로 합칩니다(별표가 붙은 조각만 퍼센트 인코딩).
|
|
* 예전에는 퍼센트 인코딩을 풀지 않아 한글 첨부 이름이 "%ED%95%9C.pdf" 로 보였고, 이어 쓰기 조각은 합치지 않았습니다.
|
|
*/
|
|
export function parseParams(text) {
|
|
const raw = {}; const re = /([A-Za-z0-9*_.-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^;\s]+))/g; let m;
|
|
while ((m = re.exec(String(text || '')))) raw[m[1].toLowerCase()] = m[2] !== undefined ? m[2].replace(/\\(.)/g, '$1') : m[3];
|
|
const groups = {}; // 이름 → [{ index, extended, value }]
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const k = key.match(/^(.+?)(?:\*(\d+))?(\*)?$/);
|
|
(groups[k[1]] ||= []).push({ index: k[2] === undefined ? -1 : Number(k[2]), extended: !!k[3], value });
|
|
}
|
|
const params = {};
|
|
for (const [name, pieces] of Object.entries(groups)) {
|
|
// 이어 쓰기 조각(*0, *1 …)이 있으면 그것만 순서대로, 없으면 하나뿐인 값을 씁니다.
|
|
const parts = pieces.some((p) => p.index >= 0) ? pieces.filter((p) => p.index >= 0).sort((a, b) => a.index - b.index) : pieces.slice(0, 1);
|
|
if (!parts.some((p) => p.extended)) { params[name] = parts.map((p) => p.value).join(''); continue; }
|
|
// 첫 확장 조각은 charset'language'값 형식입니다.
|
|
let charset = 'utf-8'; const bytes = [];
|
|
parts.forEach((p, i) => {
|
|
let v = p.value;
|
|
if (p.extended && i === 0) { const q = v.match(/^([^']*)'[^']*'(.*)$/); if (q) { charset = q[1] || 'utf-8'; v = q[2]; } }
|
|
bytes.push(p.extended ? Buffer.from(v.replace(/%([0-9A-Fa-f]{2})/g, (x, h) => String.fromCharCode(parseInt(h, 16))), 'latin1') : Buffer.from(v, 'utf8'));
|
|
});
|
|
try { params[name] = new TextDecoder(charset).decode(Buffer.concat(bytes)); } catch { params[name] = Buffer.concat(bytes).toString('utf8'); }
|
|
}
|
|
return params;
|
|
}
|
|
/**
|
|
* 주소 헤더(From/To/Cc …)를 {name, mailbox, host} 목록으로 나눕니다.
|
|
* 순서가 중요합니다: 먼저 "부호화된 그대로" 쉼표(따옴표 밖)로 나누고 그 다음에 조각마다 encoded-word 를 풉니다.
|
|
* 예전에는 먼저 풀고 나눠서, 이름에 쉼표가 든 "김, 민수" 가 두 주소로 갈라졌습니다. 그룹 구문(name: a, b;)의 이름 제거도
|
|
* 조각 단위로, 따옴표 밖에 있고 뒤에 숫자가 오지 않을 때만 합니다("Meeting 10:30", "Re: invoice" 같은 이름이 잘리던 문제, 0.8.16 수정).
|
|
*/
|
|
export function parseAddresses(value) {
|
|
const out = []; let raw = String(value || '');
|
|
if (!raw.trim()) return out;
|
|
// 그룹 구문은 "이름: 구성원, 구성원;" 처럼 세미콜론으로 끝납니다. 끝나는 세미콜론이 있을 때만 이름을 버리고 구성원만 남깁니다.
|
|
// 세미콜론 없이 콜론만 있는 "Re: invoice <c@x>" 같은 (규격에는 어긋나지만 흔한) 이름은 그대로 둡니다.
|
|
raw = raw.replace(/(^|,)(\s*)[^"<>@:;,]+:\s*([^;:]*);/g, '$1$2$3');
|
|
for (const part of raw.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/)) {
|
|
const p = decodeWords(part).trim(); if (!p) continue;
|
|
const m = p.match(/^(.*?)\s*<([^<>]*)>\s*$/);
|
|
let name = '', addr = p;
|
|
if (m) { name = m[1].trim().replace(/^"(.*)"$/, '$1').replace(/\\(.)/g, '$1'); addr = m[2].trim(); }
|
|
const at = addr.lastIndexOf('@');
|
|
out.push({ name: name || null, mailbox: at > 0 ? addr.slice(0, at) : addr, host: at > 0 ? addr.slice(at + 1) : null });
|
|
}
|
|
return out;
|
|
}
|
|
/**
|
|
* 원본 바이트를 파트 트리로. 각 파트: {headers, type, subtype, params, encoding, id, description, disposition, dispositionParams,
|
|
* start, headerEnd, bodyStart, end, parts(children), size, lines}
|
|
*/
|
|
export function parseMime(eml) {
|
|
const buf = Buffer.from(eml); const s = latin1(buf);
|
|
function parsePart(start, end) {
|
|
let headerEnd, bodyStart;
|
|
// 헤더가 없는 파트(RFC 2046 5.1: 경계 바로 뒤에 빈 줄 → text/plain us-ascii)는 CRLF 로 시작합니다. 예전에는 CRLFCRLF 를 찾다
|
|
// 본문을 통째로 잃거나(크기 0) 본문의 첫 단락을 헤더로 잘못 읽었습니다(0.8.25 수정).
|
|
if (s.startsWith('\r\n', start)) { headerEnd = start; bodyStart = Math.min(start + 2, end); }
|
|
else { const sep = s.indexOf('\r\n\r\n', start); if (sep < 0 || sep >= end) { headerEnd = end; bodyStart = end; } else { headerEnd = sep + 2; bodyStart = sep + 4; } }
|
|
const headers = parseHeaders(s.slice(start, headerEnd));
|
|
const ct = parseContentType(header(headers, 'content-type'));
|
|
const cd = parseContentType(header(headers, 'content-disposition') || '');
|
|
const body = s.slice(bodyStart, end);
|
|
const part = {
|
|
headers, type: ct.type, subtype: ct.subtype, params: ct.params,
|
|
encoding: (header(headers, 'content-transfer-encoding') || '7bit').trim().toLowerCase(),
|
|
id: header(headers, 'content-id'), description: header(headers, 'content-description'),
|
|
disposition: header(headers, 'content-disposition') ? cd.type : null, dispositionParams: cd.params,
|
|
start, headerEnd, bodyStart, end, parts: [],
|
|
size: end - bodyStart, lines: body ? body.split('\n').length - (body.endsWith('\n') ? 1 : 0) : 0,
|
|
};
|
|
if (ct.type === 'multipart' && ct.params.boundary) {
|
|
// 경계 줄은 "--경계" 바로 뒤가 "--"(끝), 공백 또는 줄바꿈이어야 합니다. 예전에는 접두사만 비교해서 바깥 경계 "b1" 이
|
|
// 안쪽 경계 "--b1x" 줄과도 맞아 파트가 잘못 나뉘었습니다(BODYSTRUCTURE·섹션 번호 오류).
|
|
const b = '--' + ct.params.boundary;
|
|
const isBoundary = (at) => { const c = s.charAt(at + b.length); return c === '' || c === '-' || c === '\r' || c === '\n' || c === ' ' || c === '\t'; };
|
|
const find = (from, prefix) => { let at = s.indexOf(prefix + b, from); while (at >= 0 && !isBoundary(at + prefix.length)) at = s.indexOf(prefix + b, at + 1); return at; };
|
|
let pos = s.startsWith(b, bodyStart) && isBoundary(bodyStart) ? bodyStart : find(bodyStart, '\n'); if (pos >= 0 && s[pos] === '\n') pos += 1;
|
|
while (pos >= 0 && pos < end) {
|
|
if (s.startsWith(b + '--', pos)) break;
|
|
const lineEnd = s.indexOf('\n', pos); if (lineEnd < 0) break;
|
|
const childStart = lineEnd + 1;
|
|
let next = find(childStart, '\r\n'); if (next < 0 || next > end) next = -1;
|
|
part.parts.push(parsePart(childStart, next < 0 ? end : next));
|
|
if (next < 0) break; pos = next + 2;
|
|
}
|
|
} else if (ct.type === 'message' && ct.subtype === 'rfc822') {
|
|
part.parts.push(parsePart(bodyStart, end));
|
|
}
|
|
return part;
|
|
}
|
|
return { buffer: buf, text: s, root: parsePart(0, s.length) };
|
|
}
|
|
// ------------------------------------------------------------------ IMAP 문자열 표현
|
|
export const nstring = (v) => v === null || v === undefined ? 'NIL' : imapString(String(v));
|
|
export function imapString(v) {
|
|
const s = String(v);
|
|
// 헤더 값은 원본 바이트를 latin1 로 읽은 "바이트 문자열"(문자 하나 = 바이트 하나)입니다. 리터럴 길이는 그 바이트 수이고,
|
|
// 세션(imap.mjs send)은 이런 문자열을 latin1 로 써서 바이트를 그대로 내보냅니다. 예전에는 길이를 UTF-8 로 다시 재고 UTF-8 로 써서
|
|
// 한글 제목이 두 번 인코딩돼 깨졌습니다. 진짜 유니코드(0xFF 초과 문자)가 들어오면 UTF-8 바이트 문자열로 바꿔 같은 규칙을 적용합니다.
|
|
if (/[\r\n]/.test(s) || /[^\x01-\x7f]/.test(s)) { const raw = /[^\u0000-\u00ff]/.test(s) ? Buffer.from(s, 'utf8').toString('latin1') : s; return `{${raw.length}}${CRLF}${raw}`; }
|
|
return '"' + s.replace(/[\\"]/g, (c) => '\\' + c) + '"';
|
|
}
|
|
const addressList = (list) => list.length ? '(' + list.map((a) => `(${nstring(a.name)} NIL ${nstring(a.mailbox)} ${nstring(a.host)})`).join('') + ')' : 'NIL';
|
|
export function envelope(headers) {
|
|
const from = parseAddresses(header(headers, 'from')), sender = parseAddresses(header(headers, 'sender')), replyTo = parseAddresses(header(headers, 'reply-to'));
|
|
return '(' + [
|
|
nstring(header(headers, 'date')), nstring(header(headers, 'subject')),
|
|
addressList(from), addressList(sender.length ? sender : from), addressList(replyTo.length ? replyTo : from),
|
|
addressList(parseAddresses(header(headers, 'to'))), addressList(parseAddresses(header(headers, 'cc'))), addressList(parseAddresses(header(headers, 'bcc'))),
|
|
nstring(header(headers, 'in-reply-to')), nstring(header(headers, 'message-id')),
|
|
].join(' ') + ')';
|
|
}
|
|
const paramList = (params) => { const e = Object.entries(params || {}); return e.length ? '(' + e.map(([k, v]) => `${imapString(k.toUpperCase())} ${imapString(v)}`).join(' ') + ')' : 'NIL'; };
|
|
const dispositionField = (part) => part.disposition ? `(${imapString(part.disposition.toUpperCase())} ${paramList(part.dispositionParams)})` : 'NIL';
|
|
export function bodyStructure(part, extended = true) {
|
|
if (part.type === 'multipart') {
|
|
const children = part.parts.map((p) => bodyStructure(p, extended)).join('');
|
|
return `(${children || '("TEXT" "PLAIN" ("CHARSET" "US-ASCII") NIL NIL "7BIT" 0 0)'} ${imapString(part.subtype.toUpperCase())}${extended ? ` ${paramList(part.params)} ${dispositionField(part)} NIL NIL` : ''})`;
|
|
}
|
|
const basic = [imapString(part.type.toUpperCase()), imapString(part.subtype.toUpperCase()), paramList(part.params), nstring(part.id), nstring(part.description), imapString(part.encoding.toUpperCase()), String(part.size)];
|
|
if (part.type === 'text') basic.push(String(part.lines));
|
|
else if (part.type === 'message' && part.subtype === 'rfc822' && part.parts[0]) basic.push(envelope(part.parts[0].headers), bodyStructure(part.parts[0], extended), String(part.lines));
|
|
if (extended) basic.push('NIL', dispositionField(part), 'NIL', 'NIL');
|
|
return '(' + basic.join(' ') + ')';
|
|
}
|
|
/** 섹션 지정자(BODY[...] 의 대괄호 안)를 바이트로 돌려줍니다. */
|
|
export function section(parsed, spec) {
|
|
const { text, root } = parsed; const s = String(spec || '').trim();
|
|
const slice = (a, b) => Buffer.from(text.slice(a, b), 'latin1');
|
|
if (s === '') return parsed.buffer;
|
|
const m = s.match(/^((?:\d+\.)*\d+)(?:\.(MIME|HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\)))?$/i) || s.match(/^()(HEADER|TEXT|HEADER\.FIELDS(?:\.NOT)?\s*\([^)]*\))$/i);
|
|
if (!m) return null;
|
|
let part = root;
|
|
if (m[1]) {
|
|
for (const n of m[1].split('.').map(Number)) {
|
|
if (part.type === 'multipart') part = part.parts[n - 1];
|
|
else if (part.type === 'message' && part.subtype === 'rfc822') part = part.parts[0] && (part.parts[0].type === 'multipart' ? part.parts[0].parts[n - 1] : n === 1 ? part.parts[0] : null);
|
|
else part = n === 1 ? part : null;
|
|
if (!part) return null;
|
|
}
|
|
}
|
|
const what = (m[2] || '').toUpperCase();
|
|
if (!what) return m[1] ? slice(part.bodyStart, part.end) : parsed.buffer;
|
|
const target = m[1] && what !== 'MIME' && part.type === 'message' && part.parts[0] ? part.parts[0] : part;
|
|
if (what === 'MIME') return slice(part.start, part.bodyStart);
|
|
if (what === 'HEADER') return slice(target.start, target.bodyStart);
|
|
if (what === 'TEXT') return slice(target.bodyStart, target.end);
|
|
const fm = what.match(/^HEADER\.FIELDS(\.NOT)?\s*\(([^)]*)\)$/); if (!fm) return null;
|
|
const wanted = new Set(fm[2].split(/\s+/).filter(Boolean).map((x) => x.replace(/^"|"$/g, '').toLowerCase())), not = !!fm[1];
|
|
const lines = text.slice(target.start, target.headerEnd).split(/\r\n(?![ \t])/).filter(Boolean);
|
|
const picked = lines.filter((line) => { const name = line.slice(0, line.indexOf(':')).trim().toLowerCase(); return wanted.has(name) !== not; });
|
|
return Buffer.from(picked.join(CRLF) + (picked.length ? CRLF : '') + CRLF, 'latin1');
|
|
}
|
|
/** 웹 저장용 요약: 제목·발신·수신·본문(text/html)·첨부. */
|
|
export function summarize(parsed) {
|
|
const h = parsed.root.headers; const addr = (a) => a.host ? `${a.mailbox}@${a.host}` : a.mailbox;
|
|
const out = { subject: decodeWords(header(h, 'subject') || ''), from: decodeWords(header(h, 'from') || ''), to: parseAddresses(header(h, 'to')).map(addr), cc: parseAddresses(header(h, 'cc')).map(addr), bcc: parseAddresses(header(h, 'bcc')).map(addr), replyTo: parseAddresses(header(h, 'reply-to')).map(addr), messageId: header(h, 'message-id'), date: header(h, 'date'), text: '', html: '', attachments: [], headers: Object.fromEntries(h) };
|
|
const walk = (p) => {
|
|
if (p.type === 'multipart') { p.parts.forEach(walk); return; }
|
|
const body = Buffer.from(parsed.text.slice(p.bodyStart, p.end), 'latin1');
|
|
// 텍스트가 아닌 파트는 모두 첨부입니다. message/rfc822(메일을 "첨부로 전달")도 첨부로 둡니다.
|
|
// 예전에는 message 형식을 본문도 첨부도 아닌 것으로 건너뛰어, 첨부로 전달한 메일이 웹 화면과 SMTP 발송에서 사라졌습니다.
|
|
// 파일 이름이 붙은 텍스트(notes.txt, text/csv, Apple Mail 의 inline; filename=…)도 첨부입니다. 예전에는 본문도 첨부도 아닌 것으로 사라졌습니다.
|
|
const named = !!(p.dispositionParams.filename || p.params.name);
|
|
const isAttachment = p.disposition === 'attachment' || p.type !== 'text' || named || !['plain', 'html'].includes(p.subtype);
|
|
if (isAttachment) { out.attachments.push({ filename: decodeWords(p.dispositionParams.filename || p.params.name || (p.type === 'message' ? 'message.eml' : 'attachment')), contentType: `${p.type}/${p.subtype}`, contentId: p.id ? p.id.replace(/^<|>$/g, '') : null, disposition: p.disposition === 'inline' && p.id ? 'inline' : p.disposition === 'inline' ? 'attachment' : p.disposition || 'attachment', bytes: decodeBody(body, p.encoding) }); return; }
|
|
if (p.type === 'text' && p.subtype === 'html' && !out.html) out.html = decodeText(body, p.encoding, p.params.charset);
|
|
else if (p.type === 'text' && !out.text) out.text = decodeText(body, p.encoding, p.params.charset);
|
|
};
|
|
walk(parsed.root);
|
|
return out;
|
|
}
|