Files
AriWeb-Suite/modules/arimail/server/main.mjs
T

346 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AriMail 게이트웨이 본체(1.0.5 부터 index.mjs 는 경고 필터만 거는 부트스트랩이고 이 파일이 서버입니다; 1.0.0 부터 프로젝트 이름 AriMail — 저장소 https://source.ibit.kr/OZ-Group/AriMail).
* - HTTP: 웹 메일함 + API + Resend 웹훅 + Haraka 수신 전달(/api/inbound/haraka) (nginx 뒤, 127.0.0.1:8790)
* - 전체 설정 화면(1.0.7)·초기 설정 페이지: 전용 포트(SETUP_PORT, 기본 8791, 토큰 인증)와 /setup(관리자 세션) — 설정 파일(config.json)을 웹에서 채웁니다(1.0.0)
* - POP3: 110(STLS) / 995(TLS)
* - IMAP: 143(STARTTLS) / 993(TLS)
* - SMTP 제출: 587(STARTTLS) / 465(TLS) — 로그인 비밀번호로 인증하고 설정된 발송 모듈(Resend / 내장 Haraka / 외부 SMTP 중계)로 발송
* - 작업: 1시간마다 Resend 목록 동기화(Resend 를 쓰는 방향만), 만료 세션 정리
* (0.8.0 부터 메일 보관 기한이 없어 주기 작업이 메일을 지우지 않습니다.)
*
* 설정의 두 층(1.0.0): 환경 파일(gateway.env, systemd EnvironmentFile)에는 배포에 꼭 필요한 값(DB·포트·메일함·로그인 방식·시스템 제한)만 있고,
* 운영 설정(발송·수신 방식, Resend 키, 중계 서버, Haraka, 사이트 정보)은 config.json(config.mjs)에 있어 재시작 없이 바뀝니다.
*
* 다중 도메인(1.0.7, 요구사항 1): 한 프로세스가 여러 도메인을 섬깁니다(server/domains.mjs). 환경 파일의 도메인이 "기본 도메인" 이고, 전체 설정 화면에서
* 더한 도메인은 데이터 디렉터리의 domains/<도메인>/ 에 자기 DB·설정·파일을 갖습니다. HTTP 요청은 Host 헤더로, 메일 프로그램 로그인은 로그인 ID 의 @도메인으로
* 어느 도메인(테넌트 env)의 일인지 고릅니다. 모르는 호스트나 꺼 둔 도메인으로 온 웹 요청에는 "메일 서비스가 존재하지 않습니다/활성화되지 않았습니다" 화면을 줍니다.
*/
import { createServer } from 'node:http';
import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { openDb, d1, migrate } from './db.mjs';
import { handle } from './api.mjs';
import { createPop3 } from './pop3.mjs';
import { createImap } from './imap.mjs';
import { createSmtp } from './smtp.mjs';
import { syncMailbox, cleanupSessions } from './store.mjs';
import { ensureInitialAdmin, currentUser, authMode } from './auth.mjs';
import { uploadLargeFile, largeFileInfo, claimDownload, openLargeFile, finishDownload, cleanupLargeFiles, humanSize, formatExpiry, filesDir, systemLargeFiles } from './files.mjs';
import { escapeHtml } from './util.mjs';
import { memoryRateStore, siteName } from './util.mjs';
import { systemLimits } from './limits.mjs';
import { recoverQueue } from './sendqueue.mjs';
import { loadConfig, configFile, setupToken, mailDomain } from './config.mjs';
import { createSetupServer, setupPage, writeSetupUrls } from './setup.mjs';
import { inboundMode, outboundMode, usesResend, usesHaraka } from './providers/index.mjs';
import { autoVerifyDomain } from './providers/resend.mjs';
import { openTenants, noServicePage, normalizePrefix } from './domains.mjs';
import { isWebmaker } from './webmaker.mjs';
import { usageApi, clientIp } from './usage.mjs';
const HERE = dirname(fileURLToPath(import.meta.url)), ROOT = join(HERE, '..');
const PORT = Number(process.env.PORT || 8790), HOST = process.env.HOST || '127.0.0.1';
const DB_FILE = process.env.DB_FILE || join(ROOT, 'data', 'mail-gateway.sqlite');
const db = openDb(DB_FILE); console.log(` DB: ${DB_FILE}`); migrate(db, join(ROOT, 'migrations'));
const env = { ...process.env, DB: d1(db), RATE: memoryRateStore() };
// 운영 설정(1.0.0): config.json 을 읽어 env 에 덮어씁니다. 파일이 없으면(1.0.0 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
loadConfig(env); console.log(` 설정 파일: ${configFile(env)}${env.CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : ''}`);
// 로그인 방식(0.9.0~, 설치 옵션): LOGIN_MODE=standalone(기본) 은 www 사이트와 연동하지 않는 독립 실행, LOGIN_MODE=www 는 www 사이트 DB 로 관리자를 인증하는 연동 실행,
// LOGIN_MODE=webmaker(1.0.8) 는 웹메이커의 사용자 파일(WEBMAKER_DIR, 선택: WEBMAKER_USER·WEBMAKER_SITE)을 읽기 전용으로 참조하는 연동 실행입니다(추가 도메인은 등록부의 auth 로 정함).
// 값이 없으면 예전처럼 WWW_DB_FILE 유무로 정합니다. standalone 인데 WWW_DB_FILE 이 남아 있으면 무시하고, www·webmaker 인데 경로가 없으면 시작하지 않습니다(설정 실수를 조용히 넘기지 않음).
const LOGIN_MODE = String(env.LOGIN_MODE || (env.WWW_DB_FILE ? 'www' : 'standalone')).trim().toLowerCase();
env.LOGIN_MODE = LOGIN_MODE;
if (LOGIN_MODE === 'standalone') { delete env.WWW_DB_FILE; delete env.WEBMAKER_DIR; }
else if (LOGIN_MODE === 'www') { delete env.WEBMAKER_DIR; if (!env.WWW_DB_FILE) { console.error(' ✖ LOGIN_MODE=www 인데 WWW_DB_FILE 이 없습니다 — www 사이트 DB 경로를 적거나 LOGIN_MODE=standalone 으로 바꾸세요'); process.exit(1); } }
else if (LOGIN_MODE === 'webmaker') { delete env.WWW_DB_FILE; if (!env.WEBMAKER_DIR) { console.error(' ✖ LOGIN_MODE=webmaker 인데 WEBMAKER_DIR 이 없습니다 — 웹메이커 데이터 디렉터리(DATA_ROOT, 예: /data/webmaker)를 적으세요'); process.exit(1); } }
else { console.error(` ✖ LOGIN_MODE 값을 모릅니다: ${LOGIN_MODE} (standalone | www | webmaker)`); process.exit(1); }
// WWW_DB_FILE 이 없으면 독립 실행(0.8.18~): www 관리자 대신 게이트웨이 계정(mail_users)만 쓰고, 기본 메일함도 게이트웨이 계정이 갖습니다.
if (isWebmaker(env)) console.log(` 웹메이커 연동(LOGIN_MODE=webmaker): ${env.WEBMAKER_DIR}${env.WEBMAKER_USER ? ` (sudo -u ${env.WEBMAKER_USER} 도우미로 읽음)` : ''}${env.WEBMAKER_SITE ? `, 사이트 ${env.WEBMAKER_SITE}` : ''}`);
else if (!env.WWW_DB_FILE) console.warn(' ℹ️ 독립 실행(LOGIN_MODE=standalone): www 관리자 없이 게이트웨이 계정만 씁니다'); else console.log(` www 연동(LOGIN_MODE=www): ${env.WWW_DB_FILE}`);
// 시스템 제한(0.9.0~): 설치 시 환경 파일에 적는 상위 제한. 0 은 제한 없음. 시작 로그에 남겨 운영자가 적용 값을 확인할 수 있게 합니다.
{ const l = systemLimits(env); const show = (n) => (n > 0 ? String(n) : '제한 없음'); console.log(` 시스템 제한: 계정 ${show(l.accounts)} · 전체 ${show(l.totalBytes)}B · 메일함 ${show(l.mailboxBytes)}B · 첨부 ${show(l.attachmentBytes)}B · 수신자 ${show(l.recipients)} · 월간 ${show(l.monthlyMessages)}통`); }
// 초기 관리자(0.8.24~): 독립 실행이고 계정이 하나도 없으면 admin / adminpasswd 관리자 계정을 만들어 설치 직후 바로 로그인할 수 있게 합니다.
// 화면은 이 계정이 초기 비밀번호를 쓰는 동안 경고를 보여 주고, 비밀번호를 바꾸면 경고가 사라집니다.
try { const made = await ensureInitialAdmin(env); if (made) console.warn(` ⚠️ 초기 관리자 계정을 만들었습니다: ${made.login} / ${made.password} — 관리자 페이지(/admin)에서 바로 비밀번호를 바꾸세요`); }
catch (e) { console.error(' 초기 관리자 계정 생성 실패:', e.message); }
// ------------------------------------------------------------------ 정적 화면
/**
* JSON 파일 읽기(1.0.5): 잠시 뒤 다시 시도합니다. mail.heart-m.com 의 0.9.7 배포 직후 첫 시작이 package.json 을 읽다 "Unexpected end of JSON input" 으로
* 죽은 기록이 있습니다(systemd 가 2초 뒤 다시 띄워 정상). 릴리스 복사와 재시작 사이의 짧은 경쟁으로 보이며, 어느 경우든 비어 있는 파일 한 번으로
* 프로세스가 죽지 않게 3번(200ms 간격) 다시 읽고, 끝내 실패하면 기본값을 쓰며 오류를 기록합니다.
*/
function readJsonSync(file, fallback) {
for (let attempt = 1; ; attempt++) {
try { const text = readFileSync(file, 'utf8'); if (!text.trim()) throw new Error('empty file'); return JSON.parse(text); }
catch (e) {
if (attempt >= 3) { if (fallback === undefined) throw e; console.error(` ✖ ${file} 을 읽지 못해 기본값을 씁니다: ${e.message}`); return fallback; }
const until = Date.now() + 200; while (Date.now() < until) { /* 동기 대기(시작 단계라 이벤트 루프를 막아도 무방) */ }
}
}
}
const baseStrings = readJsonSync(join(ROOT, 'lang', 'ko.json'));
// VERSION: package.json 의 버전(예: 0.8.1). 화면의 "아리학당 메일" 아래 작은 글씨와 /api/health 응답에 씁니다.
// 배포 스크립트가 릴리스마다 package.json 을 함께 복사하므로 지금 돌고 있는 코드의 버전과 항상 같습니다.
const VERSION = readJsonSync(join(ROOT, 'package.json'), { version: '0.0.0' }).version;
env.APP_VERSION = VERSION;
{ const l = systemLargeFiles(env); console.log(` 대용량 첨부파일 시스템 상한: ${humanSize(l.bytes)} · ${l.days}일 · ${l.downloads}회 · 저장 위치 ${filesDir(env)}`); }
const indexHtml = readFileSync(join(ROOT, 'web', 'index.html'), 'utf8');
// ------------------------------------------------------------------ TLS(메일 포트·설정 페이지)
function readTls() {
if (!env.POP_TLS_CERT || !env.POP_TLS_KEY) return null;
try { return { key: readFileSync(env.POP_TLS_KEY), cert: readFileSync(env.POP_TLS_CERT), mtime: statSync(env.POP_TLS_CERT).mtimeMs }; }
catch (e) { console.warn(' ⚠️ POP TLS 인증서를 읽지 못했습니다:', e.message); return null; }
}
let tlsFiles = readTls(); env.POP_TLS_ACTIVE = !!tlsFiles;
/**
* 도메인(테넌트) env 하나를 쓸 준비(1.0.7): 버전, 문구 묶음, 화면 캐시, 설정 변경 훅, TLS 상태. 기본 도메인과 추가 도메인 모두 같은 함수로 준비합니다.
* 지금 설정으로 만든 문구 묶음: 왼쪽 위 서비스 이름(appName, 0.8.25~)은 메일 도메인에서 정합니다(heart-m.com → "HEART-M 메일").
* 탭 제목(title)은 SITE_TITLE(1.0.0 부터 설정 파일에서 바꿀 수 있음), 없으면 서비스 이름. 설정이 바뀌면(ON_CONFIG_CHANGE) 다시 만듭니다.
* STRINGS: 서버가 만드는 메일 본문 문구(대용량 첨부파일 블록, 0.9.3)에도 같은 묶음을 씁니다.
*/
function prepareTenant(t) {
const stringsFor = () => { const s = { ...baseStrings }; s.appName = siteName(mailDomain(t), s.appName); s.title = t.SITE_TITLE ? String(t.SITE_TITLE).trim() : s.appName; return s; };
t.APP_VERSION = VERSION; t.POP_TLS_ACTIVE = !!tlsFiles; t.STRINGS = stringsFor(); t.PAGE = null;
// 설정 저장(초기 설정 페이지·관리자) 뒤 바로 반영할 것: 문구(탭 제목)와 화면 캐시. 발송·수신 모듈은 호출마다 env 를 읽으므로 따로 할 일이 없습니다.
t.ON_CONFIG_CHANGE = () => { t.STRINGS = stringsFor(); t.PAGE = null; };
if (t !== env) {
// 추가 도메인의 시작 안내(기본 도메인은 위에서 이미 출력). 빠진 운영 설정은 경고만 하고 계속 뜹니다.
console.log(` [${t.DOMAIN}] 메일 서비스: 수신 ${inboundMode(t)} · 발신 ${outboundMode(t)} · DB ${t.DB_FILE}`);
if (usesResend(t) && !t.RESEND_API_KEY) console.warn(` [${t.DOMAIN}] ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(전체 설정 화면에서 채우세요)`);
}
}
// 화면 한 장(index.html)에 문구·버전·호스트·도메인을 심어 둡니다. 로그인 안내 팝업이 "이 도메인의 메일 서비스" 를 설명하는 데 씁니다.
// __STANDALONE__: www 사이트 연동 여부. 화면은 이 값으로 www 관리자 관련 안내(접속 안내 3번 항목 등)를 숨깁니다. __AUTH__(1.0.8): 로그인 방식(local|www|webmaker) — 웹메이커 안내 문구용.
// __BASE__(1.0.8): 화면의 기준 경로. 보통 "/" 이고, 경로 접두사(/mail) 아래로 들어온 요청이면 "/mail/" — 정적 파일·API 주소가 모두 이 값을 앞에 붙입니다. 설정이 바뀌면 다시 만듭니다(PAGE 캐시).
const renderPage = (t, prefix) => indexHtml.replace('__STRINGS__', JSON.stringify(t.STRINGS).replace(/</g, '\\u003c')).replace(/__TITLE__/g, t.STRINGS.title).replace(/__VERSION__/g, VERSION).replace(/__HOST__/g, (t.PUBLIC_HOST || 'mail.arihakdang.com') + (t.PUBLIC_PATH || '')).replace(/__DOMAIN__/g, mailDomain(t)).replace(/__STANDALONE__/g, t.WWW_DB_FILE ? '0' : '1').replace(/__AUTH__/g, authMode(t)).replace(/__BASE__/g, prefix + '/');
// 접두사 없는 화면은 캐시하고(대부분의 요청), 접두사가 있는 화면은 그때그때 만듭니다(문자열 치환 몇 번이라 비용이 작음).
const page = (t, prefix = '') => prefix ? renderPage(t, prefix) : t.PAGE || (t.PAGE = renderPage(t, ''));
/**
* 이 요청의 경로 접두사(1.0.8). nginx 가 /mail/ 아래를 게이트웨이로 넘기며 붙인 X-Forwarded-Prefix 가 우선이고, 없으면 등록부의 "호스트/경로" 가 정한 값입니다.
* 잘못된 값(허용 글자 밖·..)은 무시합니다. 돌려주는 값은 '' 또는 '/mail' 꼴(끝 슬래시 없음).
*/
const prefixOf = (req, resolved) => {
// 등록부에 경로가 없는 호스트(보통의 mail.<도메인>)는 헤더를 믿지 않습니다 — 클라이언트가 X-Forwarded-Prefix 를 직접 보내 화면의 기준 경로를 바꾸지 못하게(nginx 템플릿도 이 헤더를 비웁니다).
if (!resolved?.prefix) return '';
const h = req.headers['x-forwarded-prefix']; if (h !== undefined) { const p = normalizePrefix(String(h).split(',')[0]); if (p !== null) return p; }
return resolved.prefix;
};
// 도메인 등록부(1.0.7): 기본 도메인(이 env)과 추가 도메인의 env 를 엽니다. 등록부가 없으면 기본 도메인만으로 만듭니다(데이터 이동 없음).
const tenants = openTenants(env, { migrationsDir: join(ROOT, 'migrations'), onCreate: prepareTenant });
// [Ariweb Suite 변경] 지난 실행에서 발송 중(sending)으로 남은 대기열 행은 interrupted 로 표시해 관리자가 확인·재발송할 수 있게 합니다(메일을 잃지 않음).
for (const t of tenants.envs()) recoverQueue(t).then((n) => { if (n) console.warn(` [${t.DOMAIN}] ⚠️ 끊긴 발송 ${n}건 — 관리자 페이지 › 발송 대기열에서 확인하세요`); }).catch(() => {});
console.log(` 도메인 등록부: ${join(env.DATA_DIR, 'domains.json')} — 기본 ${env.DOMAIN} (${env.TENANT.hosts.join(', ')})${tenants.all().length > 1 ? `, 추가 ${tenants.all().length - 1}개` : ''}`);
console.log(` 메일 서비스: 수신 ${inboundMode(env)} · 발신 ${outboundMode(env)}`);
// 빠진 운영 설정은 경고만 하고 서버는 뜹니다(요구사항 3: 값이 없어도 배포는 성공). 값은 초기 설정 페이지에서 채우면 재시작 없이 적용됩니다.
if (usesResend(env) && !env.RESEND_API_KEY) console.warn(' ⚠️ RESEND_API_KEY 없음 — Resend 수신 동기화·발송이 동작하지 않습니다(초기 설정 페이지에서 채우세요)');
if (inboundMode(env) === 'resend' && !env.RESEND_WEBHOOK_SECRET) console.warn(' ⚠️ RESEND_WEBHOOK_SECRET 없음 — 웹훅 수신을 거부합니다(1시간 동기화만 동작)');
if (outboundMode(env) === 'smtp' && !env.SMTP_RELAY_HOST) console.warn(' ⚠️ SMTP_RELAY_HOST 없음 — 발송이 동작하지 않습니다(초기 설정 페이지에서 중계 서버를 적으세요)');
if (tenants.envs().some(usesHaraka)) console.log(' 내장 Haraka 사용: haraka/run.mjs(별도 서비스)가 설정 파일을 보고 Haraka 를 띄웁니다');
// 정적 파일: 경로 → Content-Type. favicon.svg 는 브라우저 탭 아이콘입니다(없으면 매 방문마다 404 가 기록됐습니다).
const STATIC = { '/app.js': 'application/javascript; charset=utf-8', '/style.css': 'text/css; charset=utf-8', '/favicon.svg': 'image/svg+xml', '/setup.js': 'application/javascript; charset=utf-8', '/setup.css': 'text/css; charset=utf-8' };
// X-App-Version(0.8.25~): 모든 응답에 지금 돌고 있는 버전을 실어, 열어 둔 화면이 배포 뒤 옛 app.js 로 새 API 를 부르지 않도록 화면이 스스로 새로고침합니다.
const SECURITY = { 'X-App-Version': VERSION, 'X-Content-Type-Options': 'nosniff', 'Referrer-Policy': 'no-referrer', 'X-Frame-Options': 'DENY', 'X-Robots-Tag': 'noindex, nofollow', 'Cache-Control': 'no-store, no-transform' };
// img-src 에 https: 를 두는 이유: 읽기 창의 srcdoc iframe 은 이 페이지의 CSP 를 상속하므로, 여기서 막으면
// "외부 이미지 표시"를 눌러도 보이지 않습니다. 기본 차단은 iframe 쪽 CSP 와 src 제거로 유지합니다.
const CSP = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data: https:; font-src 'self'; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
async function readBody(req) {
if (req.method === 'GET' || req.method === 'HEAD') return undefined;
const chunks = []; let size = 0;
for await (const c of req) { size += c.length; if (size > 32 * 1024 * 1024) throw new Error('body_too_large'); chunks.push(c); }
return chunks.length ? Buffer.concat(chunks) : undefined;
}
/** 요청의 호스트 이름(nginx 가 넘긴 X-Forwarded-Host 우선). 테넌트 선택과 표준 Request 의 주소에 씁니다. */
const hostOf = (req) => String(req.headers['x-forwarded-host'] || req.headers.host || '').split(',')[0].trim();
const server = createServer(async (req, res) => {
let pathname = '/';
try {
const url = new URL(req.url, 'http://x'); pathname = url.pathname; const headOnly = req.method === 'HEAD';
// /favicon.ico 를 직접 찾는 브라우저·크롤러에는 SVG 아이콘으로 안내합니다(404 오류 기록 방지). 정적 파일은 도메인과 무관하므로 먼저 줍니다.
if (pathname === '/favicon.ico') { res.writeHead(301, { Location: prefixOf(req, tenants.resolveHost(hostOf(req))) + '/favicon.svg', ...SECURITY }); return res.end(); }
if (STATIC[pathname]) { const body = readFileSync(join(ROOT, 'web', pathname)); res.writeHead(200, { 'Content-Type': STATIC[pathname], ...SECURITY, 'Cache-Control': 'no-cache' }); return res.end(headOnly ? undefined : body); }
// 상태 확인은 어느 호스트로 와도(배포 스크립트는 127.0.0.1 로 부름) 기본 도메인의 버전을 돌려줍니다.
if (pathname === '/api/health') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: true, version: VERSION, domains: tenants.all().length })); }
// 사용량 API(1.0.9): 어느 호스트로 와도 같은 응답(키·IP 로 인증, 읽기 전용). 본문 없는 GET 만 받습니다.
if (pathname === '/api/usage' || pathname.startsWith('/api/usage/')) {
const hdrs = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => hdrs.append(k, x)); else if (v !== undefined) hdrs.set(k, v); }
// 접속 IP 는 클라이언트가 넣을 수 있는 헤더(X-Forwarded-For 첫 값·CF-Connecting-IP)가 아니라 nginx 가 보증하는 값(X-Real-IP, XFF 마지막)으로 — 허용 목록·시도 제한이 이 값에 걸려 있음.
const ip = clientIp(hdrs, req.socket.remoteAddress);
// HEAD 는 GET 으로 처리하고 본문만 빼서 보냅니다(상태 확인용).
const response = await usageApi(tenants, new Request(`https://x${req.url}`, { method: headOnly ? 'GET' : req.method, headers: hdrs }), { path: pathname.split('/').slice(3).filter(Boolean), ip, query: url.searchParams });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { out[k] = v; }); const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); return res.end(headOnly ? undefined : buf);
}
// 테넌트 선택(1.0.7): Host 로 도메인을 고릅니다. 모르는 호스트·꺼 둔 도메인이면 화면에는 안내 페이지, API 에는 404 JSON 을 줍니다.
const resolved = tenants.resolveHost(hostOf(req));
const isHaraka = pathname.startsWith('/api/inbound/haraka');
if ((!resolved.tenant || resolved.disabled) && !isHaraka) {
const disabled = resolved.disabled;
if (pathname.startsWith('/api/')) { res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: disabled ? 'domain_disabled' : 'domain_not_found', host: resolved.host })); }
res.writeHead(disabled ? 403 : 404, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; frame-ancestors 'none'", ...SECURITY });
return res.end(headOnly ? undefined : noServicePage({ host: resolved.host, disabled, strings: baseStrings, version: VERSION }));
}
// Haraka 수신 전달은 로컬(127.0.0.1)에서 오며 도메인은 받는 사람 주소로 고르므로 기본 도메인의 env 로 들어가 api.mjs 가 나눕니다.
const t = isHaraka ? env : resolved.tenant.env;
// 경로 접두사(1.0.8): nginx 가 /mail/ 을 떼고 넘기므로 여기 경로는 늘 "/…" 이고, 화면이 돌아갈 기준 경로만 prefix 로 압니다.
const prefix = prefixOf(req, resolved);
// "/" 는 웹 메일, "/admin" 은 관리자 페이지입니다(0.8.4~). 같은 화면 파일을 주고, 앱이 주소(location.pathname)로 모드를 정합니다.
// /admin 은 항상 관리자 페이지로 이어지고 별도의 로그인(관리자 세션 쿠키)을 거칩니다.
if (pathname === '/' || pathname === '/index.html' || /^\/admin\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : page(t, prefix)); }
// /setup(1.0.0): 메일 서비스 설정 화면. 관리자 세션으로 /api/setup/* 를 부릅니다(전용 포트의 토큰 방식과 같은 화면, setup.mjs).
if (/^\/setup\/?$/.test(pathname)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Content-Security-Policy': CSP, ...SECURITY }); return res.end(headOnly ? undefined : setupPage(t, join(ROOT, 'web'), 'admin', { base: prefix + '/' })); }
// 대용량 첨부파일 다운로드 링크(0.9.3): /dl/<토큰>. GET 은 랜딩 페이지(횟수를 세지 않음), POST(페이지의 단추)가 실제 다운로드입니다.
// return await: 프라미스를 그냥 돌려주면 그 안의 예외(잘못된 Host 헤더로 new Request 가 던지는 TypeError 등)가 아래 catch 에 잡히지 않고 프로세스를 죽였습니다(0.9.5 수정).
if (pathname.startsWith('/dl/')) return await download(t, req, res, pathname.slice(4).split('/')[0], prefix);
if (!pathname.startsWith('/api/')) { res.writeHead(404, { 'Content-Type': 'application/json', ...SECURITY }); return res.end(JSON.stringify({ ok: false, error: 'not_found' })); }
// 대용량 파일 업로드(0.9.3): 본문을 메모리에 모으지 않고 디스크로 흘려 씁니다(일반 API 의 32MB 본문 한도 밖). 로그인·초안 소유 확인은 uploadLargeFile 이 합니다.
if (pathname === '/api/files' && req.method === 'POST') return await upload(t, req, res, url);
const body = await readBody(req);
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
const request = new Request(`${proto}://${host}${req.url}`, { method: headOnly ? 'GET' : req.method, headers, body });
// 웹훅과 Haraka 수신 전달은 브라우저가 아니므로 Origin 검사(CSRF)를 하지 않습니다(각자 서명·토큰으로 인증).
const isWebhook = pathname === '/api/webhooks/resend' || pathname.startsWith('/api/inbound/');
if (!isWebhook && !['GET', 'HEAD'].includes(req.method) && headers.get('origin') && headers.get('origin') !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const ip = headers.get('cf-connecting-ip') || (headers.get('x-forwarded-for') || '').split(',')[0].trim() || req.socket.remoteAddress || '';
const response = await handle(t, request, { path: pathname.slice(5).split('/').filter(Boolean).map(decodeURIComponent), ip, prefix });
const out = { ...SECURITY }; response.headers.forEach((v, k) => { if (k.toLowerCase() !== 'set-cookie') out[k] = v; });
const cookies = response.headers.getSetCookie?.() || []; if (cookies.length) out['Set-Cookie'] = cookies;
const buf = Buffer.from(await response.arrayBuffer()); out['Content-Length'] = String(buf.length);
res.writeHead(response.status, out); res.end(headOnly ? undefined : buf);
} catch (e) {
console.error(`오류 ${req.method} ${pathname}`, e?.message);
if (!res.headersSent) res.writeHead(e.message === 'body_too_large' ? 413 : 500, { 'Content-Type': 'application/json', ...SECURITY });
res.end(JSON.stringify({ ok: false, error: 'server_error' }));
}
});
server.listen(PORT, HOST, () => console.log(` HTTP 대기 중: http://${HOST}:${PORT}`));
const jsonOut = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', ...SECURITY }); res.end(JSON.stringify(obj)); };
/** 요청 헤더만으로 표준 Request 를 만듭니다(세션 쿠키 확인용). */
function headerRequest(t, req) {
const proto = req.headers['x-forwarded-proto'] || 'https', host = hostOf(req) || t.PUBLIC_HOST || 'mail.arihakdang.com';
const headers = new Headers(); for (const [k, v] of Object.entries(req.headers)) { if (Array.isArray(v)) v.forEach((x) => headers.append(k, x)); else if (v !== undefined) headers.set(k, v); }
return new Request(`${proto}://${host}${req.url}`, { method: 'GET', headers });
}
/**
* POST /api/files?draft=<초안 id> — 대용량 파일 업로드(0.9.3). 본문이 파일 바이트이고 이름은 X-File-Name(URL 인코딩), 형식은 Content-Type 입니다.
* 웹 메일 세션(쿠키)으로 인증하고 같은 출처(Origin)만 받습니다. 상한을 넘기면 413 과 limit 을 돌려주고 남은 본문은 버립니다.
* @param {object} t 이 요청의 도메인 env(1.0.7)
*/
async function upload(t, req, res, url) {
const request = headerRequest(t, req);
if (req.headers.origin && req.headers.origin !== new URL(request.url).origin) { res.writeHead(403, SECURITY); return res.end(); }
const user = await currentUser(t, request, { scope: 'mail' });
if (!user) { req.resume(); return jsonOut(res, 401, { ok: false, error: 'unauthenticated' }); }
try {
// X-File-Name 은 URL 인코딩이 깨져 있어도(잘못된 % 열) 오류 대신 원문을 이름으로 씁니다.
const rawName = String(req.headers['x-file-name'] || ''); let filename; try { filename = decodeURIComponent(rawName); } catch { filename = rawName; }
const file = await uploadLargeFile(t, user, req, { draftId: url.searchParams.get('draft'), filename, contentType: req.headers['content-type'], declaredSize: Number(req.headers['content-length'] || 0) });
return jsonOut(res, 201, { ok: true, file: { id: file.id, filename: file.filename, size: file.size, content_type: file.content_type, state: file.state } });
} catch (e) {
req.resume();
const status = e.message === 'file_too_large' ? 413 : e.message === 'large_files_disabled' ? 404 : e.message === 'mail_locked' ? 409 : e.message === 'bad_request' ? 400 : 422;
return jsonOut(res, status, { ok: false, error: e.message || 'server_error', ...(e.limit !== undefined ? { limit: e.limit } : {}) });
}
}
/** 랜딩 페이지 HTML(외부 공개). 스크립트 없이 폼(POST)만 쓰며 검색 엔진이 색인하지 않게 합니다. */
function landing(t, title, body) {
return `<!DOCTYPE html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><title>${escapeHtml(title)}</title><style>body{margin:0;background:#f3f5f8;font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733}.card{max-width:520px;margin:48px auto;background:#fff;border-radius:14px;padding:28px;box-shadow:0 8px 30px rgba(20,35,60,.12)}h1{font-size:18px;margin:0 0 14px}dl{display:grid;grid-template-columns:110px 1fr;gap:6px 12px;margin:0 0 18px}dt{color:#6b7a8c}dd{margin:0;overflow-wrap:anywhere}button{background:#2b64c8;color:#fff;border:0;border-radius:8px;padding:12px 18px;font:inherit;font-weight:600;cursor:pointer;width:100%}.meta{color:#6b7a8c;font-size:12px;margin-top:14px}.warn{color:#9b2c1f}</style></head><body><div class="card">${body}<p class="meta">${escapeHtml(t.STRINGS.appName)} · ${escapeHtml(t.PUBLIC_HOST || '')}</p></div></body></html>`;
}
/**
* /dl/<토큰> — 받는 사람이 여는 다운로드 링크(0.9.3, 로그인 없음).
* GET : 파일 이름·크기·남은 횟수·기한을 보여 주는 랜딩 페이지. 메일 보안 장치·미리 보기 봇이 미리 열어도 횟수가 줄지 않습니다.
* POST : "다운로드" 단추. 횟수를 1 차지한 뒤 파일을 흘려보내고, 마지막 허용 횟수였으면 끝난 뒤 파일을 지웁니다.
* 만료·없는 토큰에는 410/404 안내 페이지를 줍니다.
*/
async function download(t, req, res, token, prefix = '') {
const page = (status, title, body) => { res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8', ...SECURITY, 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'" }); res.end(landing(t, title, body)); };
const s = (k) => t.STRINGS[k] || k;
if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (req.method === 'GET' || req.method === 'HEAD') {
const info = await largeFileInfo(t, token);
if (info.status === 'missing') return page(404, s('dl_missingTitle'), `<h1>${escapeHtml(s('dl_missingTitle'))}</h1><p class="warn">${escapeHtml(s('dl_missing'))}</p>`);
if (info.status === 'expired') return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(info.file.filename)}</dd></dl>`);
const f = info.file;
return page(200, s('dl_title'), `<h1>${escapeHtml(s('dl_title'))}</h1><dl><dt>${escapeHtml(s('dl_file'))}</dt><dd>${escapeHtml(f.filename)}</dd><dt>${escapeHtml(s('dl_size'))}</dt><dd>${humanSize(f.size)}</dd><dt>${escapeHtml(s('dl_remaining'))}</dt><dd>${info.remaining} / ${f.max_downloads}</dd><dt>${escapeHtml(s('dl_until'))}</dt><dd>${formatExpiry(f.expires_at, t)}</dd></dl><form method="post" action="${prefix}/dl/${encodeURIComponent(token)}"><button type="submit">⬇ ${escapeHtml(s('dl_button'))}</button></form><p class="meta">${escapeHtml(s('dl_note'))}</p>`);
}
if (req.method !== 'POST') { res.writeHead(405, SECURITY); return res.end(); }
req.resume();
const claim = await claimDownload(t, token);
if (!claim) return page(410, s('dl_expiredTitle'), `<h1>${escapeHtml(s('dl_expiredTitle'))}</h1><p class="warn">${escapeHtml(s('dl_expired'))}</p>`);
const { row } = claim;
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': String(row.size), 'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(row.filename)}`, ...SECURITY });
const stream = openLargeFile(claim.path);
stream.on('error', (e) => { console.error('대용량 파일 전송 오류', row.id, e.message); res.destroy(); });
// 전송이 끝나면(또는 끊기면) 마지막 허용 횟수였던 파일을 지웁니다. 끊긴 경우도 횟수는 이미 차지했으므로 같은 처리입니다.
res.on('close', () => { finishDownload(t, claim).catch((e) => console.error('대용량 파일 정리 오류', row.id, e.message)); });
stream.pipe(res);
}
// ------------------------------------------------------------------ POP3 / IMAP / SMTP
// 평문 포트(110/143)의 TLS 없는 로그인(0.9.7): POP_ALLOW_PLAINTEXT=1 이면 항상 허용(진단용), 그 밖에는 null 을 넘겨 관리자 설정 "평문 로그인 허용"(기본 켬)을 접속마다 읽습니다.
// 0.9.5 까지는 이 변수가 1 이 아니면 항상 거부해, 설치 템플릿 기본값(0)에서 STLS 를 쓰지 않는 Gmail 의 110 접속이 전부 실패했습니다.
const allowPlaintext = env.POP_ALLOW_PLAINTEXT === '1' ? true : null;
// resolveEnv(1.0.7): 로그인 ID 의 @도메인(없으면 TLS SNI 호스트, 그래도 없으면 기본 도메인)으로 그 접속이 쓸 도메인 env 를 고릅니다. 메일 서버 이름·인증서는 기본 도메인 것 하나입니다.
const resolveEnv = (login, servername) => tenants.resolveLogin(login, servername);
const mailOpts = { hostname: env.PUBLIC_HOST || 'mail.arihakdang.com', tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, resolveEnv };
const pop = createPop3(env, { ...mailOpts, allowPlaintext });
if (env.POP_DISABLED !== '1') {
// TLS 포트는 인증서가 없으면 지금 열지 않고 기억해 두어, 아래 1시간 점검이 인증서를 읽는 순간 엽니다(0.9.7, 예전에는 재시작 전까지 닫혀 있었음).
pop.listen({ plainPort: Number(env.POP_PORT || 110), securePort: Number(env.POPS_PORT || 995), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' POP3 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ POP TLS 인증서 없음 — 995 포트는 인증서를 읽을 수 있게 되면 엽니다(110 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' POP3 시작 실패:', e.message));
}
const imap = createImap(env, { ...mailOpts, allowPlaintext });
if (env.IMAP_DISABLED !== '1') {
imap.listen({ plainPort: Number(env.IMAP_PORT || 143), securePort: Number(env.IMAPS_PORT || 993), host: env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' IMAP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 993 포트는 인증서를 읽을 수 있게 되면 엽니다(143 의 TLS 없는 로그인은 관리자 설정 "평문 로그인 허용" 을 따름)'); })
.catch((e) => console.error(' IMAP 시작 실패:', e.message));
}
// 메일 프로그램의 "보내는 메일 서버". 웹 메일과 같은 아이디·비밀번호로 인증하고, 실제 발송에만 서버의 RESEND_API_KEY 를 씁니다.
// 인증은 TLS 위에서만 받습니다(SMTP_ALLOW_PLAINTEXT=1 은 진단용). 인증서는 POP·IMAP 과 같은 파일입니다.
const smtp = createSmtp(env, { ...mailOpts, allowPlaintext: env.SMTP_ALLOW_PLAINTEXT === '1' });
if (env.SMTP_DISABLED !== '1') {
smtp.listen({ plainPort: Number(env.SMTP_PORT || 587), securePort: Number(env.SMTPS_PORT || 465), host: env.SMTP_HOST || env.POP_HOST || '0.0.0.0' })
.then((bound) => { console.log(' SMTP 대기 중:', bound.join(', ')); if (!tlsFiles) console.warn(' ⚠️ TLS 인증서 없음 — 465 포트는 인증서를 읽을 수 있게 되면 열고, 587 은 STARTTLS 없이 로그인을 거부합니다'); })
.catch((e) => console.error(' SMTP 시작 실패:', e.message));
}
// 인증서 갱신(certbot 훅이 사본을 바꿈)을 1시간마다 확인해 POP·IMAP·SMTP 에 새 인증서를 넣습니다. 시작 때 인증서가 없었다면(tlsFiles=null) 이때 TLS 포트도 열립니다(0.9.7).
setInterval(() => { const next = readTls(); if (next && next.mtime !== tlsFiles?.mtime) { tlsFiles = next; pop.setTls({ key: next.key, cert: next.cert }); imap.setTls({ key: next.key, cert: next.cert }); smtp.setTls({ key: next.key, cert: next.cert }); setup.setTls({ key: next.key, cert: next.cert }); for (const t of tenants.envs()) t.POP_TLS_ACTIVE = true; console.log(' TLS 인증서를 다시 읽었습니다'); } }, 3600000).unref();
// ------------------------------------------------------------------ 전체 설정 화면·초기 설정 페이지(1.0.0 요구사항 4, 1.0.7 요구사항 2)
// 전용 포트(SETUP_PORT, 기본 8791, 0 이면 끔)에서 토큰으로 인증하는 설정 화면을 엽니다. 인증서를 읽을 수 있으면 HTTPS, 없으면 HTTP.
// 1.0.7: 이 화면이 "전체 설정" 입니다 — 도메인 목록·추가·중지와 도메인별 설정(API 키·DNS·수·발신 방식)을 기본 도메인의 이 포트에서 관리합니다.
// 접속 주소는 로그와 데이터 디렉터리의 setup-url.txt 에 남겨 배포 스크립트가 끝에 출력합니다.
const setup = createSetupServer(env, { webDir: join(ROOT, 'web'), tlsOptions: tlsFiles ? { key: tlsFiles.key, cert: tlsFiles.cert } : null, tenants });
const SETUP_PORT = Number(env.SETUP_PORT ?? 8791);
if (SETUP_PORT > 0) {
setup.listen({ port: SETUP_PORT, host: env.SETUP_HOST || '0.0.0.0' })
.then(() => { const urls = setup.urls(setupToken(env)); console.log(' 전체 설정 화면: ' + urls[0] + (urls.length > 1 ? ` (또는 ${urls.slice(1).join(' , ')})` : '')); writeSetupUrls(env, urls); })
.catch((e) => console.error(' 전체 설정 화면 시작 실패:', e.message));
}
// ------------------------------------------------------------------ 주기 작업(도메인마다, 1.0.7)
const SYNC_INTERVAL = Number(env.SYNC_INTERVAL_MS || 3600000);
const runSync = (trigger) => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) syncMailbox(t, { trigger }).then((r) => { if (!r.busy) console.log(` 동기화(${trigger}${t === env ? '' : ', ' + t.DOMAIN}): ${r.imported}건`); }).catch((e) => console.error(` 동기화 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
// 도메인 검증 자동 재요청(1.0.11): 검증이 실패 상태로 멈춘 도메인의 Resend 레코드가 모두 보이면 검증을 다시 요청합니다(동기화와 같은 주기).
// 운영(webmail.ibit.kr)에서 DKIM 레코드 불일치로 failed 가 된 뒤 사용자가 DNS 를 고쳐도 다시 요청하지 않으면 영영 실패 상태라 발송이 막혔습니다.
const runAutoVerify = () => { for (const t of tenants.envs()) if (t.RESEND_API_KEY && usesResend(t)) autoVerifyDomain(t).then((r) => { if (r.requested) console.log(` Resend 도메인 검증 자동 요청(${t.DOMAIN}): 이전 상태 ${r.status}`); else if (r.skipped === 'records') console.warn(` Resend 도메인 검증 대기(${t.DOMAIN}): ${r.status} — ${r.problems.join(', ')}`); }).catch((e) => console.warn(` Resend 도메인 검증 확인 실패(${t.DOMAIN}):`, e.message, e.providerMessage || '')); };
setTimeout(runAutoVerify, 20000).unref(); setInterval(runAutoVerify, SYNC_INTERVAL).unref();
setTimeout(() => runSync('startup'), 10000).unref();
setInterval(() => runSync('timer'), SYNC_INTERVAL).unref();
// 만료된 로그인 세션 정리. 예전에는 같은 작업이 보관 기간(30일)이 지난 메일도 지웠지만 0.8.0 에서 그 삭제를 없앴습니다.
const runCleanup = () => { for (const t of tenants.envs()) cleanupSessions(t).catch((e) => console.error(` 세션 정리 실패(${t.DOMAIN}):`, e.message)); };
// 대용량 첨부파일 정리(0.9.3): 기한·횟수가 끝난 파일과 버려진 초안의 파일을 1시간마다 지웁니다.
const runFileCleanup = () => { for (const t of tenants.envs()) cleanupLargeFiles(t).then((r) => { if (r.expired || r.removed) console.log(` 대용량 파일 정리(${t.DOMAIN}): 만료 ${r.expired}건, 삭제 ${r.removed}건`); }).catch((e) => console.error(` 대용량 파일 정리 실패(${t.DOMAIN}):`, e.message)); };
setTimeout(runFileCleanup, 45000).unref(); setInterval(runFileCleanup, 3600000).unref();
setTimeout(runCleanup, 30000).unref(); setInterval(runCleanup, 3600000).unref();
for (const sig of ['SIGINT', 'SIGTERM']) process.on(sig, () => { console.log(`\n ${sig} — 종료합니다`); pop.close(); imap.close(); smtp.close(); setup.close(); server.close(() => { tenants.close(); db.close(); process.exit(0); }); setTimeout(() => process.exit(0), 3000).unref(); });