'use strict'; /** * AriMail ↔ Haraka 연결 플러그인(1.0.0, 요구사항 1). haraka/run.mjs 가 Haraka 설치 디렉터리(/haraka/plugins/)에 복사하고 * config/plugins 에 'arimail' 로 올립니다. Haraka 플러그인은 CommonJS 라 이 파일만 require 문법을 씁니다. * * 하는 일(세 개의 훅) * connect : 127.0.0.1 에서 "제출 포트(relay_port)" 로 들어온 접속은 게이트웨이가 보내는 메일이므로 connection.relaying = true 로 둡니다. * relaying 이 켜진 접속은 Haraka 가 받는 쪽 MX 를 찾아 직접 배달하는 "발신" 경로(outbound 큐)로 처리합니다. * 다른 주소·다른 포트에서 온 접속은 relaying 이 꺼진 "수신" 경로이며, 우리 도메인의 실제 계정으로 오는 메일만 받습니다(오픈 릴레이 방지). * rcpt : 수신 접속의 받는 사람마다 (1) 우리 도메인인지, (2) 게이트웨이에 그 메일함이 있는지(GET /api/inbound/haraka/rcpt) 확인합니다. * 없는 사용자는 RCPT 단계에서 바로 거부해(550) 받아 놓고 반송하는 역산란(backscatter)을 만들지 않습니다. * queue : 수신한 메일 원본을 모아 게이트웨이의 POST /api/inbound/haraka 로 넘깁니다(헤더: 토큰, 트랜잭션 id, 봉투 보낸 사람/받는 사람). * 게이트웨이가 저장하면 250(OK), 메일함이 가득 차 건너뛰면 452(DENYSOFT — 보낸 쪽이 나중에 다시 시도), 게이트웨이 오류면 451(DENYSOFT). * * 설정(config/arimail.ini, run.mjs 가 만듭니다) * [main] gateway=http://127.0.0.1:8790 token= relay_port=2525 domains=example.com[,other.com] timeout_ms=30000 */ const http = require('node:http'); const { URL } = require('node:url'); // Haraka 플러그인 샌드박스에는 전역 URL 이 없습니다 const { Writable } = require('node:stream'); exports.register = function () { this.load_arimail_ini(); this.register_hook('connect', 'mark_relay'); this.register_hook('rcpt', 'check_rcpt'); this.register_hook('queue', 'deliver'); }; /** arimail.ini 를 읽고, 파일이 바뀌면 다시 읽습니다(Haraka 의 config 감시 기능). */ exports.load_arimail_ini = function () { this.cfg = this.config.get('arimail.ini', { booleans: [] }, () => this.load_arimail_ini()); const m = this.cfg.main || {}; this.gateway = String(m.gateway || 'http://127.0.0.1:8790').replace(/\/+$/, ''); this.token = String(m.token || ''); this.relayPort = Number(m.relay_port || 2525); this.domains = String(m.domains || '').split(/[,\s]+/).map((d) => d.trim().toLowerCase()).filter(Boolean); this.timeoutMs = Number(m.timeout_ms || 30000); }; const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1'; // Haraka 3 의 주소 객체(address-rfc2821)는 address 가 속성(문자열)입니다. 예전 판은 메서드였으므로 둘 다 받습니다. const addrOf = (a) => (a ? String(typeof a.address === 'function' ? a.address() : a.address || '') : ''); /** connect: 로컬 제출 포트로 온 접속은 발신(relaying). */ exports.mark_relay = function (next, connection) { const port = Number(connection.local && connection.local.port); if (isLoopback(connection.remote && connection.remote.ip) && port === this.relayPort) { connection.relaying = true; connection.loginfo(this, `relay from gateway (local port ${port})`); } next(); }; /** 게이트웨이 API 호출(JSON). 실패는 reject. */ exports.gateway_request = function (method, path, headers, body) { return new Promise((resolve, reject) => { const url = new URL(this.gateway + path); const req = http.request({ hostname: url.hostname, port: url.port || 80, path: url.pathname + url.search, method, headers: { 'X-AriMail-Token': this.token, ...headers } }, (res) => { const chunks = []; res.on('data', (c) => chunks.push(c)); res.on('end', () => { let data = null; try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { data = null; } resolve({ status: res.statusCode, data }); }); }); req.setTimeout(this.timeoutMs, () => req.destroy(new Error('gateway timeout'))); req.on('error', reject); if (body) req.end(body); else req.end(); }); }; /** rcpt: 수신 접속의 받는 사람 확인. */ exports.check_rcpt = function (next, connection, params) { if (connection.relaying) return next(); // 발신(게이트웨이 제출)은 어떤 받는 사람이든 허용 const rcpt = params[0]; const addr = addrOf(rcpt).toLowerCase(), domain = String(rcpt.host || addr.split('@')[1] || '').toLowerCase(); if (!this.domains.includes(domain)) return next(DENY, 'Relay access denied'); this.gateway_request('GET', '/api/inbound/haraka/rcpt?addr=' + encodeURIComponent(addr), {}) .then(({ status, data }) => { if (status === 200 && data && data.local) return next(OK); if (status === 200 && data && data.local === false) { connection.loginfo(this, `rcpt rejected (no such user): ${addr}`); return next(DENY, 'No such user here'); } connection.logwarn(this, `gateway rcpt check failed: HTTP ${status}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); }) .catch((e) => { connection.logerror(this, `gateway rcpt check error: ${e.message}`); next(DENYSOFT, 'Mailbox lookup temporarily unavailable'); }); }; /** queue: 원본을 모아 게이트웨이로 넘깁니다. */ exports.deliver = function (next, connection) { const txn = connection.transaction; if (!txn) return next(); const chunks = []; const sink = new Writable({ write(chunk, _enc, cb) { chunks.push(chunk); cb(); } }); sink.on('error', (e) => { connection.logerror(this, `message stream error: ${e.message}`); next(DENYSOFT, 'Temporary failure reading message'); }); sink.on('finish', () => { const eml = Buffer.concat(chunks); const rcpt = (txn.rcpt_to || []).map((r) => addrOf(r).toLowerCase()); const from = addrOf(txn.mail_from); const headers = { 'Content-Type': 'message/rfc822', 'Content-Length': String(eml.length), 'X-AriMail-Id': String(txn.uuid || ''), 'X-AriMail-From': from, 'X-AriMail-Rcpt': JSON.stringify(rcpt) }; this.gateway_request('POST', '/api/inbound/haraka', headers, eml) .then(({ status, data }) => { if (status >= 200 && status < 300 && data && data.ok) { if (data.skipped === 'quota_exceeded' || data.skipped === 'storage_full') { connection.lognotice(this, `mailbox full: ${rcpt.join(',')}`); return next(DENYSOFT, 'Mailbox full, try again later'); } connection.loginfo(this, `delivered to gateway${data.imported ? ' id=' + data.id : ' (' + (data.skipped || 'skipped') + ')'}`); return next(OK, 'Message accepted for delivery'); } connection.logerror(this, `gateway rejected: HTTP ${status} ${data && data.error ? data.error : ''}`); next(DENYSOFT, 'Delivery to mailbox temporarily failed'); }) .catch((e) => { connection.logerror(this, `gateway delivery error: ${e.message}`); next(DENYSOFT, 'Delivery to mailbox temporarily failed'); }); }); // haraka-message-stream 의 옵션 뜻(3.1+): dot_stuffed:true = 저장된 SMTP 점 스터핑을 풀어 "로컬 전달용" 원본으로 내보냄(false 는 SMTP 중계용으로 그대로 둠). // 이름이 직관과 반대이므로 주의. 줄 끝은 CRLF 로 맞춥니다(게이트웨이 해석기 기준). txn.message_stream.pipe(sink, { line_endings: '\r\n', dot_stuffed: true }); };