/**
* AriMail 전체 설정 화면(1.0.7, 요구사항 2) · 메일 서비스 설정 화면(1.0.0, 요구사항 4·6).
*
* 1.0.7 다중 도메인: 전용 포트(token 방식)에서는 맨 위 "도메인" 카드가 서버의 모든 도메인을 보여 주고(추가·중지·삭제·수정), 고른 도메인(DOMAIN)의 설정을
* 아래 카드들이 다룹니다. 모든 API 호출에 ?domain=<도메인> 을 붙여 서버가 그 도메인의 설정 파일을 읽고 씁니다. admin 방식(/setup)은 자기 도메인만 다룹니다.
*
* 하는 일
* - /api/setup/config 로 현재 설정(비밀값은 가린 채)·출처·메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 상태)를 읽어 카드로 그립니다.
* - 카드마다 "저장" 이 바뀐 값만 PUT 합니다. 비밀값 입력 칸은 비워 두면 "그대로", "비우기" 를 누르면 '' 로 지웁니다.
* - 수·발신 방식에 따라 필요한 카드만 보입니다(Resend / 외부 SMTP 중계 / 내장 Haraka).
* - Haraka 카드: DKIM 키 생성과 DNS 레코드 안내(복사 단추), 서비스 상태(설치·실행·마지막 로그).
* - 마무리 카드: 빠진 항목 점검표, "설치 완료로 표시", systemd 아래면 "서비스 재시작".
*
* 인증 방식(index.html 의 data-mode)
* token : 전용 포트. 주소의 ?token= 을 세션 저장소에 두고(주소에서 지움) X-Setup-Token 헤더로 보냅니다. 없으면 토큰 입력 칸을 보입니다.
* admin : 웹 메일 서버의 /setup. 관리자 세션 쿠키 + X-Mail-Scope: admin. 로그인이 없으면 /admin 으로 안내합니다.
*/
(() => {
const MODE = document.documentElement.dataset.mode || 'token';
// BASE(1.0.8): 화면의 기준 경로(서버가 data-base 로 심음). 경로 접두사(/mail) 아래의 /setup 에서도 API·링크가 맞게 동작합니다.
const BASE = (document.documentElement.dataset.base || '/').replace(/\/?$/, '/');
const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)];
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const TOKEN_KEY = 'arimail-setup-token';
let token = '';
let DOMAIN = '', DOMAINS = null; // 1.0.7: 고른 도메인과 도메인 목록 응답(token 방식에서만)
let EDIT = null; // 도메인 카드의 수정 중인 항목(null 이면 추가 폼)
if (MODE === 'token') {
const u = new URL(location.href); const t = u.searchParams.get('token');
if (t) { try { sessionStorage.setItem(TOKEN_KEY, t); } catch { /* 저장소를 못 쓰면 메모리로만 */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + (u.search || '')); token = t; }
else { try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch { token = ''; } }
}
let toastTimer = null;
function toast(msg, error = false) { const t = $('#toast'); t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, error ? 7000 : 3500); }
/** API 호출. 인증 헤더는 방식에 따라 다르고, 실패는 Error(코드) 로 던집니다(detail·field 포함). */
async function api(path, method = 'GET', body) {
const headers = { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(MODE === 'token' ? { 'X-Setup-Token': token } : { 'X-Mail-Scope': 'admin' }) };
// 1.0.7: 전용 포트에서는 고른 도메인을 ?domain= 으로 알립니다(도메인 등록부 API 자체는 예외).
const q = MODE === 'token' && DOMAIN && !path.startsWith('domains') ? (path.includes('?') ? '&' : '?') + 'domain=' + encodeURIComponent(DOMAIN) : '';
const r = await fetch(BASE + 'api/setup/' + path + q, { method, headers, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' });
let d = null; try { d = await r.json(); } catch { d = null; }
if (!r.ok || !d || d.ok === false) { const e = new Error((d && d.error) || 'http_' + r.status); e.status = r.status; e.field = d && d.field; e.detail = d && d.detail; throw e; }
return d;
}
/** 로그인 방식 이름표(도메인 표). */
const AUTH_LABEL = { local: '자체 계정', www: '웹 서비스 연동', webmaker: '웹메이커 연동' };
const ERRORS = { dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: '도메인의 DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다', setup_token_invalid: '접속 토큰이 맞지 않습니다. 배포 출력에 나온 주소(…?token=…)로 다시 여세요.', admin_login_required: '관리자 페이지에서 로그인한 뒤 다시 여세요.', rate_limited: '시도가 너무 많습니다. 15분 뒤 다시 하세요.', validation: '입력 값 형식이 맞지 않습니다', restart_unavailable: 'systemd 서비스로 돌고 있을 때만 재시작할 수 있습니다. 서버에서 직접 재시작하세요.', domain_exists: '이미 등록된 도메인입니다', host_taken: '다른 도메인이 쓰는 호스트 이름입니다', www_db_unreadable: '웹 서비스 DB 파일을 읽을 수 없습니다(경로·권한 확인)', webmaker_dir_unreadable: '웹메이커 데이터 디렉터리의 users.json 을 서비스 계정이 읽을 수 없습니다. "파일을 읽을 계정" 을 적고 deploy/webmaker-access.sh 를 실행하거나 읽기 권한을 주세요.', primary_domain: '기본 도메인은 지울 수 없습니다(환경 파일이 정합니다)', primary_only: '이 작업은 기본 도메인에서만 할 수 있습니다(서버 하나에 키 하나)', domain_not_found: '등록되지 않은 도메인입니다', server_error: '서버 오류가 났습니다', mail_not_configured: '연결 정보가 없습니다', mail_auth_failed: '인증(키·비밀번호)이 거부되었습니다', mail_forbidden: 'Resend 가 요청을 거부했습니다(403) — API 키 권한·도메인 소유 확인', mail_domain_unverified: '발신 도메인이 Resend 에서 검증되지 않았습니다 — 자동 설정 카드의 DNS 레코드 확인', mail_domain_missing: '발신 도메인이 Resend 에 등록되어 있지 않습니다 — 자동 설정 실행', mail_rate_limited: '서비스가 잠시 요청을 제한하고 있습니다', mail_rejected: '서비스가 요청을 거부했습니다', mail_provider_unavailable: '서비스에 연결할 수 없습니다', ok: '정상' };
const errText = (e) => (ERRORS[e.message] || e.message) + (e.field ? ` (${e.field})` : '') + (e.detail ? ` — ${e.detail}` : '');
const MODES = {
inbound: { resend: ['Resend 웹훅 + 동기화', 'Resend 가 받은 메일을 웹훅으로 밀어 주고 1시간마다 목록을 맞춥니다(예전 방식). DNS 의 MX 는 Resend 를 가리킵니다.'], haraka: ['내장 Haraka 로 직접 수신', '이 서버의 25 포트가 메일을 받습니다. DNS 의 MX 를 이 서버로 바꾸고 방화벽에서 25 를 열어야 합니다.'] },
outbound: { resend: ['Resend API', 'Resend 발송 API 로 보냅니다(예전 방식). 도메인이 Resend 에 검증되어 있어야 합니다.'], haraka: ['내장 Haraka 로 직접 발송', '이 서버가 받는 쪽 메일 서버(MX)에 직접 배달합니다. SPF·DKIM DNS 레코드와 역방향 DNS(PTR)가 있어야 스팸으로 분류되지 않습니다.'], smtp: ['외부 SMTP 중계 서버', '회사 메일 서버나 다른 발송 서비스의 SMTP(587/465)로 넘깁니다. 아래 "SMTP 중계 서버" 카드에 접속 정보를 적으세요.'] },
};
const FIELD = {
USAGE_API_ALLOW: ['접속 허용 IP', '웹메이커 서버의 IP 또는 CIDR(쉼표 구분, 예: 203.0.113.10, 10.0.0.0/8). 비우면 키만 맞으면 어디서나 허용합니다.'],
RESEND_API_KEY: ['API 키', 'Resend → API Keys 에서 만든 전체 접근(Full access) 키. 서버에만 저장되고 사용자에게 노출되지 않습니다.'],
RESEND_WEBHOOK_SECRET: ['웹훅 서명 비밀키', 'Resend → Webhooks → 아래 주소의 엔드포인트 → Signing Secret(whsec_…). 수신 방식이 Resend 일 때 필요합니다.'],
SMTP_RELAY_HOST: ['서버 주소', '예: smtp.example.com'], SMTP_RELAY_PORT: ['포트', '587(STARTTLS) · 465(TLS) · 25'], SMTP_RELAY_SECURITY: ['암호화', 'starttls = 접속 뒤 STARTTLS · tls = 접속 즉시 TLS · none = 없음(내부망만)'],
SMTP_RELAY_USER: ['로그인 ID', '비우면 인증 없이 보냅니다'], SMTP_RELAY_PASS: ['비밀번호', ''],
HARAKA_PORT: ['수신 포트', '외부에서 메일을 받는 포트(표준 25). 방화벽에서 열어야 합니다.'], HARAKA_RELAY_PORT: ['제출 포트(내부)', '게이트웨이가 Haraka 에 발송을 넘기는 로컬 전용 포트(127.0.0.1). 외부에서 열지 않습니다.'],
HARAKA_HOSTNAME: ['메일 서버 이름', 'EHLO·MX 에 쓰는 이름. 비우면 공개 호스트 이름(웹 메일 주소)과 같습니다. 이 이름의 A 레코드와 역방향(PTR)이 이 서버를 가리켜야 합니다.'], HARAKA_DKIM_SELECTOR: ['DKIM 셀렉터', 'DNS 레코드 이름의 앞부분(기본 arimail). 바꾸면 키를 다시 만들고 DNS 도 바꿔야 합니다.'],
MAIL_FROM_NAME: ['발신 표시 이름', '계정에 이름이 없을 때 보낸 사람 이름으로 쓰는 값'], SITE_TITLE: ['브라우저 탭 제목', '비우면 서비스 이름(도메인 기준, 예: ARIHAKDANG 메일)'], MAILBOX_OWNER_LOGIN: ['기본 메일함 소유 www 관리자', 'www 연동일 때 admin 메일함을 가진 www 관리자(로그인 ID 또는 이메일)'],
RESEND_REGION: ['Resend 지역', '도메인을 등록할 발송 지역. 수신 MX 값도 지역에 따라 다릅니다(한국은 ap-northeast-1 권장).'],
DNS_PROVIDER: ['DNS 서비스', 'none = 자동 적용 없이 레코드만 안내 · google = Google Cloud DNS · route53 = AWS Route 53'],
DNS_GOOGLE_CREDENTIALS: ['서비스 계정 JSON', 'Cloud DNS 관리자 역할을 가진 서비스 계정의 키 파일(JSON) 내용을 그대로 붙여 넣으세요.'], DNS_GOOGLE_PROJECT: ['프로젝트 ID', '비우면 JSON 의 project_id'], DNS_GOOGLE_ZONE: ['존 이름(선택)', '비우면 도메인으로 자동 검색'],
DNS_AWS_ACCESS_KEY_ID: ['AWS Access Key ID', 'Route 53 변경 권한(ListHostedZonesByName, ChangeResourceRecordSets 등)이 있는 IAM 키'], DNS_AWS_SECRET_ACCESS_KEY: ['AWS Secret Access Key', ''], DNS_AWS_HOSTED_ZONE_ID: ['호스팅 존 ID(선택)', '비우면 도메인으로 자동 검색'],
STATS_TIMEZONE: ['통계 시간대', '메일 현황(일·주·월)의 기준 시간대. 예: Asia/Seoul'], SYNC_INTERVAL_MIN: ['Resend 동기화 주기(분)', 'Resend 목록을 다시 훑는 간격(기본 60분)'],
};
const SRC = { config: '설정 파일', env: '환경 파일 값(아직 설정 파일에 없음)', default: '기본값' };
let D = null; // 마지막으로 읽은 설정 응답
let PLAN = null, planBusy = false; // 자동 설정 계획(/api/setup/plan)과 실행 중 표시(1.0.5)
const STATE = { ok: ['✅', '완료', 'ok'], todo: ['▶', '자동 실행 가능', ''], wait: ['⏳', '반영 대기', 'warn'], action: ['✍️', '사용자 작업', 'warn'], error: ['❌', '오류', 'warn'], info: ['ℹ️', '참고', 'off'], skip: ['—', '해당 없음', 'off'] };
/** 자동 설정 카드 HTML. PLAN 이 없으면 읽기 단추만. */
function planCard() {
if (!PLAN) return `
자동 설정
고른 수·발신 방식과 도메인에 맞게 Resend 도메인·웹훅, DNS 레코드, 내장 Haraka 준비 상태를 진단하고 할 수 있는 일은 자동으로 합니다.
초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}
` : ''}
${btns.join('')}
`;
}).join('');
const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary;
const form = ``;
return `
도메인 ${DOMAINS.domains.length}개
이 서버가 메일을 다루는 도메인입니다. 도메인마다 계정·메일·설정이 따로 있고, 웹 메일은 각 호스트 이름으로, 메일 프로그램은 ${esc(DOMAINS.serverHost || '')} 에 아이디@도메인 으로 접속합니다. 아래 카드는 선택한 도메인의 설정입니다.
도메인
웹 메일 호스트
상태
로그인
수신 / 발신
계정 · 메일
${rows}
${form}`;
}
/**
* 사용량 API 카드(1.0.9, 요구사항 1): 웹메이커 같은 외부 시스템이 /api/usage 로 디스크 사용량·발송량을 읽을 수 있게 비밀 키를 만들고(한 번만 표시), 허용 IP 를 정합니다.
* 키는 서버 하나에 하나(기본 도메인 설정)이므로 기본 도메인을 보고 있을 때만 그립니다. 새로 만든 키는 USAGE_KEY_SHOWN 에 잠시 두어 화면에 한 번 보여 줍니다.
*/
let USAGE_KEY_SHOWN = null;
function usageCard() {
const m = D.meta, u = m.usageApi; if (!u || !m.primary) return '';
const v = D.values, s = D.sources;
// 새 키는 이번 그리기에만 보이고 다음 그리기(다른 저장 등)부터는 사라집니다 — "지금만 보입니다" 그대로.
const shown = USAGE_KEY_SHOWN; USAGE_KEY_SHOWN = null;
const curl = `curl -H 'Authorization: Bearer <키>' '${u.endpoint}?days=30'`;
return `
사용량 API ${u.enabled ? '사용 중' : '꺼짐'}
웹메이커 등 외부 관리 시스템이 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이)을 읽는 읽기 전용 API 입니다. 비밀 키(Bearer)와 접속 IP 제한으로 보호되며 메일 내용·비밀번호·API 키는 나가지 않습니다. 사용 방법은 저장소의 docs/USAGE-API.md.
주소
${esc(u.endpoint)}(도메인 하나: /api/usage/<도메인>)
키
${u.enabled ? `${esc(u.keyMasked)}${u.updatedAt ? ` (${esc(new Date(u.updatedAt).toLocaleString('ko-KR'))} 생성)` : ''}` : '없음 — 아래 단추로 만들면 API 가 켜집니다'}
${shown ? `
새 키(지금만 보입니다 — 웹메이커 설정에 복사해 두세요):
${esc(shown)}
예: ${esc(curl.replace('<키>', shown))}
` : ''}
${u.enabled ? '' : ''}
`;
}
/** DNS 모듈 카드(요구사항 1): 서비스 선택과 인증 정보, 연결 확인. */
function dnsCard() {
const v = D.values, s = D.sources, prov = v.DNS_PROVIDER || 'none';
return `
DNS 모듈
도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.
`;
}
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
const [label, help] = FIELD[key] || [key, ''];
const src = `${esc(SRC[source] || source)}`;
if (secret && multiline) return ``;
if (secret) return ``;
if (options) return ``;
return ``;
}
const tag = (ok, onText, offText, cls = ok ? 'ok' : 'warn') => `${esc(ok ? onText : offText)}`;
const statusTag = (st) => !st ? tag(false, '', '확인 중') : st.provider === 'ok' ? tag(true, '정상', '') : `${esc(ERRORS[st.provider] || st.provider)}`;
/** 전체 화면 그리기. D(설정 응답)를 기준으로 카드를 만들고 이벤트를 붙입니다. */
function render() {
const m = D.meta, v = D.values, s = D.sources, p = D.present;
$('#top-meta').textContent = `${m.host} · ${m.mailbox} · v${m.version || ''}${MODE === 'token' && DOMAINS && DOMAINS.domains.length > 1 ? ` · 도메인 ${DOMAINS.domains.length}개` : ''}`;
$('#top-links').innerHTML = `웹 메일 ↗관리자 페이지 ↗`;
const h = m.haraka || {}, hs = h.status || {};
const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기');
const choices = (name, current) => `
Resend 를 ${m.inbound === 'resend' && m.outbound === 'resend' ? '수신과 발신에' : m.inbound === 'resend' ? '수신에' : '발신에'}씁니다. 도메인 ${esc(m.domain)} 이 Resend 에 등록·검증되어 있어야 합니다.
` : ''}
${m.outbound === 'smtp' ? `
SMTP 중계 서버
보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(${esc(m.domain)})를 그 서버가 허용해야 합니다.
` : ''}
${m.usesHaraka ? `
내장 Haraka
Haraka 는 이 서버에서 함께 도는 메일 서버(MTA)입니다. 게이트웨이와는 별도 systemd 서비스로 돌며, 여기 설정을 보고 자기 설정 파일을 만들어 스스로 켜고 끕니다.
${!h.installed ? `
Haraka 가 설치되어 있지 않습니다. 서버에서 한 번 실행하세요(root): sudo bash ${esc((h.dir || '').replace(/\/haraka$/, ''))}/repo/deploy/haraka-apply.sh — 서비스 등록과 npm 설치(Haraka ${esc(hs.version || '3.x')})를 합니다. 설치 뒤 이 화면을 새로 고치세요.
` : !h.status || h.stale ? `
Haraka 관리 서비스가 돌고 있지 않습니다.sudo systemctl start <서비스 이름>-haraka 또는 deploy/haraka-apply.sh 를 다시 실행하세요.
` : hs.running ? `
실행 중 (pid ${esc(hs.pid)}, ${esc(hs.startedAt ? new Date(hs.startedAt).toLocaleString('ko-KR') : '')} 부터) · 대기 포트: ${esc((hs.listen || []).join(', '))}${hs.restarts ? ` · 재시작 ${esc(hs.restarts)}회` : ''}
` : `
${hs.enabled ? '시작을 준비하고 있습니다…' : '설정이 저장되면 몇 초 안에 시작합니다.'}${hs.lastExit ? ` 마지막 종료: 코드 ${esc(hs.lastExit.code)} (${esc(hs.lastExit.at)})` : ''}
`}
${m.primary ? '' : `
Haraka 는 서버에 하나이므로 수신 포트·제출 포트·메일 서버 이름은 기본 도메인의 설정을 따릅니다. 이 도메인에서는 DKIM 셀렉터만 정합니다.
`}
DNS 와 발신 준비
${m.inbound === 'haraka' ? 'MX 를 이 서버로 바꾸면 그때부터 메일이 이 서버로 옵니다(이전 MX 의 메일은 더 오지 않습니다). ' : ''}${m.outbound === 'haraka' ? 'SPF·DKIM 이 없으면 보낸 메일이 스팸으로 분류되기 쉽습니다. 서버 IP 의 역방향 DNS(PTR)가 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요. ' : ''}필요한 레코드와 반영 상태는 위 "자동 설정" 카드에 있습니다.
${m.outbound === 'haraka' ? `
${p.HARAKA_DKIM_PRIVATE ? `DKIM 키 있음` : `자동 설정 실행이 대신 만들어 줍니다`}
` : ''}
서비스 로그(최근)
${hs.lastLog && hs.lastLog.length ? `
${esc(hs.lastLog.join('\n'))}
` : `
아직 기록이 없습니다. 서버에서 journalctl -u <서비스 이름>-haraka -f 로 볼 수 있습니다.
`}
` : ''}
사이트 정보
배포에는 필요 없지만 운영 중 바꿀 수 있는 값입니다.
마무리
${missing.map((c) => `
${c.ok ? '✅' : '⚠️'}${esc(c.text)}
`).join('')}
${v.SETUP_DONE === true ? `` : ``}${m.systemd ? `` : ''}저장한 설정은 재시작 없이 적용됩니다. 재시작은 포트(SETUP_PORT 등 환경 파일) 변경 뒤에만 필요합니다.
${MODE === 'token' ? '이 화면은 전용 포트에서 토큰으로 열었습니다. 운영 중에는 관리자 페이지(/admin) → "메일 서비스 설정" 으로도 같은 화면을 열 수 있습니다.' : '이 화면은 관리자 세션으로 열었습니다. 설치 직후에는 배포 출력에 나온 전용 포트 주소(…:' + esc(m.setupPort) + '/?token=…)로도 열 수 있습니다.'}
`;
bind();
}
/** 점검표: 고른 방식에 꼭 필요한 값이 있는지. */
function checklist(d) {
const m = d.meta, v = d.values, p = d.present, out = [];
if (m.usesResend) { out.push({ ok: !!v.RESEND_API_KEY, required: true, text: 'Resend API 키' + (v.RESEND_API_KEY ? '' : ' — 없음') }); if (m.inbound === 'resend') out.push({ ok: !!v.RESEND_WEBHOOK_SECRET, required: true, text: 'Resend 웹훅 서명 비밀키' + (v.RESEND_WEBHOOK_SECRET ? '' : ' — 없음(웹훅 거부, 1시간 동기화만 동작)') }); }
if (m.outbound === 'smtp') out.push({ ok: !!v.SMTP_RELAY_HOST, required: true, text: 'SMTP 중계 서버 주소' + (v.SMTP_RELAY_HOST ? ` — ${v.SMTP_RELAY_HOST}:${v.SMTP_RELAY_PORT}` : ' — 없음') });
if (m.usesHaraka) { const h = m.haraka || {}; out.push({ ok: !!h.installed, required: true, text: 'Haraka 설치' + (h.installed ? '' : ' — deploy/haraka-apply.sh 실행 필요') }); out.push({ ok: !!(h.status && !h.stale && h.status.running), required: true, text: 'Haraka 실행' }); if (m.outbound === 'haraka') out.push({ ok: !!p.HARAKA_DKIM_PRIVATE, required: false, text: 'DKIM 키' + (p.HARAKA_DKIM_PRIVATE ? '(DNS 에 TXT 레코드를 올렸는지 확인)' : ' — 아직 만들지 않음') }); if (m.inbound === 'haraka') out.push({ ok: true, required: false, text: `DNS MX → ${m.harakaHost} 로 바꾸고 방화벽 ${v.HARAKA_PORT || 25} 포트 열기(수동 확인)` }); }
// 1.0.11: 서버가 만든 원인 문장(detail — 예: 어느 DNS 레코드 값이 다른지)을 함께 보입니다.
if (m.outboundStatus) out.push({ ok: m.outboundStatus.provider === 'ok', required: false, text: '발신 서비스 연결 확인' + (m.outboundStatus.provider === 'ok' ? '' : ' — ' + (ERRORS[m.outboundStatus.provider] || m.outboundStatus.provider) + (m.outboundStatus.detail ? ` (${m.outboundStatus.detail})` : '')) });
return out;
}
/** 폼에서 바뀐 값만 모읍니다. 비밀 칸은 입력했을 때만, "비우기" 를 눌렀으면 '' 로. */
function collect(form) {
const patch = {};
for (const el of form.elements) {
if (!el.name) continue;
if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; }
if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; }
if (el.name === 'SYNC_INTERVAL_MIN') { patch.SYNC_INTERVAL_MS = String(Math.max(1, Number(el.value) || 60) * 60000); continue; }
patch[el.name] = el.value;
}
return patch;
}
async function save(form, patch) {
const btn = $('button[type=submit]', form), st = $('.status', form); if (btn) btn.disabled = true; if (st) st.textContent = '저장 중…';
try { const before = D.meta.inbound + D.meta.outbound; D = await api('config', 'PUT', { values: patch }); if (PLAN && (before !== D.meta.inbound + D.meta.outbound || patch.DNS_PROVIDER !== undefined)) PLAN = null; toast('저장했습니다'); render(); }
catch (e) { if (st) st.textContent = errText(e); toast(errText(e), true); if (btn) btn.disabled = false; }
}
/** 계획 API 호출 뒤 카드 다시 그리기. */
async function planCall(fn) {
planBusy = true; render();
try { const r = await fn(); PLAN = r.plan; PLAN.log = r.log || []; D = await api('config'); }
catch (e) { toast(errText(e), true); }
finally { planBusy = false; render(); if ($('#card-plan')) $('#card-plan').scrollIntoView({ block: 'start', behavior: 'smooth' }); }
}
/** 도메인 카드의 동작(1.0.7): 선택·수정·중지/사용·삭제·추가. 목록을 다시 읽고 화면을 그립니다. */
function bindDomains() {
const f = $('#f-domain'); if (!f) return;
const refresh = async () => { DOMAINS = await api('domains'); if (!DOMAINS.domains.some((d) => d.domain === DOMAIN)) DOMAIN = DOMAINS.primary; D = await api('config'); PLAN = null; render(); };
// 기본 도메인을 수정할 때는 로그인 방식 칸이 없습니다(환경 파일이 정함) — 없으면 건너뜁니다.
const syncAuth = () => { if (!f.elements.authMode) return; const mode = f.elements.authMode.value; $$('.www-only', f).forEach((el) => { el.hidden = mode !== 'www'; }); $$('.webmaker-only', f).forEach((el) => { el.hidden = mode !== 'webmaker'; }); $$('.local-only', f).forEach((el) => { el.hidden = mode !== 'local'; }); };
if (f.elements.authMode) f.elements.authMode.onchange = syncAuth; syncAuth();
f.onsubmit = async (e) => {
e.preventDefault(); const st = $('.status', f), btn = $('button[type=submit]', f); btn.disabled = true; st.textContent = '처리 중…';
const body = { hosts: f.elements.hosts.value, note: f.elements.note.value, ...(f.elements.authMode ? { auth: { mode: f.elements.authMode.value, wwwDbFile: f.elements.wwwDbFile ? f.elements.wwwDbFile.value : '', ownerLogin: f.elements.ownerLogin ? f.elements.ownerLogin.value : '', webmakerDir: f.elements.webmakerDir ? f.elements.webmakerDir.value : '', webmakerUser: f.elements.webmakerUser ? f.elements.webmakerUser.value : '', siteId: f.elements.siteId ? f.elements.siteId.value : '' } } : {}) };
try {
if (EDIT) { await api('domains/' + encodeURIComponent(EDIT.domain), 'PATCH', body); toast('도메인 정보를 저장했습니다'); EDIT = null; }
else {
const r = await api('domains', 'POST', { ...body, domain: f.elements.domain.value.trim().toLowerCase(), mailbox: f.elements.mailbox.value.trim().toLowerCase(), adminPassword: f.elements.adminPassword.value, copyFromPrimary: f.elements.copyFromPrimary.value === '1' });
DOMAIN = r.domain.domain;
toast(r.initialAdmin ? `도메인 ${r.domain.domain} 을 추가했습니다. 초기 관리자 ${r.initialAdmin.login} / ${r.initialAdmin.password}` : `도메인 ${r.domain.domain} 을 추가했습니다`);
}
await refresh();
} catch (err) { st.textContent = errText(err); toast(errText(err), true); btn.disabled = false; }
};
if ($('#domain-cancel')) $('#domain-cancel').onclick = () => { EDIT = null; render(); };
$$('[data-domain-select]').forEach((b) => { b.onclick = async () => { DOMAIN = b.dataset.domainSelect; EDIT = null; try { D = await api('config'); PLAN = null; render(); } catch (err) { toast(errText(err), true); } }; });
$$('[data-domain-edit]').forEach((b) => { b.onclick = () => { EDIT = DOMAINS.domains.find((d) => d.domain === b.dataset.domainEdit) || null; render(); $('#f-domain').scrollIntoView({ block: 'start', behavior: 'smooth' }); }; });
$$('[data-domain-toggle]').forEach((b) => { b.onclick = async () => { const on = b.dataset.enabled === '1'; if (on && !confirm(`${b.dataset.domainToggle} 의 메일 서비스를 중지합니다. 웹 메일·메일 프로그램 로그인과 수신이 모두 막히고 화면에는 "활성화되지 않았습니다" 가 보입니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainToggle), 'PATCH', { enabled: !on }); toast(on ? '중지했습니다' : '다시 사용합니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; });
$$('[data-domain-remove]').forEach((b) => { b.onclick = async () => { if (!confirm(`${b.dataset.domainRemove} 을 등록에서 지웁니다. 데이터 디렉터리(domains/${b.dataset.domainRemove})는 서버에 남으며, 지우려면 서버에서 직접 삭제합니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainRemove), 'DELETE'); toast('등록을 지웠습니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; });
}
function bind() {
bindDomains();
$$('form:not(.plan-input):not(#f-domain)').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); save(f, collect(f)); }; });
// 자동 설정(1.0.5): 진단·실행·단계 단추·입력 저장
if ($('#plan-load')) $('#plan-load').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: [] }));
if ($('#plan-reload')) $('#plan-reload').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: PLAN && PLAN.log }));
if ($('#plan-run')) $('#plan-run').onclick = () => planCall(() => api('plan/run', 'POST', {}));
$$('[data-plan-run]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.planRun })); });
$$('[data-plan-action]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.step, action: b.dataset.planAction })); });
$$('form.plan-input').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); planCall(() => api('plan/run', 'POST', { step: f.dataset.step, input: collect(f) })); }; });
// 사용량 API 키(1.0.9): 만들기(교체) / 삭제. 만든 키는 응답에만 있으므로 화면에 한 번 보여 주고 다음 그리기부터는 가린 값만 남습니다.
if ($('#usage-generate')) $('#usage-generate').onclick = async (e) => { const btn = e.currentTarget; if (D.meta.usageApi.enabled && !confirm('기존 키는 바로 무효가 됩니다. 웹메이커 쪽 설정도 새 키로 바꿔야 합니다. 계속할까요?')) return; btn.disabled = true; try { const r = await api('usage-key', 'POST', { action: 'generate' }); USAGE_KEY_SHOWN = r.key; D = await api('config'); render(); toast('사용량 API 키를 만들었습니다 — 지금 복사하세요'); $('#card-usage').scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (err) { toast(errText(err), true); btn.disabled = false; } };
if ($('#usage-revoke')) $('#usage-revoke').onclick = async () => { if (!confirm('사용량 API 키를 지워 API 를 끕니다. 웹메이커의 사용량 조회가 멈춥니다. 계속할까요?')) return; try { await api('usage-key', 'POST', { action: 'revoke' }); USAGE_KEY_SHOWN = null; D = await api('config'); render(); toast('키를 지웠습니다'); } catch (err) { toast(errText(err), true); } };
if ($('#dns-probe')) $('#dns-probe').onclick = async (e) => { e.currentTarget.disabled = true; try { const r = await api('dns/probe', 'POST', {}); toast(r.probe.ok ? `연결 정상 — 존 ${r.probe.zone}` : (ERRORS[r.probe.error] || r.probe.error) + (r.probe.detail ? ' — ' + r.probe.detail : ''), !r.probe.ok); } catch (err) { toast(errText(err), true); } finally { e.target.disabled = false; } };
$$('.choice input').forEach((r) => { r.onchange = () => { $$('.choice', r.closest('.choices')).forEach((c) => c.classList.toggle('selected', $('input', c).checked)); }; });
$$('[data-clear]').forEach((b) => { b.onclick = () => { const input = $(`input[name="${b.dataset.clear}"]`, b.closest('form')); input.dataset.clear = input.dataset.clear === '1' ? '' : '1'; input.value = ''; input.placeholder = input.dataset.clear === '1' ? '(저장하면 지워집니다)' : '(그대로 둠)'; b.textContent = input.dataset.clear === '1' ? '취소' : '비우기'; }; });
$$('[data-copy]').forEach((b) => { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); toast('복사했습니다'); } catch { prompt('복사:', b.dataset.copy); } }; });
const check = async (btn, what) => { btn.disabled = true; try { const r = await api('check', 'POST', { what }); if (what === 'outbound') { D.meta.outboundStatus = r.outboundStatus; toast(r.outboundStatus.provider === 'ok' ? '연결 정상' : errText(new Error(r.outboundStatus.provider)) + (r.outboundStatus.detail ? ' — ' + r.outboundStatus.detail : ''), r.outboundStatus.provider !== 'ok'); } else { D.meta.haraka = r.haraka; } render(); } catch (e) { toast(errText(e), true); btn.disabled = false; } };
for (const id of ['check-outbound', 'check-resend', 'check-smtp']) if ($('#' + id)) $('#' + id).onclick = (e) => check(e.currentTarget, 'outbound');
if ($('#check-haraka')) $('#check-haraka').onclick = (e) => check(e.currentTarget, 'haraka');
const dkim = async (force) => { if (force && !confirm('DKIM 키를 다시 만들면 DNS 의 TXT 레코드도 새 값으로 바꿔야 합니다. 계속할까요?')) return; try { await api('dkim', 'POST', { force }); toast('DKIM 키를 만들었습니다. DNS 레코드를 올리세요.'); D = await api('config'); render(); } catch (e) { toast(errText(e), true); } };
if ($('#dkim-gen')) $('#dkim-gen').onclick = () => dkim(false); if ($('#dkim-regen')) $('#dkim-regen').onclick = () => dkim(true);
if ($('#setup-done')) $('#setup-done').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: true } }); toast('완료로 표시했습니다'); render(); } catch (e) { toast(errText(e), true); } };
if ($('#setup-undone')) $('#setup-undone').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: false } }); render(); } catch (e) { toast(errText(e), true); } };
if ($('#restart')) $('#restart').onclick = async () => { if (!confirm('서비스를 재시작합니다. 몇 초 동안 접속이 끊깁니다.')) return; try { await api('restart', 'POST', {}); toast('재시작 중… 10초 뒤 화면을 다시 읽습니다'); setTimeout(() => location.reload(), 10000); } catch (e) { toast(errText(e), true); } };
}
function showTokenPrompt(message) {
$('#main').innerHTML = `
접속 토큰
${esc(message)}
토큰은 서버의 데이터 디렉터리 setup-token 파일에 있습니다(배포 계정만 읽을 수 있음). 배포 스크립트가 끝에 전체 주소를 출력합니다.