313 lines
57 KiB
JavaScript
313 lines
57 KiB
JavaScript
/**
|
||||
|
|
* 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 `<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, '', '중지')}</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.<도메인>. 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 <디렉터리></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/<도메인></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 <서비스 이름>-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 <서비스 이름>-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();
|
|||
|
|
})();
|