/** * IMAP4rev1 서버 (RFC 3501) + LITERAL+, IDLE, UIDPLUS, MOVE, SPECIAL-USE, NAMESPACE, ID, UNSELECT, ENABLE. * * - 계정: www.arihakdang.com 관리자 계정(login_id 또는 email + 비밀번호). 별도 비밀번호 없음. * - 메일함: INBOX(받은), Sent(보낸), Drafts(초안), Trash(휴지통) — 웹 메일함의 folder 와 같은 저장소. * - \Seen 은 read_at, \Draft 는 초안 상태, 나머지 플래그는 imap_flags 에 저장. UID 는 imap_uids(전역 단조 증가). * - EXPUNGE 는 휴지통이 아니면 휴지통으로 이동, 휴지통에서는 영구 삭제(재수신 차단). MOVE/COPY 도 같은 저장소를 씁니다. * - 평문 포트(143): 993 은 즉시 TLS 이고 143 은 STARTTLS 뒤 로그인이 원칙이지만, 관리자 설정 "평문 로그인 허용"(services.plainLogin, 기본 켬)이 * 켜져 있으면 TLS 없이도 LOGIN/AUTHENTICATE 를 받습니다(0.9.7, POP3 110 과 같은 스위치). 꺼져 있으면 LOGINDISABLED 를 광고하고 거절합니다. * 접속마다 설정을 한 번 읽어(plainOk) 인사말의 CAPABILITY 와 로그인 허용에 같은 값을 씁니다. 그래서 인사말은 설정을 읽은 뒤에 보냅니다. */ import net from 'node:net'; import tls from 'node:tls'; import { verifyAdmin, serviceAllowed } from './auth.mjs'; import { serviceEnabled, plainLoginAllowed } from './limits.mjs'; import { normalizeCrlf } from './mime.mjs'; import { buildStoredEml, appendMessage, copyMessage } from './store.mjs'; import { parseMime, envelope, bodyStructure, section, header, decodeWords, decodeText, parseAddresses, summarize } from './imap-mime.mjs'; import { now, memoryRateStore } from './util.mjs'; const CRLF = '\r\n'; const rate = memoryRateStore(); const MAILBOXES = [['INBOX', 'inbox', []], ['Sent', 'sent', ['\\Sent']], ['Drafts', 'drafts', ['\\Drafts']], ['Trash', 'trash', ['\\Trash']]]; const FOLDER_NAME = Object.fromEntries(MAILBOXES.map(([n, f]) => [f, n])); const PERSISTED = new Set(['\\Flagged', '\\Answered', '\\Deleted']); const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; export function folderOf(name) { const n = String(name ?? '').replace(/^"|"$/g, ''); if (/^inbox$/i.test(n)) return 'inbox'; return MAILBOXES.find(([m]) => m === n)?.[1] || null; } export function internalDate(iso) { const d = new Date(iso || Date.now()); const p = (n) => String(n).padStart(2, '0'); return `${p(d.getUTCDate())}-${MONTHS[d.getUTCMonth()]}-${d.getUTCFullYear()} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())} +0000`; } /** * APPEND 의 date-time("05-Oct-2026 23:30:00 +0900", 앞에 공백이 붙은 " 7-Oct-2026 …" 포함)을 ms 로. 시각과 시간대까지 반영합니다. * 예전에는 SEARCH 용 parseImapDate(날짜만)를 써서 시각·시간대가 버려지고, 공백이 붙은 날은 아예 무시돼 보낸 메일함 정렬이 어긋났습니다. */ export function parseImapDateTime(s) { const m = String(s).replace(/^"|"$/g, '').match(/^\s?(\d{1,2})-([A-Za-z]{3})-(\d{4}) (\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/); if (!m) return null; const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); if (mon < 0) return null; const offset = (m[7] === '-' ? -1 : 1) * (Number(m[8]) * 60 + Number(m[9])) * 60000; return Date.UTC(Number(m[3]), mon, Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6])) - offset; } function parseImapDate(s) { const m = String(s).replace(/^"|"$/g, '').match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})/); if (!m) return null; const mon = MONTHS.findIndex((x) => x.toLowerCase() === m[2].toLowerCase()); return mon < 0 ? null : Date.UTC(Number(m[3]), mon, Number(m[1])); } function wildcard(pattern) { return new RegExp('^' + pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/%/g, '[^/]*') + '$', 'i'); } // ------------------------------------------------------------------ 명령 토큰화 export function tokenize(raw, literals = []) { const out = []; const stack = [out]; let i = 0; const s = raw; // 회선에서는 latin1 로 읽으므로 따옴표 문자열·원자는 UTF-8 로 되돌립니다(검색어·메일함 이름의 한글). const utf8 = (v) => Buffer.from(v, 'latin1').toString('utf8'); const push = (v) => stack[stack.length - 1].push(typeof v === 'string' ? utf8(v) : v); while (i < s.length) { const c = s[i]; if (c === ' ' || c === '\t') { i++; continue; } if (c === '(') { const arr = []; push(arr); stack.push(arr); i++; continue; } if (c === ')') { if (stack.length > 1) stack.pop(); i++; continue; } if (c === '"') { let j = i + 1, v = ''; while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { v += s[j + 1]; j += 2; } else v += s[j++]; } push(v); i = j + 1; continue; } // NUL 쌍(\0번호\0)은 리터럴 자리표시자입니다. 짝이 없는 NUL(클라이언트가 보낸 날 바이트)은 indexOf 가 -1 을 돌려주고 i 가 0 으로 되돌아가 // 무한 루프(인증 전 한 줄로 프로세스 전체가 멈춤)가 됐으므로, 짝이 없으면 남은 글자를 버리고 끝냅니다(0.8.24). if (c === '\0') { const j = s.indexOf('\0', i + 1); if (j < 0) break; const lit = literals[Number(s.slice(i + 1, j))]; stack[stack.length - 1].push(lit ? lit.toString('utf8') : ''); i = j + 1; continue; } let j = i, depth = 0; while (j < s.length) { const d = s[j]; if (d === '[') depth++; else if (d === ']') depth--; else if (depth === 0 && (d === ' ' || d === ')' || d === '(')) break; j++; } push(s.slice(i, j)); i = j; } return out; } /** 시퀀스/UID 집합 → 스냅샷 인덱스 배열 */ export function resolveSet(spec, messages, byUid) { const out = new Set(); if (!messages.length) return []; const last = byUid ? messages[messages.length - 1].uid : messages.length; for (const piece of String(spec).split(',')) { let [a, b] = piece.split(':'); if (b === undefined) b = a; const lo = a === '*' ? last : Number(a), hi = b === '*' ? last : Number(b); if (!Number.isFinite(lo) || !Number.isFinite(hi)) continue; const [from, to] = lo <= hi ? [lo, hi] : [hi, lo]; messages.forEach((m, idx) => { const key = byUid ? m.uid : idx + 1; if (key >= from && key <= to) out.add(idx); }); } return [...out].sort((x, y) => x - y); } class Session { constructor(server, socket, { secure }) { this.server = server; this.env = server.env; this.secure = secure; this.state = 'notauth'; this.user = null; this.buf = Buffer.alloc(0); this.raw = ''; this.literals = []; this.needLiteral = 0; this.closed = false; this.mailbox = null; this.messages = []; this.cache = new Map(); this.idle = null; this.continuation = null; this.failures = 0; // plainOk: 이 접속에서 TLS 없이 로그인을 받을지(pop3.mjs 와 같은 규칙). 설정 읽기가 끝난 뒤 인사말을 보내고, 그 뒤에야 명령 큐가 돌기 시작합니다. this.plainOk = secure || server.allowPlaintext === true; this.ip = socket.remoteAddress || ''; this.attach(socket); console.log(`IMAP 접속 ${this.ip} ${secure ? 'tls' : 'plain'}`); this.queue = this.loadPlainLogin().then(() => this.send(`* OK [CAPABILITY ${this.capabilities()}] ${server.hostname} IMAP4rev1 ready`)); } async loadPlainLogin() { if (this.secure || typeof this.server.allowPlaintext === 'boolean') return; try { this.plainOk = await plainLoginAllowed(this.env); } catch (e) { console.error('IMAP 평문 로그인 설정 읽기 실패', e?.message); this.plainOk = false; } } attach(socket) { this.socket = socket; socket.setTimeout(35 * 60 * 1000); socket.on('data', (c) => this.onData(c)); socket.on('timeout', () => this.close('* BYE timeout')); socket.on('error', () => { this.closed = true; }); socket.on('close', () => { this.closed = true; this.stopIdle(); }); } // 응답 문자열은 latin1(문자 = 바이트)로 씁니다. ENVELOPE/BODYSTRUCTURE 의 리터럴은 원본 헤더 바이트를 담은 바이트 문자열이라 // UTF-8 로 쓰면 두 번 인코딩됩니다. 0xFF 를 넘는 문자가 섞인(진짜 유니코드) 줄만 UTF-8 로 씁니다. send(line) { if (!this.closed) { const text = line + CRLF; this.socket.write(Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1')); } } write(buffer) { if (!this.closed) this.socket.write(buffer); } close(line) { if (line) this.send(line); this.closed = true; this.stopIdle(); try { this.socket.end(); } catch {} } capabilities() { const caps = ['IMAP4rev1', 'LITERAL+', 'IDLE', 'UIDPLUS', 'MOVE', 'SPECIAL-USE', 'NAMESPACE', 'ID', 'UNSELECT', 'ENABLE', 'CHILDREN']; if (this.plainOk) caps.push('AUTH=PLAIN'); else caps.push('LOGINDISABLED'); // 평문 접속에서 로그인을 안 받으면 LOGINDISABLED(RFC 3501 7.2.1) if (!this.secure && this.server.secureContext) caps.push('STARTTLS'); return caps.join(' '); } onData(chunk) { // STARTTLS 를 받은 뒤 TLS 전환 전까지 평문으로 오는 바이트는 버립니다(명령 주입 방지, 아래 설명). if (this.upgrading) return; this.buf = Buffer.concat([this.buf, chunk]); if (this.buf.length > 64 * 1024 * 1024) { this.close('* BYE too much data'); return; } for (;;) { if (this.needLiteral > 0) { if (this.buf.length < this.needLiteral) return; this.literals.push(this.buf.subarray(0, this.needLiteral)); this.raw += `\0${this.literals.length - 1}\0`; this.buf = this.buf.subarray(this.needLiteral); this.needLiteral = 0; continue; } const idx = this.buf.indexOf('\r\n'); if (idx < 0) { if (this.buf.length > 65536) this.close('* BYE line too long'); return; } const line = this.buf.subarray(0, idx).toString('latin1'); this.buf = this.buf.subarray(idx + 2); const lit = line.match(/\{(\d+)(\+?)\}$/); if (lit && !this.continuation && !this.idle) { const n = Number(lit[1]); if (n > 40 * 1024 * 1024) { this.raw = ''; this.literals = []; this.send('* BAD literal too large'); continue; } this.raw += line.slice(0, -lit[0].length); this.needLiteral = n; if (n === 0) { this.literals.push(Buffer.alloc(0)); this.raw += `\0${this.literals.length - 1}\0`; this.needLiteral = 0; } if (!lit[2]) this.send('+ Ready for literal data'); continue; } const raw = this.raw + line, literals = this.literals; this.raw = ''; this.literals = []; // STARTTLS 명령 주입 방지(CVE-2011-0411 유형): 같은 평문 묶음에서 STARTTLS 뒤에 붙어 온 명령은 TLS 전환 뒤에 // 암호화된 명령처럼 실행될 수 있으므로, STARTTLS 를 큐에 넣은 뒤 남은 입력을 버리고 전환이 끝날 때까지 받지 않습니다. const isStartTls = !this.idle && !this.continuation && /^\S+\s+STARTTLS\s*$/i.test(raw); if (isStartTls) { this.upgrading = true; this.buf = Buffer.alloc(0); } this.queue = this.queue.then(() => this.dispatch(raw, literals)).catch((e) => { console.error('IMAP 오류', e?.stack || e?.message); this.send('* BAD internal error'); }); if (isStartTls) return; } } async dispatch(raw, literals) { if (this.closed) return; if (this.continuation) { const fn = this.continuation; this.continuation = null; return fn(raw); } if (this.idle) { if (raw.trim().toUpperCase() === 'DONE') { const tag = this.idle.tag; this.stopIdle(); await this.refresh({ expunge: true }); this.send(`${tag} OK IDLE terminated`); } return; } const tokens = tokenize(raw, literals); const tag = tokens[0]; if (!tag) return; let name = String(tokens[1] || '').toUpperCase(), args = tokens.slice(2); if (name === 'UID') { name = 'UID ' + String(args[0] || '').toUpperCase(); args = args.slice(1); } try { await this.command(tag, name, args, raw, literals); } catch (e) { if (e?.imap) this.send(`${tag} ${e.imap} ${e.message}`); else { console.error('IMAP 명령 오류', name, e?.stack || e?.message); this.send(`${tag} NO internal error`); } } } fail(kind, message) { const e = new Error(message); e.imap = kind; throw e; } async command(tag, name, args, raw, literals = []) { const needAuth = () => { if (this.state === 'notauth') this.fail('NO', 'login first'); }; const needSelected = () => { needAuth(); if (!this.mailbox) this.fail('NO', 'no mailbox selected'); }; switch (name) { case 'CAPABILITY': this.send(`* CAPABILITY ${this.capabilities()}`); return this.send(`${tag} OK CAPABILITY completed`); case 'NOOP': if (this.mailbox) await this.refresh({ expunge: true }); return this.send(`${tag} OK NOOP completed`); case 'LOGOUT': this.send('* BYE logging out'); this.send(`${tag} OK LOGOUT completed`); return this.close(); case 'ID': this.send('* ID ("name" "arihakdang-mail-gateway" "vendor" "Ari Hakdang")'); return this.send(`${tag} OK ID completed`); case 'ENABLE': this.send('* ENABLED'); return this.send(`${tag} OK ENABLE completed`); case 'NAMESPACE': needAuth(); this.send('* NAMESPACE (("" "/")) NIL NIL'); return this.send(`${tag} OK NAMESPACE completed`); case 'STARTTLS': return this.startTls(tag); case 'LOGIN': return this.login(tag, args[0], args[1]); case 'AUTHENTICATE': return this.authenticate(tag, args); case 'LIST': case 'LSUB': case 'XLIST': needAuth(); return this.list(tag, name, args); case 'SUBSCRIBE': case 'UNSUBSCRIBE': needAuth(); return this.send(`${tag} OK ${name} completed`); case 'CREATE': case 'DELETE': case 'RENAME': needAuth(); return this.send(`${tag} NO [CANNOT] mailboxes are fixed (INBOX, Sent, Drafts, Trash)`); case 'STATUS': needAuth(); return this.status(tag, args); case 'SELECT': case 'EXAMINE': needAuth(); return this.select(tag, args[0], name === 'EXAMINE'); case 'APPEND': needAuth(); return this.append(tag, args, literals); case 'CHECK': needSelected(); await this.refresh({ expunge: true }); return this.send(`${tag} OK CHECK completed`); case 'CLOSE': needSelected(); if (!this.readOnly) await this.expunge(false); this.mailbox = null; this.messages = []; return this.send(`${tag} OK CLOSE completed`); case 'UNSELECT': needSelected(); this.mailbox = null; this.messages = []; return this.send(`${tag} OK UNSELECT completed`); case 'EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true); return this.send(`${tag} OK EXPUNGE completed`); // EXAMINE(읽기 전용) 세션에서는 EXPUNGE 와 마찬가지로 UID EXPUNGE 도 거부합니다(예전에는 검사가 빠져 읽기 전용 세션이 메일을 지웠습니다, 0.8.24). case 'UID EXPUNGE': needSelected(); if (this.readOnly) this.fail('NO', 'mailbox is read-only'); await this.expunge(true, resolveSet(args[0], this.messages, true)); return this.send(`${tag} OK EXPUNGE completed`); case 'SEARCH': case 'UID SEARCH': needSelected(); return this.search(tag, args, name.startsWith('UID')); case 'FETCH': case 'UID FETCH': needSelected(); return this.fetch(tag, args, name.startsWith('UID')); case 'STORE': case 'UID STORE': needSelected(); return this.store(tag, args, name.startsWith('UID')); case 'COPY': case 'UID COPY': case 'MOVE': case 'UID MOVE': needSelected(); return this.copy(tag, args, name.startsWith('UID'), name.endsWith('MOVE')); case 'IDLE': needAuth(); return this.startIdle(tag); default: return this.send(`${tag} BAD unknown command`); } } // ---------------------------------------------------------------- 인증 async startTls(tag) { this.upgrading = false; // 거절하면 평문 입력을 다시 받고, 전환하면 새 TLS 소켓으로 받습니다. if (this.secure) return this.send(`${tag} NO already using TLS`); if (!this.server.secureContext) return this.send(`${tag} NO TLS not available`); this.send(`${tag} OK Begin TLS negotiation now`); const plain = this.socket; ['data', 'timeout', 'error', 'close'].forEach((ev) => plain.removeAllListeners(ev)); const secureSocket = new tls.TLSSocket(plain, { isServer: true, secureContext: this.server.secureContext, SNICallback: (n, cb) => cb(null, this.server.secureContext) }); this.secure = true; this.plainOk = true; this.buf = Buffer.alloc(0); this.attach(secureSocket); } async login(tag, login, password) { if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); } if (this.state !== 'notauth') return this.send(`${tag} BAD already logged in`); if (rate.blocked('imap:' + this.ip, { limit: 20 })) return this.close(`${tag} NO [AUTHENTICATIONFAILED] too many attempts`); // 1.0.7 다중 도메인: 로그인 ID 의 @도메인(또는 TLS SNI 호스트)으로 어느 도메인의 계정인지 고릅니다. 모르는·꺼 둔 도메인이면 인증 실패와 같게 처리합니다. const env = this.server.resolveEnv ? this.server.resolveEnv(String(login || ''), sniName(this.socket)) : this.env; const user = env ? await verifyAdmin(env, String(login || ''), String(password || '')) : null; if (user) this.env = env; if (!user) rate.hit('imap:' + this.ip, { limit: 20, windowSec: 900 }); // 실패만 셉니다 console.log(`IMAP 인증 ${user ? '성공' : '실패'} ${this.ip} user=${String(login || '').slice(0, 80)}`); if (!user) { this.failures++; await new Promise((r) => setTimeout(r, this.env.POP_AUTH_DELAY_MS ?? 1500)); if (this.failures >= 3) return this.close(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); return this.send(`${tag} NO [AUTHENTICATIONFAILED] authentication failed`); } // 관리자가 IMAP 서비스 자체를 꺼 두었으면(0.9.0, 관리자 페이지 설정) 모든 계정의 접속을 거부합니다. if (!(await serviceEnabled(this.env, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (관리자가 IMAP 서비스를 껐음)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP service is disabled by the administrator`); } // 계정 설정에서 IMAP 접속을 꺼 두었으면 비밀번호가 맞아도 거부합니다(0.8.4~). if (!(await serviceAllowed(this.env, user, 'imap'))) { console.log(`IMAP 접속 거부 ${this.ip} user=${user.login} (계정 설정에서 IMAP 사용 안 함)`); return this.send(`${tag} NO [UNAVAILABLE] IMAP access is disabled for this account`); } this.user = user; this.state = 'auth'; this.send(`${tag} OK [CAPABILITY ${this.capabilities()}] LOGIN completed`); } async authenticate(tag, args) { if (String(args[0] || '').toUpperCase() !== 'PLAIN') return this.send(`${tag} NO unsupported mechanism`); if (!this.plainOk) { console.log(`IMAP 평문 로그인 거부 ${this.ip} (STARTTLS 필요)`); return this.send(`${tag} NO [PRIVACY] use STARTTLS or port 993 before login`); } const finish = async (payload) => { if (payload.trim() === '*') return this.send(`${tag} BAD cancelled`); let parts; try { parts = Buffer.from(payload.trim(), 'base64').toString('utf8').split('\0'); } catch { return this.send(`${tag} BAD invalid response`); } if (parts.length < 3) return this.send(`${tag} BAD invalid response`); return this.login(tag, parts[1], parts[2]); }; if (args[1]) return finish(args[1]); this.send('+ '); this.continuation = finish; } // ---------------------------------------------------------------- 메일함 async list(tag, name, args) { const reference = String(args[0] ?? ''), pattern = String(args[1] ?? ''); if (pattern === '') { this.send(`* ${name} (\\Noselect) "/" ""`); return this.send(`${tag} OK ${name} completed`); } const re = wildcard((reference + pattern).replace(/^\/+/, '')); for (const [mailbox, , attrs] of MAILBOXES) if (re.test(mailbox)) this.send(`* ${name} (\\HasNoChildren${attrs.length ? ' ' + attrs.join(' ') : ''}) "/" "${mailbox}"`); this.send(`${tag} OK ${name} completed`); } async uidValidity() { const row = await this.env.DB.prepare("SELECT value FROM settings WHERE key='imap_uidvalidity'").first(); return Number(row?.value || 1); } async snapshot(folder) { // 새 UID 는 도착 순서대로: 인덱스 순회 방향에 따라 순서가 뒤집히지 않도록 명시적으로 정렬합니다. await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) SELECT id,folder FROM messages WHERE folder=? AND mailbox=? ORDER BY received_at, id').bind(folder, this.user.mailbox).run(); const rows = (await this.env.DB.prepare(`SELECT m.id, u.id AS uid, m.read_at, m.state, m.folder, m.raw_size, m.received_at, COALESCE(f.flags,'[]') AS flags FROM messages m JOIN imap_uids u ON u.message_id=m.id AND u.folder=m.folder LEFT JOIN imap_flags f ON f.message_id=m.id WHERE m.folder=? AND m.mailbox=? ORDER BY u.id`).bind(folder, this.user.mailbox).all()).results; // state: 메일 상태(draft/sending/sent/received/failed). COPY/MOVE 가 Drafts 로 옮길 수 있는 메일인지 판단하는 데 씁니다(0.8.24). return rows.map((r) => ({ id: r.id, uid: r.uid, seen: !!r.read_at, draft: r.state === 'draft' || r.folder === 'drafts', state: r.state, flags: JSON.parse(r.flags), size: r.raw_size, date: r.received_at })); } flagList(m) { const f = []; if (m.seen) f.push('\\Seen'); if (m.draft) f.push('\\Draft'); for (const x of m.flags) if (!f.includes(x)) f.push(x); return f; } async uidNext() { const row = await this.env.DB.prepare('SELECT COALESCE(MAX(id),0)+1 AS n FROM imap_uids').first(); return row.n; } async select(tag, name, readOnly) { const folder = folderOf(name); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`); this.mailbox = folder; this.readOnly = readOnly; this.messages = await this.snapshot(folder); this.cache.clear(); const unseen = this.messages.findIndex((m) => !m.seen); this.send(`* ${this.messages.length} EXISTS`); this.send('* 0 RECENT'); if (unseen >= 0) this.send(`* OK [UNSEEN ${unseen + 1}] first unseen`); this.send(`* OK [UIDVALIDITY ${await this.uidValidity()}] UIDs valid`); this.send(`* OK [UIDNEXT ${await this.uidNext()}] predicted next UID`); this.send('* FLAGS (\\Answered \\Flagged \\Deleted \\Seen \\Draft)'); this.send(`* OK [PERMANENTFLAGS (${readOnly ? '' : '\\Answered \\Flagged \\Deleted \\Seen'})] flags`); this.send(`${tag} OK [${readOnly ? 'READ-ONLY' : 'READ-WRITE'}] ${readOnly ? 'EXAMINE' : 'SELECT'} completed`); } async status(tag, args) { const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [NONEXISTENT] no such mailbox`); const items = Array.isArray(args[1]) ? args[1] : [args[1]].filter(Boolean); const rows = await this.snapshot(folder); const out = []; for (const item of items.map((x) => String(x).toUpperCase())) { if (item === 'MESSAGES') out.push(`MESSAGES ${rows.length}`); else if (item === 'RECENT') out.push('RECENT 0'); else if (item === 'UNSEEN') out.push(`UNSEEN ${rows.filter((m) => !m.seen).length}`); else if (item === 'UIDNEXT') out.push(`UIDNEXT ${await this.uidNext()}`); else if (item === 'UIDVALIDITY') out.push(`UIDVALIDITY ${await this.uidValidity()}`); } this.send(`* STATUS "${FOLDER_NAME[folder]}" (${out.join(' ')})`); this.send(`${tag} OK STATUS completed`); } /** 다른 세션·웹·수신으로 바뀐 내용을 알립니다. */ async refresh({ expunge = false } = {}) { if (!this.mailbox) return; const fresh = await this.snapshot(this.mailbox); const freshByUid = new Map(fresh.map((m) => [m.uid, m])); if (expunge) { for (let i = this.messages.length - 1; i >= 0; i--) if (!freshByUid.has(this.messages[i].uid)) { this.messages.splice(i, 1); this.send(`* ${i + 1} EXPUNGE`); } } const known = new Set(this.messages.map((m) => m.uid)); this.messages.forEach((m, i) => { const f = freshByUid.get(m.uid); if (f && this.flagList(f).join(' ') !== this.flagList(m).join(' ')) { Object.assign(m, f); this.send(`* ${i + 1} FETCH (FLAGS (${this.flagList(m).join(' ')}))`); } }); const added = fresh.filter((m) => !known.has(m.uid)); if (added.length) { this.messages.push(...added); this.send(`* ${this.messages.length} EXISTS`); } } // ---------------------------------------------------------------- 내용 async loadParsed(m) { const hit = this.cache.get(m.id); if (hit) return hit; const row = await this.env.DB.prepare('SELECT eml FROM message_raw WHERE message_id=?').bind(m.id).first(); let eml = row ? Buffer.from(row.eml) : null; if (!eml) { const stored = await this.env.DB.prepare('SELECT * FROM messages WHERE id=?').bind(m.id).first(); eml = stored ? await buildStoredEml(this.env, stored) : Buffer.from('Subject: (missing)\r\n\r\n'); } const parsed = parseMime(normalizeCrlf(eml)); parsed.size = parsed.buffer.length; if (this.cache.size > 40) this.cache.delete(this.cache.keys().next().value); this.cache.set(m.id, parsed); return parsed; } parseFetchItems(spec) { let items = Array.isArray(spec) ? spec : [spec]; const upper = items.map((x) => String(x).toUpperCase()); if (upper.length === 1 && upper[0] === 'ALL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE']; else if (upper.length === 1 && upper[0] === 'FAST') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE']; else if (upper.length === 1 && upper[0] === 'FULL') items = ['FLAGS', 'INTERNALDATE', 'RFC822.SIZE', 'ENVELOPE', 'BODY']; return items.map(String); } async fetch(tag, args, byUid) { const indexes = resolveSet(args[0], this.messages, byUid); const items = this.parseFetchItems(args[1] ?? []); if (byUid && !items.some((x) => x.toUpperCase() === 'UID')) items.unshift('UID'); for (const idx of indexes) { const m = this.messages[idx]; const parts = []; let markSeen = false; for (const item of items) { const key = item.toUpperCase(); if (key === 'UID') parts.push(`UID ${m.uid}`); else if (key === 'FLAGS') parts.push(`FLAGS (${this.flagList(m).join(' ')})`); else if (key === 'INTERNALDATE') parts.push(`INTERNALDATE "${internalDate(m.date)}"`); else if (key === 'RFC822.SIZE') parts.push(`RFC822.SIZE ${(await this.loadParsed(m)).size}`); else if (key === 'ENVELOPE') parts.push(`ENVELOPE ${envelope((await this.loadParsed(m)).root.headers)}`); else if (key === 'BODYSTRUCTURE') parts.push(`BODYSTRUCTURE ${bodyStructure((await this.loadParsed(m)).root, true)}`); else if (key === 'BODY') parts.push(`BODY ${bodyStructure((await this.loadParsed(m)).root, false)}`); else if (key === 'RFC822' || key === 'RFC822.HEADER' || key === 'RFC822.TEXT') { const parsed = await this.loadParsed(m); const data = section(parsed, key === 'RFC822' ? '' : key === 'RFC822.HEADER' ? 'HEADER' : 'TEXT'); if (key !== 'RFC822.HEADER') markSeen = true; parts.push({ label: key, data }); } else { const bm = item.match(/^(BODY(?:\.PEEK)?)\[([^\]]*)\](?:<(\d+)\.(\d+)>)?$/i); if (!bm) continue; const parsed = await this.loadParsed(m); let data = section(parsed, bm[2]); if (!data) { parts.push(`BODY[${bm[2]}] NIL`); continue; } let label = `BODY[${bm[2].toUpperCase().replace(/\s+/g, ' ')}]`; if (bm[3] !== undefined) { const start = Number(bm[3]), len = Number(bm[4]); data = data.subarray(start, start + len); label += `<${start}>`; } if (!/PEEK/i.test(bm[1])) markSeen = true; parts.push({ label, data }); } } if (markSeen && !m.seen && !this.readOnly) { await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=? AND read_at IS NULL').bind(now(), m.id).run(); m.seen = true; if (!items.some((x) => x.toUpperCase() === 'FLAGS')) parts.push(`FLAGS (${this.flagList(m).join(' ')})`); } this.write(Buffer.from(`* ${idx + 1} FETCH (`)); // 문자열 조각(ENVELOPE·BODYSTRUCTURE 등)은 바이트 문자열이므로 latin1 로 씁니다(send 와 같은 규칙). 예전에는 UTF-8 로 써서 한글 제목이 깨졌습니다. const bytes = (text) => Buffer.from(text, /[^\u0000-\u00ff]/.test(text) ? 'utf8' : 'latin1'); parts.forEach((p, i) => { if (i) this.write(Buffer.from(' ')); if (typeof p === 'string') this.write(bytes(p)); else { this.write(Buffer.from(`${p.label} {${p.data.length}}${CRLF}`)); this.write(p.data); } }); this.write(Buffer.from(`)${CRLF}`)); } this.send(`${tag} OK FETCH completed`); } async store(tag, args, byUid) { if (this.readOnly) return this.send(`${tag} NO mailbox is read-only`); const indexes = resolveSet(args[0], this.messages, byUid); const op = String(args[1] || '').toUpperCase(); const om = op.match(/^([+-]?)FLAGS(\.SILENT)?$/); if (!om) return this.send(`${tag} BAD invalid STORE`); const flags = (Array.isArray(args[2]) ? args[2] : args.slice(2)).map((f) => String(f)).map((f) => { const l = f.toLowerCase(); return l === '\\seen' ? '\\Seen' : l === '\\flagged' ? '\\Flagged' : l === '\\answered' ? '\\Answered' : l === '\\deleted' ? '\\Deleted' : l === '\\draft' ? '\\Draft' : f; }); for (const idx of indexes) { const m = this.messages[idx]; let seen = m.seen; let persisted = new Set(m.flags); const wantSeen = flags.includes('\\Seen'), persistedWanted = flags.filter((f) => PERSISTED.has(f)); if (om[1] === '+') { if (wantSeen) seen = true; persistedWanted.forEach((f) => persisted.add(f)); } else if (om[1] === '-') { if (wantSeen) seen = false; persistedWanted.forEach((f) => persisted.delete(f)); } else { seen = wantSeen; persisted = new Set(persistedWanted); } if (seen !== m.seen) await this.env.DB.prepare('UPDATE messages SET read_at=? WHERE id=?').bind(seen ? now() : null, m.id).run(); const list = [...persisted]; if (list.join() !== m.flags.join()) await this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(list), now()).run(); m.seen = seen; m.flags = list; if (!om[2]) this.send(`* ${idx + 1} FETCH (FLAGS (${this.flagList(m).join(' ')})${byUid ? ` UID ${m.uid}` : ''})`); } this.send(`${tag} OK STORE completed`); } async search(tag, args, byUid) { let keys = args.slice(); if (String(keys[0] || '').toUpperCase() === 'CHARSET') keys = keys.slice(2); // text(m): 검색용 캐시. body 는 원본 바이트 그대로(7bit 로 읽은 것), decoded 는 base64·quoted-printable 과 // 문자 집합을 풀어 낸 본문(text + html). 우리가 만드는 원본(buildEml)은 본문을 base64 로 쓰므로, 원본 바이트만 // 보던 예전 BODY/TEXT 검색은 본문 단어를 찾지 못했습니다. const parsedCache = new Map(); const text = async (m) => { if (!parsedCache.has(m.id)) { const p = await this.loadParsed(m); let decoded = ''; try { const s = summarize(p); decoded = (s.text + '\n' + s.html).toLowerCase(); } catch { /* 해석 실패 시 원본 바이트 검색만 */ } parsedCache.set(m.id, { p, body: decodeText(p.buffer, '7bit').toLowerCase(), decoded }); } return parsedCache.get(m.id); }; const hdr = async (m, name) => header((await text(m)).p.root.headers, name) || ''; const dateOf = async (m, sent) => sent ? Date.parse(await hdr(m, 'date')) || Date.parse(m.date) : Date.parse(m.date); const day = (t) => Math.floor(t / 86400000); const evaluate = async (m, idx, list) => { const next = () => list.shift(); const item = next(); // 괄호 묶음 "(FROM a SUBJECT b)" 은 토큰화에서 배열이 됩니다. OR/NOT 뒤에 오면 여기로 들어오므로 묶음 전체를 // AND 로 평가합니다. 예전에는 배열을 문자열 "FROM,a" 로 바꿔 default(참)로 빠져, OR (…) (…) 가 모두 참이 됐습니다. if (Array.isArray(item)) return evalAll(m, idx, item); const key = String(item ?? '').toUpperCase(); const has = (f) => this.flagList(m).includes(f); switch (key) { case 'ALL': return true; case 'ANSWERED': return has('\\Answered'); case 'DELETED': return has('\\Deleted'); case 'DRAFT': return has('\\Draft'); case 'FLAGGED': return has('\\Flagged'); case 'SEEN': return m.seen; case 'UNANSWERED': return !has('\\Answered'); case 'UNDELETED': return !has('\\Deleted'); case 'UNDRAFT': return !has('\\Draft'); case 'UNFLAGGED': return !has('\\Flagged'); case 'UNSEEN': return !m.seen; case 'NEW': case 'RECENT': return false; case 'OLD': return true; case 'KEYWORD': next(); return false; case 'UNKEYWORD': next(); return true; case 'UID': return resolveSet(next(), this.messages, true).includes(idx); case 'LARGER': return (await this.loadParsed(m)).size > Number(next()); case 'SMALLER': return (await this.loadParsed(m)).size < Number(next()); case 'SINCE': return day(await dateOf(m)) >= day(parseImapDate(next())); case 'BEFORE': return day(await dateOf(m)) < day(parseImapDate(next())); case 'ON': return day(await dateOf(m)) === day(parseImapDate(next())); case 'SENTSINCE': return day(await dateOf(m, true)) >= day(parseImapDate(next())); case 'SENTBEFORE': return day(await dateOf(m, true)) < day(parseImapDate(next())); case 'SENTON': return day(await dateOf(m, true)) === day(parseImapDate(next())); case 'FROM': case 'TO': case 'CC': case 'BCC': case 'SUBJECT': { const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, key)).toLowerCase().includes(v); } case 'HEADER': { const name = String(next() ?? ''); const v = String(next() ?? '').toLowerCase(); return decodeWords(await hdr(m, name)).toLowerCase().includes(v); } case 'BODY': case 'TEXT': { const v = String(next() ?? '').toLowerCase(); const t = await text(m); return t.body.includes(v) || t.decoded.includes(v) || decodeWords(t.p.text).toLowerCase().includes(v); } case 'NOT': return !(await evaluate(m, idx, list)); case 'OR': { const a = await evaluate(m, idx, list); const b = await evaluate(m, idx, list); return a || b; } default: if (/^[\d*:,]+$/.test(key)) return resolveSet(key, this.messages, false).includes(idx); return true; } }; const evalAll = async (m, idx, list) => { const copy = list.slice(); let ok = true; while (copy.length) { const item = copy[0]; if (Array.isArray(item)) { copy.shift(); if (!(await evalAll(m, idx, item))) ok = false; } else if (!(await evaluate(m, idx, copy))) ok = false; } return ok; }; const hits = []; for (let i = 0; i < this.messages.length; i++) if (await evalAll(this.messages[i], i, keys)) hits.push(byUid ? this.messages[i].uid : i + 1); this.send(`* SEARCH${hits.length ? ' ' + hits.join(' ') : ''}`); this.send(`${tag} OK SEARCH completed`); } async copy(tag, args, byUid, move) { const indexes = resolveSet(args[0], this.messages, byUid); const folder = folderOf(args[1]); if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`); if (move && this.readOnly) return this.send(`${tag} NO mailbox is read-only`); // Drafts 에는 초안(발신·draft 상태)만 둘 수 있습니다. 받은 메일을 Drafts 로 옮기면 웹 메일이 그것을 초안으로 열어 편집·발송을 시도하다 // mail_locked 로 막혀 열 수 없는 메일이 됐습니다(0.8.24). 초안이 아닌 메일은 NO [CANNOT] 로 거부합니다. if (folder === 'drafts' && indexes.some((idx) => !['draft', 'failed'].includes(this.messages[idx].state))) return this.send(`${tag} NO [CANNOT] only drafts can be put in Drafts`); const validity = await this.uidValidity(); const src = [], dst = []; for (const idx of indexes) { const m = this.messages[idx]; if (move) { if (folder !== this.mailbox) await this.env.DB.prepare('UPDATE messages SET previous_folder=folder,folder=?,updated_at=? WHERE id=?').bind(folder, now(), m.id).run(); await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(m.id, folder).run(); const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(m.id, folder).first(); src.push(m.uid); dst.push(row.id); } else { const newId = await copyMessage(this.env, m.id, folder); await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(newId, folder).run(); const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(newId, folder).first(); src.push(m.uid); dst.push(row.id); } } if (src.length) this.send(`* OK [COPYUID ${validity} ${src.join(',')} ${dst.join(',')}] ${move ? 'moved' : 'copied'}`); if (move && folder !== this.mailbox) for (const idx of indexes.slice().reverse()) { this.messages.splice(idx, 1); this.send(`* ${idx + 1} EXPUNGE`); } this.send(`${tag} OK ${move ? 'MOVE' : 'COPY'} completed`); } async expunge(report, onlyIndexes = null) { const targets = this.messages.map((m, i) => ({ m, i })).filter(({ m, i }) => m.flags.includes('\\Deleted') && (!onlyIndexes || onlyIndexes.includes(i))); for (const { m } of targets) { // 휴지통에서 지우면 영구 삭제. 임시 보관함(Drafts)의 초안도 바로 지웁니다: Thunderbird·Apple Mail 은 초안을 자동 저장할 때마다 // 새 버전을 APPEND 하고 옛 버전에 \Deleted 를 붙여 EXPUNGE 하므로, 예전처럼 휴지통으로 옮기면 저장할 때마다 옛 초안이 휴지통에 쌓였습니다(0.8.16 수정). // 초안은 발송 서비스에서 온 메일이 아니라 tombstone 이 필요 없습니다. if (this.mailbox === 'trash' || this.mailbox === 'drafts') { const row = await this.env.DB.prepare('SELECT provider_id FROM messages WHERE id=?').bind(m.id).first(); const statements = []; if (row?.provider_id) statements.push(this.env.DB.prepare("INSERT OR IGNORE INTO tombstones(provider_id,deleted_at,reason) VALUES(?,?,'deleted')").bind(row.provider_id, now())); statements.push(this.env.DB.prepare('DELETE FROM messages WHERE id=?').bind(m.id)); await this.env.DB.batch(statements); } else { await this.env.DB.batch([ this.env.DB.prepare("UPDATE messages SET previous_folder=folder,folder='trash',updated_at=? WHERE id=?").bind(now(), m.id), this.env.DB.prepare('INSERT INTO imap_flags(message_id,flags,updated_at) VALUES(?,?,?) ON CONFLICT(message_id) DO UPDATE SET flags=excluded.flags,updated_at=excluded.updated_at').bind(m.id, JSON.stringify(m.flags.filter((f) => f !== '\\Deleted')), now()), ]); } } for (const { i } of targets.slice().reverse()) { this.messages.splice(i, 1); if (report) this.send(`* ${i + 1} EXPUNGE`); } } /** * APPEND: 클라이언트가 올린 메일 원본을 메일함에 저장합니다(보낸 메일 사본·초안 등). * @param {string} tag 명령 태그 * @param {Array} args 토큰화된 인자(메일함, [플래그 목록], [날짜], 원본 리터럴) * @param {Buffer[]} literals 회선에서 받은 리터럴 원본 바이트. 메일 원본은 마지막 리터럴입니다. * 왜 literals 를 따로 받는가: 토큰화는 리터럴을 UTF-8 문자열로 바꾸는데, 8bit EUC-KR 본문이나 이진 부분처럼 * UTF-8 이 아닌 바이트는 이 과정에서 U+FFFD 로 깨집니다. 원본은 받은 바이트 그대로 저장해야 합니다. */ async append(tag, args, literals = []) { const folder = folderOf(args[0]); if (!folder) return this.send(`${tag} NO [TRYCREATE] no such mailbox`); let i = 1; let flags = []; let date = null; if (Array.isArray(args[i])) { flags = args[i].map(String); i++; } if (typeof args[i] === 'string' && /^\s?\d{1,2}-[A-Za-z]{3}-\d{4}/.test(args[i])) { date = args[i]; i++; } const literal = args[i]; if (typeof literal !== 'string' || !literal.length) return this.send(`${tag} BAD APPEND needs message literal`); const bytes = literals.length ? literals[literals.length - 1] : Buffer.from(literal, 'utf8'); // 메일함 최대 크기(0.9.0)를 넘으면 RFC 9208 의 OVERQUOTA 응답 코드로 거부합니다(메일 프로그램이 "용량 초과" 로 보여 줌). let result; try { result = await appendMessage(this.env, folder, bytes, { flags, internalDate: date && parseImapDateTime(date) ? new Date(parseImapDateTime(date)).toISOString() : null, mailbox: this.user.mailbox }); } catch (e) { if (e.message === 'quota_exceeded') return this.send(`${tag} NO [OVERQUOTA] mailbox quota exceeded`); if (e.message === 'storage_full') return this.send(`${tag} NO [OVERQUOTA] server storage is full`); throw e; } await this.env.DB.prepare('INSERT OR IGNORE INTO imap_uids(message_id,folder) VALUES(?,?)').bind(result.id, folder).run(); const row = await this.env.DB.prepare('SELECT id FROM imap_uids WHERE message_id=? AND folder=?').bind(result.id, folder).first(); if (this.mailbox === folder) await this.refresh(); this.send(`${tag} OK [APPENDUID ${await this.uidValidity()} ${row.id}] APPEND completed`); } startIdle(tag) { this.send('+ idling'); this.idle = { tag, timer: setInterval(() => { this.queue = this.queue.then(() => this.refresh({ expunge: true })).catch(() => {}); }, Number(this.env.IMAP_IDLE_POLL_MS || 10000)) }; this.socket.setTimeout(35 * 60 * 1000); } stopIdle() { if (this.idle) { clearInterval(this.idle.timer); this.idle = null; } } } /** @param {{key:string|Buffer,cert:string|Buffer}|null} tlsOptions */ /** allowPlaintext: true 항상 허용 / false 항상 거부 / null(기본) 접속마다 관리자 설정 "평문 로그인 허용" 을 읽음(0.9.7, pop3.mjs 와 같음). */ /** TLS 접속의 SNI 서버 이름(평문 접속이면 ''). 1.0.7 다중 도메인에서 도메인 없는 로그인 ID 의 도메인을 짐작하는 데 씁니다. */ const sniName = (socket) => String(socket?.servername || ''); /** resolveEnv(login, servername)(1.0.7): 로그인마다 쓸 env 를 고르는 함수(domains.mjs resolveLogin). 없으면 env 하나로 동작. */ export function createImap(env, { hostname = 'mail.arihakdang.com', tlsOptions = null, allowPlaintext = null, resolveEnv = null } = {}) { const shared = { env, hostname, secureContext: tlsOptions ? tls.createSecureContext(tlsOptions) : null, allowPlaintext, resolveEnv }; const plain = net.createServer((socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: false }); }); // secure 서버는 인증서가 없어도 만들어 두고, 인증서가 없으면 포트를 기억만 합니다(pop3.mjs 와 같은 구조, 0.9.7). const secure = tls.createServer({ ...(tlsOptions || {}), minVersion: 'TLSv1.2' }, (socket) => { socket.on('error', () => {}); new Session(shared, socket, { secure: true }); }); secure.on('tlsClientError', () => {}); let pendingSecure = null; const listenOn = (server, port, host, label, out) => new Promise((res, rej) => server.once('error', rej).listen(port, host, () => { out.push(`${label} ${host}:${port}`); res(); })); return { plain, secure, setTls(options) { shared.secureContext = tls.createSecureContext(options); secure.setSecureContext(options); if (pendingSecure && !secure.listening) { const { port, host } = pendingSecure; pendingSecure = null; listenOn(secure, port, host, 'imaps', []).then(() => console.log(` IMAP TLS 포트를 열었습니다: ${host}:${port}`)).catch((e) => console.error(' IMAP TLS 포트 열기 실패:', e.message)); } }, async listen({ plainPort, securePort, host = '0.0.0.0' }) { const out = []; if (plainPort) await listenOn(plain, plainPort, host, 'imap', out); if (securePort) { if (shared.secureContext) await listenOn(secure, securePort, host, 'imaps', out); else pendingSecure = { port: securePort, host }; } return out; }, close() { plain.close(); if (secure.listening) secure.close(); }, }; }