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