Files

313 lines
58 KiB
JavaScript
Raw Permalink 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.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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<section class="card step" id="card-plan"><h2>자동 설정</h2><p class="intro">고른 수·발신 방식과 도메인에 맞게 Resend 도메인·웹훅, DNS 레코드, 내장 Haraka 준비 상태를 진단하고 할 수 있는 일은 자동으로 합니다.</p><div class="actions"><button type="button" class="primary" id="plan-load">상태 진단</button><span class="status muted">Resend·DNS 조회를 하므로 몇 초 걸립니다.</span></div></section>`;
const p = PLAN;
const rows = p.steps.filter((s) => s.state !== 'skip').map((s) => {
const [icon, word, cls] = STATE[s.state] || STATE.info;
const inputsHtml = s.input && s.input.length ? `<form class="plan-input" data-step="${esc(s.id)}"><div class="grid">${s.input.map((f) => field(f.key, { type: f.type === 'number' ? 'number' : 'text', options: f.options || null, value: f.type === 'secret' ? (D.values[f.key] || '') : (D.values[f.key] ?? ''), source: (D.sources || {})[f.key] || 'default', secret: f.type === 'secret', multiline: !!f.multiline })).join('')}</div><div class="actions"><button class="primary" type="submit">저장하고 계속</button></div></form>` : '';
const btns = (s.actions || []).map((a) => `<button type="button" class="small" data-plan-action="${esc(a.id)}" data-step="${esc(s.id)}">${esc(a.label)}</button>`).join('') + (s.auto && s.state === 'todo' ? `<button type="button" class="small primary" data-plan-run="${esc(s.id)}">지금 실행</button>` : '');
return `<li class="plan-step state-${esc(s.state)}"><span class="mark">${icon}</span><div class="body"><div class="t">${esc(s.title)} <span class="tag ${cls}">${esc(word)}</span></div>${s.detail ? `<div class="d">${esc(s.detail)}</div>` : ''}${btns ? `<div class="actions" style="margin-top:6px">${btns}</div>` : ''}${inputsHtml}</div></li>`;
}).join('');
const recs = (p.records || []).map((r) => `<tr class="${r.check && r.check.ok ? 'ok' : r.optional ? '' : 'missing'}"><td>${r.check && r.check.ok ? '✅' : r.optional ? '◦' : '⚠️'}</td><td>${esc(r.type)}</td><td class="mono">${esc(r.name)}</td><td class="mono">${esc((r.priority !== undefined ? r.priority + ' ' : '') + r.values.join(' / '))}</td><td class="muted">${esc(r.purpose)}${r.check && !r.check.ok && r.check.found && r.check.found.length ? `<div>지금 값: ${esc(r.check.found.join(' / '))}</div>` : ''}</td><td><button type="button" class="small" data-copy="${esc(r.values.join(' '))}">복사</button></td></tr>`).join('');
const dnsLine = p.dns.provider === 'none' ? 'DNS 모듈 없음 — 레코드를 직접 넣습니다(아래 "DNS 모듈" 카드에서 Google Cloud DNS / Route 53 을 연결하면 자동 적용).' : `DNS 모듈: ${p.dns.provider}${p.dns.zone ? ` (존 ${p.dns.zone})` : ''}${!p.dns.configured ? ' — 인증 정보 없음' : ''}${p.dns.error ? ` — 오류: ${ERRORS[p.dns.error] || p.dns.error}` : ''}`;
return `<section class="card step" id="card-plan"><h2>자동 설정</h2><p class="intro">수신 <strong>${esc(MODES.inbound[p.inbound][0])}</strong> · 발신 <strong>${esc(MODES.outbound[p.outbound][0])}</strong> · 도메인 <code>${esc(p.domain)}</code> · 메일 서버 이름 <code>${esc(p.host)}</code></p>
<div class="note ${p.ready ? 'ok' : p.steps.some((s) => s.state === 'error') ? 'warn' : ''}"><strong>${esc(p.readyMessage)}</strong><div class="meta muted">진단 ${esc(new Date(p.checkedAt).toLocaleString('ko-KR'))}</div></div>
<ul class="checklist plan">${rows}</ul>
<div class="actions"><button type="button" class="primary" id="plan-run" ${planBusy ? 'disabled' : ''}>자동 설정 실행</button><button type="button" id="plan-reload" ${planBusy ? 'disabled' : ''}>다시 진단</button><span class="status muted">${planBusy ? '실행 중…' : '자동 실행 가능한 단계(▶)를 순서대로 처리하고 다시 진단합니다.'}</span></div>
<h3>필요한 DNS 레코드 (${esc(p.domain)})</h3><p class="muted" style="font-size:13px;margin:0 0 8px">${esc(dnsLine)} 공개 DNS(8.8.8.8 / 1.1.1.1) 기준으로 반영 여부를 봅니다.</p>
${recs ? `<table class="simple"><thead><tr><th></th><th>종류</th><th>이름</th><th>값</th><th>용도</th><th></th></tr></thead><tbody>${recs}</tbody></table>` : '<p class="muted">이 구성에는 추가할 DNS 레코드가 없습니다.</p>'}
${PLAN.log && PLAN.log.length ? `<h3>마지막 실행 기록</h3><pre class="log">${esc(PLAN.log.map((l) => `${l.id}: ${l.result}${l.detail ? ' — ' + l.detail : ''}`).join('\n'))}</pre>` : ''}</section>`;
}
/**
* 도메인 카드(1.0.7, 요구사항 1·2): 서버의 모든 도메인 목록과 추가·수정 폼. 전용 포트(token)에서만 보입니다.
* 행의 "선택" 이 아래 카드들의 대상 도메인(DOMAIN)을 바꾸고, "중지/사용" 은 그 도메인의 모든 접속(웹·메일 프로그램·수신)을 막거나 엽니다.
*/
function domainsCard() {
if (MODE !== 'token' || !DOMAINS) return '';
const rows = DOMAINS.domains.map((d) => {
const cur = d.domain === DOMAIN;
const btns = [`<button type="button" class="small ${cur ? '' : 'primary'}" data-domain-select="${esc(d.domain)}" ${cur ? 'disabled' : ''}>${cur ? '보는 중' : '선택'}</button>`, `<button type="button" class="small" data-domain-edit="${esc(d.domain)}">수정</button>`];
if (!d.primary) btns.push(`<button type="button" class="small" data-domain-toggle="${esc(d.domain)}" data-enabled="${d.enabled ? '1' : '0'}">${d.enabled ? '중지' : '사용'}</button>`, `<button type="button" class="small ghost" data-domain-remove="${esc(d.domain)}">삭제</button>`);
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}${d.operating && d.operating !== 'active' ? `<div class="meta warn">운영 상태: ${esc({ migrating_out: '이전 중(보내는 쪽)', migrating_in: '이전 중(받는 쪽)', frozen: '동결' }[d.operating] || d.operating)}</div>` : ''}${d.ariweb ? `<div class="meta muted">Ariweb: ${esc(d.ariweb.kind === 'site_bound' ? '사이트 대표 메일' : '독립 메일 서비스')}${d.ariweb.siteId ? ' · 사이트 ' + esc(d.ariweb.siteId) : ''}</div>` : ''}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
}).join('');
const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary;
const form = `<form id="f-domain" class="domain-form ${editing ? 'editing' : ''}"><h3>${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}</h3><div class="grid">
${editing ? '' : `<label class="field"><span>도메인</span><input name="domain" required placeholder="example.com" autocomplete="off" pattern="[a-z0-9.\\-]+"><span class="help">메일 주소의 @ 뒤. 이 도메인으로 받은 메일을 다루고 계정의 메일함은 로그인 ID@도메인이 됩니다.</span></label>`}
<label class="field"><span>웹 메일 호스트 이름</span><input name="hosts" value="${esc(editing ? e.hosts.join(', ') : '')}" placeholder="mail.example.com (쉼표로 여러 개)" autocomplete="off" ${isPrimaryEdit ? '' : ''}><span class="help">이 이름으로 들어온 요청이 이 도메인의 웹 메일입니다. 비우면 mail.&lt;도메인&gt;. nginx 에 server_name 과 인증서가 있어야 합니다(deploy/domain-nginx.sh). 메일용 호스트를 따로 만들기 어려우면 "site.example.com/mail" 처럼 경로를 붙여 그 사이트의 /mail 아래로 둘 수 있습니다(deploy/site-mail-path.sh).</span></label>
${isPrimaryEdit ? '' : `<label class="field"><span>기본 메일함(로컬 부분)</span><input name="mailbox" value="${esc(editing ? e.mailbox.split('@')[0] : 'admin')}" placeholder="admin" autocomplete="off" ${editing ? 'readonly' : ''}><span class="help">첫 관리자의 로그인 ID 이자 기본 메일함(admin@도메인). 만든 뒤에는 바꿀 수 없습니다.</span></label>
<label class="field"><span>로그인 방식</span><select name="authMode"><option value="local" ${!editing || e.auth.mode === 'local' ? 'selected' : ''}>자체 계정(admin 관리자 자동 생성)</option><option value="www" ${editing && e.auth.mode === 'www' ? 'selected' : ''}>연결된 웹 서비스의 계정과 연동</option><option value="webmaker" ${editing && e.auth.mode === 'webmaker' ? 'selected' : ''}>웹메이커(webmaker) 계정과 연동</option></select><span class="help">자체 계정: 이 도메인만의 관리자(admin)와 계정을 관리자 페이지에서 만듭니다. 웹 서비스 연동: www 사이트 DB 의 관리자 계정으로 로그인합니다. 웹메이커 연동: 웹메이커의 사용자 파일을 읽기 전용으로 참조해 그 계정(관리자 admin, 사이트 소유자, 사이트의 사용자)으로 웹 메일·IMAP·POP·SMTP 에 같은 비밀번호로 로그인합니다.</span></label>
<label class="field www-only"><span>웹 서비스 DB 파일</span><input name="wwwDbFile" value="${esc(editing ? e.auth.wwwDbFile : '')}" placeholder="/srv/example/data/example.sqlite" autocomplete="off"><span class="help">users 표(login_id·email·role·status·password_hash)를 가진 SQLite 파일. 서비스 계정이 읽을 수 있어야 합니다(systemd ReadWritePaths 참고).</span></label>
<label class="field www-only"><span>기본 메일함 소유 관리자(선택)</span><input name="ownerLogin" value="${esc(editing ? e.auth.ownerLogin : '')}" placeholder="로그인 ID 또는 이메일" autocomplete="off"></label>
<label class="field webmaker-only"><span>웹메이커 데이터 디렉터리</span><input name="webmakerDir" value="${esc(editing ? e.auth.webmakerDir : '')}" placeholder="/data/webmaker" autocomplete="off"><span class="help">웹메이커 서비스의 DATA_ROOT(users.json·sites/·modules/·domains.json 이 있는 곳). 서비스 계정이 못 읽는 디렉터리(보통 0700)면 아래 계정을 적고 서버에서 <code>sudo bash deploy/webmaker-access.sh &lt;디렉터리&gt;</code> 로 읽기 도우미를 설치합니다.</span></label>
<label class="field webmaker-only"><span>파일을 읽을 계정(선택)</span><input name="webmakerUser" value="${esc(editing ? e.auth.webmakerUser : '')}" placeholder="master" autocomplete="off" pattern="[a-z_][a-z0-9_\\-]{0,31}"><span class="help">웹메이커 데이터의 소유 계정. 적으면 sudo -u 이 계정으로 읽기 도우미(/usr/local/lib/arimail/webmaker-snapshot)를 부릅니다. 비우면 서비스 계정이 직접 읽습니다.</span></label>
<label class="field webmaker-only"><span>웹메이커 사이트 ID(선택)</span><input name="siteId" value="${esc(editing ? e.auth.siteId : '')}" placeholder="32자리 16진수" autocomplete="off" pattern="[a-f0-9]{32}"><span class="help">비우면 도메인 이름으로 사이트를 찾습니다(서브 도메인 등록 → 사이트의 도메인 목록 → 사이트의 메일 도메인). 사이트가 없으면 웹메이커의 모든 회원이 아이디@도메인으로 로그인합니다.</span></label>
${editing ? '' : `<label class="field local-only"><span>초기 관리자 비밀번호</span><input name="adminPassword" value="adminpasswd" autocomplete="off" minlength="6"><span class="help">admin 계정의 첫 비밀번호(6자 이상). 로그인 뒤 바꾸라는 경고가 보입니다.</span></label>
<label class="field"><span>기본 도메인 설정 복사</span><select name="copyFromPrimary"><option value="1">API 키·DNS 인증 정보·중계 서버 설정을 복사</option><option value="0">빈 설정으로 시작</option></select><span class="help">같은 Resend 계정·DNS 계정을 쓰면 복사가 편합니다. 수·발신 방식, Resend 도메인, DKIM 키는 복사하지 않습니다.</span></label>`}`}
<label class="field"><span>메모(선택)</span><input name="note" value="${esc(editing ? e.note : '')}" maxlength="200" autocomplete="off"></label>
</div><div class="actions"><button class="primary" type="submit">${editing ? '저장' : '도메인 추가'}</button>${editing ? '<button type="button" id="domain-cancel">취소</button>' : ''}<span class="status"></span></div></form>`;
return `<section class="card" id="card-domains"><h2>도메인 <span class="tag">${DOMAINS.domains.length}개</span></h2><p class="intro">이 서버가 메일을 다루는 도메인입니다. 도메인마다 계정·메일·설정이 따로 있고, 웹 메일은 각 호스트 이름으로, 메일 프로그램은 <code>${esc(DOMAINS.serverHost || '')}</code> 에 <code>아이디@도메인</code> 으로 접속합니다. 아래 카드는 <strong>선택한 도메인</strong>의 설정입니다.</p>
<table class="simple domains"><thead><tr><th>도메인</th><th>웹 메일 호스트</th><th>상태</th><th>로그인</th><th>수신 / 발신</th><th>계정 · 메일</th><th></th></tr></thead><tbody>${rows}</tbody></table>${form}</section>`;
}
/**
* 사용량 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 `<section class="card step" id="card-usage"><h2>사용량 API <span class="tag ${u.enabled ? 'ok' : ''}">${u.enabled ? '사용 중' : '꺼짐'}</span></h2>
<p class="intro">웹메이커 등 외부 관리 시스템이 도메인·계정별 <strong>디스크 사용량·메일 수·발송량(일/주/월·일별 추이)</strong>을 읽는 읽기 전용 API 입니다. 비밀 키(Bearer)와 접속 IP 제한으로 보호되며 메일 내용·비밀번호·API 키는 나가지 않습니다. 사용 방법은 저장소의 <code>docs/USAGE-API.md</code>.</p>
<div class="summary"><div class="item"><div class="k">주소</div><div class="v"><code>${esc(u.endpoint)}</code> <span class="muted">(도메인 하나: <code>/api/usage/&lt;도메인&gt;</code>)</span></div></div>
<div class="item"><div class="k">키</div><div class="v">${u.enabled ? `<code>${esc(u.keyMasked)}</code>${u.updatedAt ? ` <span class="muted">(${esc(new Date(u.updatedAt).toLocaleString('ko-KR'))} 생성)</span>` : ''}` : '<span class="muted">없음 — 아래 단추로 만들면 API 가 켜집니다</span>'}</div></div></div>
${shown ? `<div class="notice"><strong>새 키(지금만 보입니다 — 웹메이커 설정에 복사해 두세요):</strong><pre class="log" style="user-select:all">${esc(shown)}</pre><p class="meta">예: <code>${esc(curl.replace('<키>', shown))}</code></p></div>` : ''}
<div class="actions"><button type="button" class="primary" id="usage-generate">${u.enabled ? '키 다시 만들기(기존 키 무효)' : '키 만들기'}</button>${u.enabled ? '<button type="button" class="ghost" id="usage-revoke">키 삭제(API 끄기)</button>' : ''}</div>
<form id="f-usage"><div class="grid">${field('USAGE_API_ALLOW', { value: v.USAGE_API_ALLOW || '', source: s.USAGE_API_ALLOW, placeholder: '203.0.113.10, 10.0.0.0/8' })}</div><div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>`;
}
/** DNS 모듈 카드(요구사항 1): 서비스 선택과 인증 정보, 연결 확인. */
function dnsCard() {
const v = D.values, s = D.sources, prov = v.DNS_PROVIDER || 'none';
return `<section class="card step"><h2>DNS 모듈</h2><p class="intro">도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.</p>
<form id="f-dns"><div class="grid">${field('DNS_PROVIDER', { options: ['none', 'google', 'route53'], value: prov, source: s.DNS_PROVIDER })}${prov === 'google' ? field('DNS_GOOGLE_CREDENTIALS', { secret: true, multiline: true, value: v.DNS_GOOGLE_CREDENTIALS, source: s.DNS_GOOGLE_CREDENTIALS }) + field('DNS_GOOGLE_PROJECT', { value: v.DNS_GOOGLE_PROJECT, source: s.DNS_GOOGLE_PROJECT }) + field('DNS_GOOGLE_ZONE', { value: v.DNS_GOOGLE_ZONE, source: s.DNS_GOOGLE_ZONE }) : ''}${prov === 'route53' ? field('DNS_AWS_ACCESS_KEY_ID', { value: v.DNS_AWS_ACCESS_KEY_ID, source: s.DNS_AWS_ACCESS_KEY_ID }) + field('DNS_AWS_SECRET_ACCESS_KEY', { secret: true, value: v.DNS_AWS_SECRET_ACCESS_KEY, source: s.DNS_AWS_SECRET_ACCESS_KEY }) + field('DNS_AWS_HOSTED_ZONE_ID', { value: v.DNS_AWS_HOSTED_ZONE_ID, source: s.DNS_AWS_HOSTED_ZONE_ID }) : ''}</div>
<div class="actions"><button class="primary" type="submit">저장</button>${prov !== 'none' ? `<button type="button" id="dns-probe">연결 확인(존 찾기)</button>` : ''}<span class="status"></span></div></form></section>`;
}
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
const [label, help] = FIELD[key] || [key, ''];
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
if (options) return `<label class="field"><span>${esc(label)}</span><select name="${key}">${options.map((o) => `<option value="${esc(o)}" ${String(o) === String(value) ? 'selected' : ''}>${esc(o)}</option>`).join('')}</select>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
return `<label class="field"><span>${esc(label)}</span><input type="${type}" name="${key}" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" ${type === 'number' ? 'min="0" step="1"' : ''}>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
}
const tag = (ok, onText, offText, cls = ok ? 'ok' : 'warn') => `<span class="tag ${cls}">${esc(ok ? onText : offText)}</span>`;
const statusTag = (st) => !st ? tag(false, '', '확인 중') : st.provider === 'ok' ? tag(true, '정상', '') : `<span class="tag warn" title="${esc(st.detail || '')}">${esc(ERRORS[st.provider] || st.provider)}</span>`;
/** 전체 화면 그리기. 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 = `<a href="https://${esc(m.host)}/" target="_blank" rel="noopener">웹 메일 ↗</a><a href="https://${esc(m.host)}/admin" target="_blank" rel="noopener">관리자 페이지 ↗</a>`;
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) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
const missing = checklist(D);
$('#main').innerHTML = `
${domainsCard()}
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
<div class="item"><div class="k">발신 서비스 연결</div><div class="v">${statusTag(m.outboundStatus)} <button type="button" class="small" id="check-outbound">연결 확인</button></div>${m.outboundStatus && m.outboundStatus.detail ? `<div class="meta muted">${esc(m.outboundStatus.detail)}</div>` : ''}${m.outboundStatus && m.outboundStatus.domain ? `<div class="meta muted">Resend 도메인 ${esc(m.outboundStatus.domain.name)}: ${esc(m.outboundStatus.domain.status)}</div>` : ''}</div>
<div class="item"><div class="k">내장 Haraka</div><div class="v">${harakaTag}</div></div>
<div class="item"><div class="k">설정 파일</div><div class="v"><code>${esc(D.file)}</code></div><div class="meta muted">${D.updatedAt ? '마지막 저장 ' + esc(new Date(D.updatedAt).toLocaleString('ko-KR')) : ''}${D.importedFromEnv && D.importedFromEnv.length ? ` · 환경 파일에서 가져온 값 ${D.importedFromEnv.length}개` : ''}</div></div>
<div class="item"><div class="k">초기 설정</div><div class="v">${tag(v.SETUP_DONE === true, '완료 표시됨', '진행 중', v.SETUP_DONE === true ? 'ok' : '')}</div></div>
</div></section>
<div class="steps">
<section class="card step"><h2>메일을 받고 보내는 방식</h2><p class="intro">받는 길과 보내는 길을 따로 고를 수 있습니다. 예: 수신은 내장 Haraka, 발신은 Resend. 저장하면 바로 적용되며, Haraka 를 고르면 별도 서비스(haraka)가 설정을 보고 스스로 뜹니다.</p>
<form id="f-mail"><div class="grid"><div><h3>수신</h3>${choices('inbound', m.inbound)}</div><div><h3>발신</h3>${choices('outbound', m.outbound)}</div></div><div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>
${planCard()}
${dnsCard()}
${usageCard()}
${m.usesResend ? `<section class="card step"><h2>Resend</h2><p class="intro">Resend 를 ${m.inbound === 'resend' && m.outbound === 'resend' ? '수신과 발신에' : m.inbound === 'resend' ? '수신에' : '발신에'}씁니다. 도메인 <code>${esc(m.domain)}</code> 이 Resend 에 등록·검증되어 있어야 합니다.</p>
<form id="f-resend"><div class="grid">${field('RESEND_API_KEY', { secret: true, value: v.RESEND_API_KEY, source: s.RESEND_API_KEY })}${field('RESEND_REGION', { options: ['ap-northeast-1', 'us-east-1', 'eu-west-1', 'sa-east-1'], value: v.RESEND_REGION || 'ap-northeast-1', source: s.RESEND_REGION })}${m.inbound === 'resend' ? field('RESEND_WEBHOOK_SECRET', { secret: true, value: v.RESEND_WEBHOOK_SECRET, source: s.RESEND_WEBHOOK_SECRET }) : ''}</div>
${m.inbound === 'resend' ? `<div class="note">웹훅 엔드포인트: <span class="copy"><code>${esc(m.webhookUrl)}</code><button type="button" class="small" data-copy="${esc(m.webhookUrl)}">복사</button></span> · 이벤트 <code>email.received</code></div>` : ''}
<div class="actions"><button class="primary" type="submit">저장</button><button type="button" id="check-resend">연결 확인</button><span class="status"></span></div></form></section>` : ''}
${m.outbound === 'smtp' ? `<section class="card step"><h2>SMTP 중계 서버</h2><p class="intro">보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(<code>${esc(m.domain)}</code>)를 그 서버가 허용해야 합니다.</p>
<form id="f-smtp"><div class="grid">${field('SMTP_RELAY_HOST', { value: v.SMTP_RELAY_HOST, source: s.SMTP_RELAY_HOST, placeholder: 'smtp.example.com' })}${field('SMTP_RELAY_PORT', { type: 'number', value: v.SMTP_RELAY_PORT, source: s.SMTP_RELAY_PORT })}${field('SMTP_RELAY_SECURITY', { options: ['starttls', 'tls', 'none'], value: v.SMTP_RELAY_SECURITY || 'starttls', source: s.SMTP_RELAY_SECURITY })}${field('SMTP_RELAY_USER', { value: v.SMTP_RELAY_USER, source: s.SMTP_RELAY_USER })}${field('SMTP_RELAY_PASS', { secret: true, value: v.SMTP_RELAY_PASS, source: s.SMTP_RELAY_PASS })}</div>
<div class="actions"><button class="primary" type="submit">저장</button><button type="button" id="check-smtp">연결 확인</button><span class="status"></span></div></form></section>` : ''}
${m.usesHaraka ? `<section class="card step"><h2>내장 Haraka</h2><p class="intro">Haraka 는 이 서버에서 함께 도는 메일 서버(MTA)입니다. 게이트웨이와는 별도 systemd 서비스로 돌며, 여기 설정을 보고 자기 설정 파일을 만들어 스스로 켜고 끕니다.</p>
${!h.installed ? `<div class="note warn"><strong>Haraka 가 설치되어 있지 않습니다.</strong> 서버에서 한 번 실행하세요(root): <code>sudo bash ${esc((h.dir || '').replace(/\/haraka$/, ''))}/repo/deploy/haraka-apply.sh</code> — 서비스 등록과 npm 설치(Haraka ${esc(hs.version || '3.x')})를 합니다. 설치 뒤 이 화면을 새로 고치세요.</div>` : !h.status || h.stale ? `<div class="note warn"><strong>Haraka 관리 서비스가 돌고 있지 않습니다.</strong> <code>sudo systemctl start &lt;서비스 이름&gt;-haraka</code> 또는 <code>deploy/haraka-apply.sh</code> 를 다시 실행하세요.</div>` : hs.running ? `<div class="note ok">실행 중 (pid ${esc(hs.pid)}, ${esc(hs.startedAt ? new Date(hs.startedAt).toLocaleString('ko-KR') : '')} 부터) · 대기 포트: ${esc((hs.listen || []).join(', '))}${hs.restarts ? ` · 재시작 ${esc(hs.restarts)}회` : ''}</div>` : `<div class="note">${hs.enabled ? '시작을 준비하고 있습니다…' : '설정이 저장되면 몇 초 안에 시작합니다.'}${hs.lastExit ? ` 마지막 종료: 코드 ${esc(hs.lastExit.code)} (${esc(hs.lastExit.at)})` : ''}</div>`}
${m.primary ? '' : `<div class="note">Haraka 는 서버에 하나이므로 수신 포트·제출 포트·메일 서버 이름은 <strong>기본 도메인</strong>의 설정을 따릅니다. 이 도메인에서는 DKIM 셀렉터만 정합니다.</div>`}
<form id="f-haraka"><div class="grid">${m.primary ? field('HARAKA_PORT', { type: 'number', value: v.HARAKA_PORT, source: s.HARAKA_PORT }) + field('HARAKA_RELAY_PORT', { type: 'number', value: v.HARAKA_RELAY_PORT, source: s.HARAKA_RELAY_PORT }) + field('HARAKA_HOSTNAME', { value: v.HARAKA_HOSTNAME, source: s.HARAKA_HOSTNAME, placeholder: m.host }) : ''}${field('HARAKA_DKIM_SELECTOR', { value: v.HARAKA_DKIM_SELECTOR, source: s.HARAKA_DKIM_SELECTOR })}</div>
<div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form>
<h3>DNS 와 발신 준비</h3><p class="muted" style="font-size:13px;margin:0 0 8px">${m.inbound === 'haraka' ? 'MX 를 이 서버로 바꾸면 그때부터 메일이 이 서버로 옵니다(이전 MX 의 메일은 더 오지 않습니다). ' : ''}${m.outbound === 'haraka' ? 'SPF·DKIM 이 없으면 보낸 메일이 스팸으로 분류되기 쉽습니다. 서버 IP 의 역방향 DNS(PTR)가 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요. ' : ''}필요한 레코드와 반영 상태는 위 "자동 설정" 카드에 있습니다.</p>
${m.outbound === 'haraka' ? `<div class="actions" style="margin:0 0 8px">${p.HARAKA_DKIM_PRIVATE ? `<span class="tag ok">DKIM 키 있음</span><button type="button" id="dkim-regen" class="small">키 다시 만들기</button>` : `<button type="button" id="dkim-gen" class="primary">DKIM 키 만들기</button><span class="status">자동 설정 실행이 대신 만들어 줍니다</span>`}</div>` : ''}
<h3>서비스 로그(최근)</h3>${hs.lastLog && hs.lastLog.length ? `<pre class="log">${esc(hs.lastLog.join('\n'))}</pre>` : `<p class="muted" style="font-size:13px">아직 기록이 없습니다. 서버에서 <code>journalctl -u &lt;서비스 이름&gt;-haraka -f</code> 로 볼 수 있습니다.</p>`}
<div class="actions"><button type="button" id="check-haraka">상태 새로 고침</button></div></section>` : ''}
<section class="card step"><h2>사이트 정보</h2><p class="intro">배포에는 필요 없지만 운영 중 바꿀 수 있는 값입니다.</p>
<form id="f-site"><div class="grid">${field('MAIL_FROM_NAME', { value: v.MAIL_FROM_NAME, source: s.MAIL_FROM_NAME })}${field('SITE_TITLE', { value: v.SITE_TITLE, source: s.SITE_TITLE })}${m.loginMode === 'www' ? field('MAILBOX_OWNER_LOGIN', { value: v.MAILBOX_OWNER_LOGIN, source: s.MAILBOX_OWNER_LOGIN }) : ''}${field('STATS_TIMEZONE', { value: v.STATS_TIMEZONE || 'Asia/Seoul', source: s.STATS_TIMEZONE })}${m.usesResend ? field('SYNC_INTERVAL_MIN', { type: 'number', value: Math.round(Number(v.SYNC_INTERVAL_MS || 3600000) / 60000), source: s.SYNC_INTERVAL_MS }) : ''}</div>
<div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>
<section class="card step"><h2>마무리</h2>
<ul class="checklist">${missing.map((c) => `<li><span class="mark">${c.ok ? '✅' : '⚠️'}</span><span>${esc(c.text)}</span></li>`).join('')}</ul>
<div class="actions">${v.SETUP_DONE === true ? `<button type="button" id="setup-undone">완료 표시 해제</button>` : `<button type="button" id="setup-done" class="primary" ${missing.some((c) => !c.ok && c.required) ? 'title="필수 항목이 비어 있어도 표시할 수 있습니다"' : ''}>설치 완료로 표시</button>`}${m.systemd ? `<button type="button" id="restart">서비스 재시작</button>` : ''}<span class="status muted">저장한 설정은 재시작 없이 적용됩니다. 재시작은 포트(SETUP_PORT 등 환경 파일) 변경 뒤에만 필요합니다.</span></div>
<p class="muted" style="font-size:13px;margin-top:12px">${MODE === 'token' ? '이 화면은 전용 포트에서 토큰으로 열었습니다. 운영 중에는 관리자 페이지(/admin) → "메일 서비스 설정" 으로도 같은 화면을 열 수 있습니다.' : '이 화면은 관리자 세션으로 열었습니다. 설치 직후에는 배포 출력에 나온 전용 포트 주소(…:' + esc(m.setupPort) + '/?token=…)로도 열 수 있습니다.'}</p></section>
</div>`;
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 = `<section class="card hint-token"><h2>접속 토큰</h2><p class="muted">${esc(message)}</p><form id="f-token"><label class="field"><span>토큰</span><input name="token" autocomplete="off" placeholder="배포 출력의 ?token= 뒤 값"></label><div class="actions"><button class="primary" type="submit">열기</button></div></form><p class="muted" style="font-size:13px">토큰은 서버의 데이터 디렉터리 <code>setup-token</code> 파일에 있습니다(배포 계정만 읽을 수 있음). 배포 스크립트가 끝에 전체 주소를 출력합니다.</p></section>`;
$('#f-token').onsubmit = (e) => { e.preventDefault(); token = $('#f-token input').value.trim(); try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* 무시 */ } load(); };
}
async function load() {
try { if (MODE === 'token') { DOMAINS = await api('domains'); if (!DOMAIN) DOMAIN = DOMAINS.primary; } D = await api('config'); render(); }
catch (e) {
if (MODE === 'token' && (e.message === 'setup_token_invalid' || e.message === 'rate_limited' || !token)) return showTokenPrompt(token ? errText(e) : '이 화면은 배포 출력에 나온 주소(…/?token=…)로 열어야 합니다. 토큰을 직접 넣을 수도 있습니다.');
if (MODE === 'admin' && (e.status === 401 || e.status === 403)) { $('#main').innerHTML = `<section class="card hint-token"><h2>관리자 로그인 필요</h2><p>이 화면은 관리자 페이지에서 로그인한 뒤 열 수 있습니다.</p><div class="actions"><a href="${BASE}admin"><button type="button" class="primary">관리자 페이지로</button></a></div></section>`; return; }
// CSP(script-src 'self')가 인라인 onclick 을 막으므로 단추는 코드로 묶습니다.
$('#main').innerHTML = `<section class="card"><h2>설정을 읽지 못했습니다</h2><p class="muted">${esc(errText(e))}</p><div class="actions"><button type="button" id="retry">다시 시도</button></div></section>`;
$('#retry').onclick = () => location.reload();
}
}
load();
})();