if(t){try{sessionStorage.setItem(TOKEN_KEY,t);}catch{/* 저장소를 못 쓰면 메모리로만 */}u.searchParams.delete('token');history.replaceState(null,'',u.pathname+(u.search||''));token=t;}
constAUTH_LABEL={local:'자체 계정',www:'웹 서비스 연동',webmaker:'웹메이커 연동'};
constERRORS={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:'정상'};
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 중계 서버" 카드에 접속 정보를 적으세요.']},
};
constFIELD={
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분)'],
};
constSRC={config:'설정 파일',env:'환경 파일 값(아직 설정 파일에 없음)',default:'기본값'};
letD=null;// 마지막으로 읽은 설정 응답
letPLAN=null,planBusy=false;// 자동 설정 계획(/api/setup/plan)과 실행 중 표시(1.0.5)
constSTATE={ok:['✅','완료','ok'],todo:['▶','자동 실행 가능',''],wait:['⏳','반영 대기','warn'],action:['✍️','사용자 작업','warn'],error:['❌','오류','warn'],info:['ℹ️','참고','off'],skip:['—','해당 없음','off']};
/** 자동 설정 카드 HTML. PLAN 이 없으면 읽기 단추만. */
functionplanCard(){
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>`;
constdnsLine=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="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)을 바꾸고, "중지/사용" 은 그 도메인의 모든 접속(웹·메일 프로그램·수신)을 막거나 엽니다.
constform=`<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>`}`}
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>
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="item"><div class="k">키</div><div class="v">${u.enabled?`<code>${esc(u.keyMasked)}</code>${u.updatedAt?` <span class="muted">(${esc(newDate(u.updatedAt).toLocaleString('ko-KR'))} 생성)</span>`:''}`:'<span class="muted">없음 — 아래 단추로 만들면 API 가 켜집니다</span>'}</div></div></div>
return`<section class="card step"><h2>DNS 모듈</h2><p class="intro">도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.</p>
consthal=D.halManaged&&D.halManaged.managed?`<div class="card"><b>HAL 관리</b> — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(D.halManaged.halCompat||'')})이 관리합니다: <code>${D.halManaged.keys.map(esc).join(', ')}</code>. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다. 도메인별 설정은 그대로 여기서 관리합니다.${D.halManaged.appliedAt?`<div class="meta muted">마지막 적용 ${esc(D.halManaged.appliedAt)}${(D.halManaged.errors||[]).length?` · 적용 오류: ${D.halManaged.errors.map((x)=>esc(x.key)).join(', ')}`:''}</div>`:''}</div>`:'';// 1.1.0
<div class="item"><div class="k">설정 파일</div><div class="v"><code>${esc(D.file)}</code></div><div class="meta muted">${D.updatedAt?'마지막 저장 '+esc(newDate(D.updatedAt).toLocaleString('ko-KR')):''}${D.importedFromEnv&&D.importedFromEnv.length?` · 환경 파일에서 가져온 값 ${D.importedFromEnv.length}개`:''}</div></div>
<section class="card step"><h2>메일을 받고 보내는 방식</h2><p class="intro">받는 길과 보내는 길을 따로 고를 수 있습니다. 예: 수신은 내장 Haraka, 발신은 Resend. 저장하면 바로 적용되며, Haraka 를 고르면 별도 서비스(haraka)가 설정을 보고 스스로 뜹니다.</p>
${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>
${m.outbound==='smtp'?`<section class="card step"><h2>SMTP 중계 서버</h2><p class="intro">보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(<code>${esc(m.domain)}</code>)를 그 서버가 허용해야 합니다.</p>
${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?newDate(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>`}
<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>
<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();
}
/** 점검표: 고른 방식에 꼭 필요한 값이 있는지. */
functionchecklist(d){
constm=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){consth=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})`:''))});
returnout;
}
/** 폼에서 바뀐 값만 모읍니다. 비밀 칸은 입력했을 때만, "비우기" 를 눌렀으면 '' 로. */
toast(r.initialAdmin?`도메인 ${r.domain.domain} 을 추가했습니다. 초기 관리자 ${r.initialAdmin.login} / ${r.initialAdmin.password}`:`도메인 ${r.domain.domain} 을 추가했습니다`);
$$('[data-domain-toggle]').forEach((b)=>{b.onclick=async()=>{conston=b.dataset.enabled==='1';if(on&&!confirm(`${b.dataset.domainToggle} 의 메일 서비스를 중지합니다. 웹 메일·메일 프로그램 로그인과 수신이 모두 막히고 화면에는 "활성화되지 않았습니다" 가 보입니다. 계속할까요?`))return;try{awaitapi('domains/'+encodeURIComponent(b.dataset.domainToggle),'PATCH',{enabled:!on});toast(on?'중지했습니다':'다시 사용합니다');awaitrefresh();}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{awaitapi('domains/'+encodeURIComponent(b.dataset.domainRemove),'DELETE');toast('등록을 지웠습니다');awaitrefresh();}catch(err){toast(errText(err),true);}};});
// 사용량 API 키(1.0.9): 만들기(교체) / 삭제. 만든 키는 응답에만 있으므로 화면에 한 번 보여 주고 다음 그리기부터는 가린 값만 남습니다.
if($('#usage-generate'))$('#usage-generate').onclick=async(e)=>{constbtn=e.currentTarget;if(D.meta.usageApi.enabled&&!confirm('기존 키는 바로 무효가 됩니다. 웹메이커 쪽 설정도 새 키로 바꿔야 합니다. 계속할까요?'))return;btn.disabled=true;try{constr=awaitapi('usage-key','POST',{action:'generate'});USAGE_KEY_SHOWN=r.key;D=awaitapi('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{awaitapi('usage-key','POST',{action:'revoke'});USAGE_KEY_SHOWN=null;D=awaitapi('config');render();toast('키를 지웠습니다');}catch(err){toast(errText(err),true);}};
if($('#dns-probe'))$('#dns-probe').onclick=async(e)=>{e.currentTarget.disabled=true;try{constr=awaitapi('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;}};
constdkim=async(force)=>{if(force&&!confirm('DKIM 키를 다시 만들면 DNS 의 TXT 레코드도 새 값으로 바꿔야 합니다. 계속할까요?'))return;try{awaitapi('dkim','POST',{force});toast('DKIM 키를 만들었습니다. DNS 레코드를 올리세요.');D=awaitapi('config');render();}catch(e){toast(errText(e),true);}};
if($('#restart'))$('#restart').onclick=async()=>{if(!confirm('서비스를 재시작합니다. 몇 초 동안 접속이 끊깁니다.'))return;try{awaitapi('restart','POST',{});toast('재시작 중… 10초 뒤 화면을 다시 읽습니다');setTimeout(()=>location.reload(),10000);}catch(e){toast(errText(e),true);}};
}
functionshowTokenPrompt(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>`;
if(MODE==='token'&&(e.message==='setup_token_invalid'||e.message==='rate_limited'||!token))returnshowTokenPrompt(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 을 막으므로 단추는 코드로 묶습니다.