Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
112 lines
7.4 KiB
JavaScript
112 lines
7.4 KiB
JavaScript
'use strict';
|
|
/**
|
|
* AriMail ↔ Haraka 연결 플러그인(1.0.0, 요구사항 1). haraka/run.mjs 가 Haraka 설치 디렉터리(<APP_DIR>/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=<HARAKA_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 });
|
|
};
|