/** * 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 " 같은 (규격에는 어긋나지만 흔한) 이름은 그대로 둡니다. 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; }