2026-10-26 11:02:58 +00:00
/**
* 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' ;
2026-10-26 11:47:45 +00:00
import { recoverQueue } from './sendqueue.mjs' ;
2026-10-26 11:02:58 +00:00
import { loadConfig , configFile , setupToken , mailDomain } from './config.mjs' ;
import { createSetupServer , setupPage , writeSetupUrls } from './setup.mjs' ;
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
import { startHalManaged } from './hal-managed.mjs' ;
2026-10-26 11:02:58 +00:00
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 으로 처음 올린 서버) 환경 변수 값을 그대로 가져와 만듭니다.
HAL 0.4.0: 통합 설정 관제 — Webmaker·AriMail·Agent 서버 단위 설정을 HAL 이 관리(설정 목록·범위 global/server·비밀값 Vault·유효값 hash, /ops/api/settings·재인증, settings.apply → settings.apply.target → Agent module.configure(HMAC 설정 받기·hash 확인·hal-managed.json 0600·재시작·모듈 적용 확인), /ops/settings 화면·운영 현황 "설정 적용 필요", AI 도구 hal_settings_get/set/apply(비밀값 거부)), 도입 모듈 Webmaker 1.9.0·AriMail 1.1.0(HAL 0.4.0 대응 halCompat, hal-managed 적용·관리 항목 409 managed_by_hal 잠금·"HAL 관리" 표시) 재도입(pin 8f8716c·f959b65, import-sources 미수정 파일 갱신), Agent heartbeat 관리, module-as-user write --mode 600, 개발 지시 v0.4.0(Oz)·docs/HAL-SETTINGS.md·VERSION_HISTORY 0.4.0 — test/hal-040.test.mjs RED → GREEN 5/5, agent 11/11, 전체 31파일 통과
2026-10-29 06:46:45 +00:00
loadConfig ( env ); startHalManaged ( env ); /* 1.1.0 (HAL 0.4.0 대응): HAL 통합 설정(hal-managed.json) 적용·변경 감시 */ console . log ( ` 설정 파일: ${ configFile ( env ) }${ env . CONFIG_CREATED ? ' (새로 만듦 — 환경 변수 값을 가져왔습니다)' : '' } ` );
2026-10-26 11:02:58 +00:00
// 로그인 방식(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 });
2026-10-26 11:47:45 +00:00
// [Ariweb Suite 변경] 지난 실행에서 발송 중(sending)으로 남은 대기열 행은 interrupted 로 표시해 관리자가 확인·재발송할 수 있게 합니다(메일을 잃지 않음).
for ( const t of tenants . envs ()) recoverQueue ( t ). then (( n ) => { if ( n ) console . warn ( ` [ ${ t . DOMAIN } ] ⚠️ 끊긴 발송 ${ n } 건 — 관리자 페이지 › 발송 대기열에서 확인하세요` ); }). catch (() => {});
2026-10-26 11:02:58 +00:00
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 (); });