Files

78 lines
6.1 KiB
JavaScript

/**
* 메일 서비스 모듈 선택(1.0.0, 요구사항 2) — 발송·수신 "길" 을 설정(config.json 의 inbound / outbound)으로 고릅니다.
*
* 발신(outbound)
* resend : Resend REST API(providers/resend.mjs). 예전과 같은 기본값.
* haraka : 내장 Haraka 의 로컬 제출 포트(127.0.0.1:HARAKA_RELAY_PORT, 암호화·인증 없음)로 넘기면 Haraka 가 받는 쪽 MX 로 직접 배달합니다.
* smtp : 외부 SMTP 중계 서버(SMTP_RELAY_*)로 넘깁니다(회사 메일 서버, 다른 발송 서비스의 SMTP 등).
* 수신(inbound)
* resend : Resend 웹훅 + 목록 동기화(store.mjs).
* haraka : 내장 Haraka 가 25 포트로 받은 메일을 로컬 HTTP(/api/inbound/haraka)로 넘겨 줍니다(store.mjs ingestRaw).
*
* 발송 호출 지점은 모두 sendMail(env, payload, key) 하나입니다. payload 는 Resend 요청과 같은 모양(from, to, cc, bcc, reply_to, subject,
* text, html, headers, attachments[{filename, content(base64), content_type, content_id}])이고 돌려주는 값은 { id } 입니다.
* 새 발송 서비스를 더하려면 providers/<이름>.mjs 에 같은 모양으로 send 를 만들고 여기 outboundMode·sendMail·outboundStatus 에 한 줄씩 더합니다.
*/
import { resend, providerStatus as resendStatus } from './resend.mjs';
import { sendViaSmtp } from './smtp.mjs';
import { smtpProbe } from './smtp-client.mjs';
import { now, address } from '../util.mjs';
import { enqueue, finish, pace } from '../sendqueue.mjs';
export const OUTBOUND_MODES = ['resend', 'haraka', 'smtp'], INBOUND_MODES = ['resend', 'haraka'];
/** 설정된 발신 방식(알 수 없는 값은 resend). env.MAIL_OUTBOUND 는 config.mjs 의 applyConfig 가 채웁니다. */
export const outboundMode = (env) => (OUTBOUND_MODES.includes(env.MAIL_OUTBOUND) ? env.MAIL_OUTBOUND : 'resend');
/** 설정된 수신 방식(알 수 없는 값은 resend). */
export const inboundMode = (env) => (INBOUND_MODES.includes(env.MAIL_INBOUND) ? env.MAIL_INBOUND : 'resend');
/** Resend 를 어느 쪽으로든 쓰는지(동기화·웹훅·도메인 상태 확인이 의미 있는지). */
export const usesResend = (env) => inboundMode(env) === 'resend' || outboundMode(env) === 'resend';
/** 내장 Haraka 를 어느 쪽으로든 쓰는지(haraka/run.mjs 가 Haraka 를 띄울지). */
export const usesHaraka = (env) => inboundMode(env) === 'haraka' || outboundMode(env) === 'haraka';
/** 발신 방식에 따른 중계 서버 접속 정보. haraka 는 로컬 고정, smtp 는 SMTP_RELAY_* 설정. */
export function relaySettings(env, mode = outboundMode(env)) {
// haraka: 서버에 하나뿐인 Haraka 의 제출 포트는 기본 도메인의 설정(HARAKA_RELAY_PORT)이 정합니다(1.0.7: 추가 도메인도 같은 포트로 넘김).
if (mode === 'haraka') { const primary = env.TENANTS?.primary?.env || env; return { host: '127.0.0.1', port: Number(primary.HARAKA_RELAY_PORT || 2525), security: 'none', user: '', pass: '' }; }
if (mode === 'smtp') return { host: String(env.SMTP_RELAY_HOST || '').trim(), port: Number(env.SMTP_RELAY_PORT || 587), security: ['none', 'starttls', 'tls'].includes(env.SMTP_RELAY_SECURITY) ? env.SMTP_RELAY_SECURITY : 'starttls', user: String(env.SMTP_RELAY_USER || ''), pass: String(env.SMTP_RELAY_PASS || ''), rejectUnauthorized: env.SMTP_RELAY_INSECURE !== '1' };
return null;
}
/**
* 메일 한 통 발송. 어느 모듈을 쓰든 { id } 를 돌려주고, 실패는 공통 오류 코드(mail_not_configured / mail_auth_failed / mail_rejected /
* mail_rate_limited / mail_provider_unavailable)의 Error 로 던집니다(providerMessage 에 서비스 응답).
* @param {string} [key] 멱등 키(Resend 만 씀)
*/
export async function sendMail(env, payload, key) {
const mode = outboundMode(env);
// [Ariweb Suite 변경] 발송 대기열 기록 → 안전 발송 속도(도메인·서버 초당 5건) → 발송 모듈. 같은 멱등 키가 이미 보내졌으면 다시 보내지 않습니다.
const domain = String(env.MAILBOX || '').split('@')[1] || 'localhost';
const q = await enqueue(env, { domain, mailbox: address(payload.from), key, payload });
if (q.alreadySent) return { id: q.providerId, duplicate: true };
try {
await pace(domain);
const r = mode === 'resend' ? await resend(env, '/emails', 'POST', payload, key) : await sendViaSmtp(env, payload, relaySettings(env, mode));
await finish(env, q.id, { providerId: r.id }); return r;
} catch (e) { await finish(env, q.id, { error: e.message + (e.providerMessage ? ' ' + e.providerMessage : '') }); throw e; }
}
// 상태 캐시는 env(= DB) 마다 따로 둡니다(시험처럼 한 프로세스에 여러 env 가 있을 때 섞이지 않게). 운영은 env 가 하나라 전역 캐시와 같습니다.
const statusCaches = new WeakMap();
/**
* 발신 서비스 상태(설정 화면·상태 화면). resend 는 도메인 검증 상태까지, haraka/smtp 는 중계 서버 접속·EHLO(·AUTH) 확인입니다.
* 60초 캐시(force 로 즉시). 돌려주는 모양은 예전 providerStatus 와 같고 mode 와 relay 를 더합니다.
* @returns {Promise<{mode:string, provider:string, detail?:string, domain?:object, relay?:object, checkedAt:string}>}
*/
export async function outboundStatus(env, { force = false } = {}) {
const mode = outboundMode(env), key = mode + JSON.stringify(relaySettings(env, mode) || {}) + (env.RESEND_API_KEY ? 'k' : '');
const statusCache = statusCaches.get(env.DB || env) || { at: 0, key: '', value: null };
if (!force && statusCache.value && statusCache.key === key && Date.now() - statusCache.at < 60000) return statusCache.value;
let value;
if (mode === 'resend') value = { mode, ...(await resendStatus(env, { force })) };
else {
const relay = relaySettings(env, mode);
const probe = await smtpProbe({ ...relay, hostname: env.PUBLIC_HOST || 'localhost' });
value = { mode, provider: probe.ok ? 'ok' : probe.error, detail: probe.detail, relay: { host: relay.host, port: relay.port, security: relay.security, auth: !!relay.user }, checkedAt: now() };
}
statusCaches.set(env.DB || env, { at: Date.now(), key, value }); return value;
}