'use strict'; // 메일 발송: Resend API. 키는 암호화 보관소(lib/vault.js, v1.3.1)에서 읽는다. 관리자가 키 파일 경로를 주면 한 번 읽어 보관소에 암호화해 넣는다. // 발신자는 webmaster@<사이트 도메인>(Resend 에 검증된 도메인일 때) 아니면 webmaster@<기본 발신 도메인>. const fs = require('fs'); const settings = require('./settings'); const providers = require('./providers'); const KEY_NAMES = ['RESEND_API_KEY', 'RESEND_KEY', 'resend_api_key', 'MAIL_API_KEY', 'API_KEY', 'api_key']; const FROM_NAMES = ['MAIL_FROM', 'FROM', 'SENDER', 'ID', 'EMAIL']; function parseKeyFile(text) { const out = {}; try { const j = JSON.parse(text); if (j && typeof j === 'object') { for (const [k, v] of Object.entries(j)) if (typeof v === 'string') out[k] = v; return out; } } catch (_) {} for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (!line || line.startsWith('#')) continue; const m = line.match(/^(?:export\s+)?([A-Za-z0-9_.\-]+)\s*[=:]\s*(.*)$/); if (m) out[m[1]] = m[2].trim().replace(/^["']|["']$/g, ''); } return out; } function mailConfig() { const s = settings.load(); return { keyFile: '', apiKey: '', defaultDomain: '', fromName: '', formBase: '', ...(s.mail || {}) }; } /** * API 키 결정. {key, source, keyName, fromDomain} * v1.3.1: 암호화 보관소(lib/vault.js 'resend') 우선. 1.3.0 이전 방식(설정 입력값, 평문 키 파일)은 보관소로 옮기기 전 호환용. */ function resolveKey() { const c = mailConfig(); try { const v = require('./vault').resolve('resend'); if (v && v.secret) { let fromDomain = ''; for (const f of FROM_NAMES) { const m = String((v.settings || {})[f] || '').match(/@([a-z0-9.-]+\.[a-z]{2,})$/i); if (m) { fromDomain = m[1].toLowerCase(); break; } } return { key: v.secret, source: 'vault', keyName: v.source, fromDomain }; } } catch (_) {} if (c.apiKey) { try { return { key: providers.dec(c.apiKey), source: 'settings' }; } catch (_) {} } if (c.keyFile) { try { const kv = parseKeyFile(fs.readFileSync(c.keyFile, 'utf8')); for (const n of KEY_NAMES) if (kv[n]) { let fromDomain = ''; for (const f of FROM_NAMES) { const v = kv[f] || ''; const m = v.match(/@([a-z0-9.-]+\.[a-z]{2,})$/i); if (m) { fromDomain = m[1].toLowerCase(); break; } } return { key: kv[n], source: 'file', keyName: n, fromDomain }; } return { key: '', source: 'file', error: `키 파일에서 ${KEY_NAMES.join('/')} 항목을 찾지 못했습니다 (항목: ${Object.keys(kv).join(', ') || '없음'})` }; } catch (e) { return { key: '', source: 'file', error: '키 파일을 읽을 수 없습니다: ' + e.message }; } } return { key: '', source: null, error: '메일 발송 키가 설정되지 않았습니다 (관리자 콘솔 › 설정 › 메일 발송)' }; } async function resend(path, method, body) { const k = resolveKey(); if (!k.key) throw Object.assign(new Error(k.error || 'mail_not_configured'), { status: 503 }); const r = await fetch((process.env.RESEND_API_BASE || 'https://api.resend.com') + path, { method: method || 'GET', headers: { Authorization: 'Bearer ' + k.key, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(30000) }); const data = await r.json().catch(() => ({})); if (!r.ok) throw Object.assign(new Error(`메일 발송 실패 (Resend ${r.status}): ${String(data.message || data.name || '').slice(0, 200)}`), { status: 502, providerStatus: r.status }); return data; } let domainCache = { at: 0, list: [] }; async function verifiedDomains(force) { if (!force && Date.now() - domainCache.at < 300000) return domainCache.list; try { const d = await resend('/domains'); domainCache = { at: Date.now(), list: (d.data || []).filter((x) => x.status === 'verified').map((x) => String(x.name).toLowerCase()) }; } catch (_) { if (!domainCache.at) domainCache = { at: Date.now(), list: [] }; } return domainCache.list; } /** 발신 주소 선택 */ async function pickFrom(siteDomains) { const c = mailConfig(); const verified = await verifiedDomains(false); const name = c.fromName || 'Webmaster'; const cands = [...(siteDomains || []).map((d) => String(d).toLowerCase().replace(/^www\./, '')), ...(siteDomains || []).map((d) => String(d).toLowerCase())]; // v1.5.27 (요구 8): 사이트 도메인이 검증된 도메인의 서브 도메인(sample.ariweb.net ⊂ ariweb.net)이면 발신 주소는 검증된 상위 도메인으로 — Resend 는 서브 도메인을 따로 // 검증해야 해서 webmaster@sample.ariweb.net 으로 보내면 403 "The sample.ariweb.net domain is not verified" 가 났다(webmaker.ariweb.net 2026-10-15 08:08 UTC) for (const d of cands) { if (verified.includes(d)) return { address: `webmaster@${d}`, name, verified: true }; const parent = verified.find((v) => d.endsWith('.' + v)); if (parent) return { address: `webmaster@${parent}`, name, verified: true, replyDomain: d }; } // 기본 발신 도메인 → 검증된 도메인만 사용. 설정값이 미검증이면 검증된 첫 도메인, 그것도 없으면 설정값/키 파일 도메인 const fileDomain = (resolveKey().fromDomain || ''); const def = (c.defaultDomain || fileDomain || '').toLowerCase(); if (def && verified.includes(def)) return { address: `webmaster@${def}`, name, verified: true }; if (verified.length) return { address: `webmaster@${verified[0]}`, name, verified: true }; if (def) return { address: `webmaster@${def}`, name, verified: false }; return { address: `webmaster@${(siteDomains || [])[0] || 'localhost'}`, name, verified: false }; } /** * 메일 발송. attachments: [{ filename, content(Buffer|base64 문자열) }] — Resend 는 base64 본문을 받는다(총 40MB 제한). */ async function send({ from, to, subject, text, html, replyTo, attachments }) { const att = (attachments || []).slice(0, 10).map((a) => ({ filename: String(a.filename || 'file').slice(0, 120), content: Buffer.isBuffer(a.content) ? a.content.toString('base64') : String(a.content || '') })); return resend('/emails', 'POST', { from: from.name ? `${from.name} <${from.address}>` : from.address, to: Array.isArray(to) ? to : [to], subject, text, html, ...(att.length ? { attachments: att } : {}), ...(replyTo ? { reply_to: replyTo } : {}) }); } async function status() { const k = resolveKey(); const c = mailConfig(); const out = { configured: !!k.key, source: k.source, keyName: k.keyName || null, fileDomain: k.fromDomain || null, keyFile: c.keyFile, defaultDomain: c.defaultDomain, fromName: c.fromName, formBase: c.formBase, error: k.error || null, verifiedDomains: [] }; if (k.key) { try { out.verifiedDomains = await verifiedDomains(true); } catch (e) { out.error = e.message; } } return out; } module.exports = { mailConfig, resolveKey, send, pickFrom, verifiedDomains, status, parseKeyFile, resend }; // resend: Resend API 직접 호출 (v1.3.1 메일 시스템이 도메인 등록·확인에 사용)