/** * 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} [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') }; }