Files
AriWeb-Suite/portal/mailer.mjs
T

54 lines
5.2 KiB
JavaScript

/**
* portal/mailer.mjs — 포털 발신 메일(이메일 확인·비밀번호 재설정·초대·필수 알림).
*
* 발송 방식은 설정(deps.config.values.mailer: 'smtp' | 'resend' | 'log')과 보관소의 비밀로 정한다.
* smtp : AriMail 제출 포트(기본 127.0.0.1:587 STARTTLS 또는 465)에 로그인해 보낸다. 계정 비밀번호는 vault 'mailer:smtp-pass'.
* resend : Resend 호환 HTTP API(https://api.resend.com/emails). 키는 vault 'mailer:resend-key'.
* log : 보내지 않고 로그에만(개발·검증 환경 기본). 테스트는 deps.mailer 를 포획 객체로 바꿔 끼운다.
* 어떤 방식이든 본문에 비밀번호·토큰 원문 외의 비밀값을 넣지 않으며, 링크의 토큰은 1회용이다.
* 인터페이스: deps.mailer.send({ to, subject, text, html? }) → Promise<{ ok, id?, mode }>
*/
import net from 'node:net';
import tls from 'node:tls';
export function create(deps) {
const cfg = deps.config; const log = deps.log || console.log;
const mode = () => (cfg.values && cfg.values.mailer) || process.env.ARIWEB_MAILER || 'log';
async function send({ to, subject, text, html }) {
const m = mode(); const from = cfg.mailFrom || 'no-reply@ariweb.net';
if (!to || !subject) throw new Error('받는 사람과 제목이 필요합니다');
if (m === 'resend') {
const key = deps.vault.get('mailer:resend-key'); if (!key) throw new Error('메일 발송 키(mailer:resend-key)가 보관소에 없습니다');
const r = await fetch((cfg.values.resendBase || 'https://api.resend.com') + '/emails', { method: 'POST', headers: { Authorization: 'Bearer ' + key, 'Content-Type': 'application/json' }, body: JSON.stringify({ from, to: [].concat(to), subject, text, html }), signal: AbortSignal.timeout(20000) });
const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(`메일 발송 거부(${r.status}): ${j.message || j.name || ''}`);
return { ok: true, id: j.id, mode: m };
}
if (m === 'smtp') return smtpSend({ host: cfg.values.smtpHost || '127.0.0.1', port: Number(cfg.values.smtpPort || 587), user: cfg.values.smtpUser || from, pass: deps.vault.get('mailer:smtp-pass') || '', secure: String(cfg.values.smtpPort) === '465', from, to: [].concat(to), subject, text, html });
log(`[mailer:log] → ${[].concat(to).join(',')} | ${subject}`);
return { ok: true, mode: 'log' };
}
return { send, mode };
}
/** 작은 SMTP 클라이언트(AUTH PLAIN·STARTTLS). 외부 의존성 없이 제출 서버로 한 통 보낸다. */
function smtpSend({ host, port, user, pass, secure, from, to, subject, text, html }) {
return new Promise((resolve, reject) => {
let sock = secure ? tls.connect({ host, port, servername: host }) : net.connect({ host, port }); let buf = ''; const q = [];
const fail = (e) => { try { sock.destroy(); } catch (_) {} reject(e instanceof Error ? e : new Error(String(e))); };
const wait = () => new Promise((res, rej) => q.push({ res, rej }));
const cmd = async (line, okCodes = [250]) => { if (line != null) sock.write(line + '\r\n'); const r = await wait(); if (!okCodes.includes(Number(r.slice(0, 3)))) throw new Error(`SMTP ${line ? line.split(' ')[0] : '연결'} 실패: ${r.trim().slice(0, 120)}`); return r; };
const onData = (d) => { buf += d.toString('utf8'); const lines = buf.split('\r\n'); buf = lines.pop(); let resp = ''; for (const l of lines) { resp += l + '\r\n'; if (/^\d{3} /.test(l)) { const w = q.shift(); if (w) w.res(resp); resp = ''; } } };
sock.on('data', onData); sock.on('error', fail);
(async () => {
await cmd(null, [220]); await cmd('EHLO ariweb-portal');
if (!secure) { await cmd('STARTTLS', [220]); sock.removeListener('data', onData); sock = tls.connect({ socket: sock, servername: host }); sock.on('data', onData); sock.on('error', fail); await new Promise((r) => sock.once('secureConnect', r)); await cmd('EHLO ariweb-portal'); }
if (pass) await cmd('AUTH PLAIN ' + Buffer.from(`\0${user}\0${pass}`).toString('base64'), [235]);
await cmd(`MAIL FROM:<${from}>`); for (const t of to) await cmd(`RCPT TO:<${t}>`, [250, 251]); await cmd('DATA', [354]);
const boundary = 'aw' + Date.now().toString(36); const enc = (s) => Buffer.from(s, 'utf8').toString('base64').replace(/(.{76})/g, '$1\r\n');
const headers = [`From: ${from}`, `To: ${to.join(', ')}`, `Subject: =?UTF-8?B?${Buffer.from(subject, 'utf8').toString('base64')}?=`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${Date.now()}.${Math.random().toString(36).slice(2)}@ariweb>`, 'MIME-Version: 1.0'];
const body = html ? [`Content-Type: multipart/alternative; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || ''), `--${boundary}`, 'Content-Type: text/html; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(html), `--${boundary}--`].join('\r\n') : ['Content-Type: text/plain; charset=utf-8', 'Content-Transfer-Encoding: base64', '', enc(text || '')].join('\r\n');
const r = await cmd(headers.join('\r\n') + '\r\n' + body + '\r\n.', [250]); sock.write('QUIT\r\n'); sock.end();
resolve({ ok: true, id: (r.match(/queued as (\S+)/i) || [])[1], mode: 'smtp' });
})().catch(fail);
});
}