/** * AriMail 전체 설정 화면(1.0.7, 요구사항 2) · 메일 서비스 설정 화면(1.0.0, 요구사항 4·6). * * 1.0.7 다중 도메인: 전용 포트(token 방식)에서는 맨 위 "도메인" 카드가 서버의 모든 도메인을 보여 주고(추가·중지·삭제·수정), 고른 도메인(DOMAIN)의 설정을 * 아래 카드들이 다룹니다. 모든 API 호출에 ?domain=<도메인> 을 붙여 서버가 그 도메인의 설정 파일을 읽고 씁니다. admin 방식(/setup)은 자기 도메인만 다룹니다. * * 하는 일 * - /api/setup/config 로 현재 설정(비밀값은 가린 채)·출처·메타(호스트·도메인·웹훅 주소·Haraka 상태·DNS 레코드·발신 상태)를 읽어 카드로 그립니다. * - 카드마다 "저장" 이 바뀐 값만 PUT 합니다. 비밀값 입력 칸은 비워 두면 "그대로", "비우기" 를 누르면 '' 로 지웁니다. * - 수·발신 방식에 따라 필요한 카드만 보입니다(Resend / 외부 SMTP 중계 / 내장 Haraka). * - Haraka 카드: DKIM 키 생성과 DNS 레코드 안내(복사 단추), 서비스 상태(설치·실행·마지막 로그). * - 마무리 카드: 빠진 항목 점검표, "설치 완료로 표시", systemd 아래면 "서비스 재시작". * * 인증 방식(index.html 의 data-mode) * token : 전용 포트. 주소의 ?token= 을 세션 저장소에 두고(주소에서 지움) X-Setup-Token 헤더로 보냅니다. 없으면 토큰 입력 칸을 보입니다. * admin : 웹 메일 서버의 /setup. 관리자 세션 쿠키 + X-Mail-Scope: admin. 로그인이 없으면 /admin 으로 안내합니다. */ (() => { const MODE = document.documentElement.dataset.mode || 'token'; // BASE(1.0.8): 화면의 기준 경로(서버가 data-base 로 심음). 경로 접두사(/mail) 아래의 /setup 에서도 API·링크가 맞게 동작합니다. const BASE = (document.documentElement.dataset.base || '/').replace(/\/?$/, '/'); const $ = (s, el = document) => el.querySelector(s), $$ = (s, el = document) => [...el.querySelectorAll(s)]; const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); const TOKEN_KEY = 'arimail-setup-token'; let token = ''; let DOMAIN = '', DOMAINS = null; // 1.0.7: 고른 도메인과 도메인 목록 응답(token 방식에서만) let EDIT = null; // 도메인 카드의 수정 중인 항목(null 이면 추가 폼) if (MODE === 'token') { const u = new URL(location.href); const t = u.searchParams.get('token'); if (t) { try { sessionStorage.setItem(TOKEN_KEY, t); } catch { /* 저장소를 못 쓰면 메모리로만 */ } u.searchParams.delete('token'); history.replaceState(null, '', u.pathname + (u.search || '')); token = t; } else { try { token = sessionStorage.getItem(TOKEN_KEY) || ''; } catch { token = ''; } } } let toastTimer = null; function toast(msg, error = false) { const t = $('#toast'); t.textContent = msg; t.className = 'toast' + (error ? ' error' : ''); t.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { t.hidden = true; }, error ? 7000 : 3500); } /** API 호출. 인증 헤더는 방식에 따라 다르고, 실패는 Error(코드) 로 던집니다(detail·field 포함). */ async function api(path, method = 'GET', body) { const headers = { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(MODE === 'token' ? { 'X-Setup-Token': token } : { 'X-Mail-Scope': 'admin' }) }; // 1.0.7: 전용 포트에서는 고른 도메인을 ?domain= 으로 알립니다(도메인 등록부 API 자체는 예외). const q = MODE === 'token' && DOMAIN && !path.startsWith('domains') ? (path.includes('?') ? '&' : '?') + 'domain=' + encodeURIComponent(DOMAIN) : ''; const r = await fetch(BASE + 'api/setup/' + path + q, { method, headers, body: body ? JSON.stringify(body) : undefined, credentials: 'same-origin' }); let d = null; try { d = await r.json(); } catch { d = null; } if (!r.ok || !d || d.ok === false) { const e = new Error((d && d.error) || 'http_' + r.status); e.status = r.status; e.field = d && d.field; e.detail = d && d.detail; throw e; } return d; } /** 로그인 방식 이름표(도메인 표). */ const AUTH_LABEL = { local: '자체 계정', www: '웹 서비스 연동', webmaker: '웹메이커 연동' }; const ERRORS = { dns_not_configured: 'DNS 모듈 인증 정보가 없습니다', dns_bad_credentials: 'DNS 인증 정보 형식이 틀립니다', dns_auth_failed: 'DNS 서비스가 인증을 거부했습니다', dns_forbidden: 'DNS 서비스 권한이 없습니다', dns_zone_not_found: '도메인의 DNS 존을 찾지 못했습니다', dns_unavailable: 'DNS 서비스에 연결할 수 없습니다', setup_token_invalid: '접속 토큰이 맞지 않습니다. 배포 출력에 나온 주소(…?token=…)로 다시 여세요.', admin_login_required: '관리자 페이지에서 로그인한 뒤 다시 여세요.', rate_limited: '시도가 너무 많습니다. 15분 뒤 다시 하세요.', validation: '입력 값 형식이 맞지 않습니다', restart_unavailable: 'systemd 서비스로 돌고 있을 때만 재시작할 수 있습니다. 서버에서 직접 재시작하세요.', domain_exists: '이미 등록된 도메인입니다', host_taken: '다른 도메인이 쓰는 호스트 이름입니다', www_db_unreadable: '웹 서비스 DB 파일을 읽을 수 없습니다(경로·권한 확인)', webmaker_dir_unreadable: '웹메이커 데이터 디렉터리의 users.json 을 서비스 계정이 읽을 수 없습니다. "파일을 읽을 계정" 을 적고 deploy/webmaker-access.sh 를 실행하거나 읽기 권한을 주세요.', primary_domain: '기본 도메인은 지울 수 없습니다(환경 파일이 정합니다)', primary_only: '이 작업은 기본 도메인에서만 할 수 있습니다(서버 하나에 키 하나)', domain_not_found: '등록되지 않은 도메인입니다', server_error: '서버 오류가 났습니다', mail_not_configured: '연결 정보가 없습니다', mail_auth_failed: '인증(키·비밀번호)이 거부되었습니다', mail_forbidden: 'Resend 가 요청을 거부했습니다(403) — API 키 권한·도메인 소유 확인', mail_domain_unverified: '발신 도메인이 Resend 에서 검증되지 않았습니다 — 자동 설정 카드의 DNS 레코드 확인', mail_domain_missing: '발신 도메인이 Resend 에 등록되어 있지 않습니다 — 자동 설정 실행', mail_rate_limited: '서비스가 잠시 요청을 제한하고 있습니다', mail_rejected: '서비스가 요청을 거부했습니다', mail_provider_unavailable: '서비스에 연결할 수 없습니다', ok: '정상' }; const errText = (e) => (ERRORS[e.message] || e.message) + (e.field ? ` (${e.field})` : '') + (e.detail ? ` — ${e.detail}` : ''); const MODES = { inbound: { resend: ['Resend 웹훅 + 동기화', 'Resend 가 받은 메일을 웹훅으로 밀어 주고 1시간마다 목록을 맞춥니다(예전 방식). DNS 의 MX 는 Resend 를 가리킵니다.'], haraka: ['내장 Haraka 로 직접 수신', '이 서버의 25 포트가 메일을 받습니다. DNS 의 MX 를 이 서버로 바꾸고 방화벽에서 25 를 열어야 합니다.'] }, outbound: { resend: ['Resend API', 'Resend 발송 API 로 보냅니다(예전 방식). 도메인이 Resend 에 검증되어 있어야 합니다.'], haraka: ['내장 Haraka 로 직접 발송', '이 서버가 받는 쪽 메일 서버(MX)에 직접 배달합니다. SPF·DKIM DNS 레코드와 역방향 DNS(PTR)가 있어야 스팸으로 분류되지 않습니다.'], smtp: ['외부 SMTP 중계 서버', '회사 메일 서버나 다른 발송 서비스의 SMTP(587/465)로 넘깁니다. 아래 "SMTP 중계 서버" 카드에 접속 정보를 적으세요.'] }, }; const FIELD = { USAGE_API_ALLOW: ['접속 허용 IP', '웹메이커 서버의 IP 또는 CIDR(쉼표 구분, 예: 203.0.113.10, 10.0.0.0/8). 비우면 키만 맞으면 어디서나 허용합니다.'], RESEND_API_KEY: ['API 키', 'Resend → API Keys 에서 만든 전체 접근(Full access) 키. 서버에만 저장되고 사용자에게 노출되지 않습니다.'], RESEND_WEBHOOK_SECRET: ['웹훅 서명 비밀키', 'Resend → Webhooks → 아래 주소의 엔드포인트 → Signing Secret(whsec_…). 수신 방식이 Resend 일 때 필요합니다.'], SMTP_RELAY_HOST: ['서버 주소', '예: smtp.example.com'], SMTP_RELAY_PORT: ['포트', '587(STARTTLS) · 465(TLS) · 25'], SMTP_RELAY_SECURITY: ['암호화', 'starttls = 접속 뒤 STARTTLS · tls = 접속 즉시 TLS · none = 없음(내부망만)'], SMTP_RELAY_USER: ['로그인 ID', '비우면 인증 없이 보냅니다'], SMTP_RELAY_PASS: ['비밀번호', ''], HARAKA_PORT: ['수신 포트', '외부에서 메일을 받는 포트(표준 25). 방화벽에서 열어야 합니다.'], HARAKA_RELAY_PORT: ['제출 포트(내부)', '게이트웨이가 Haraka 에 발송을 넘기는 로컬 전용 포트(127.0.0.1). 외부에서 열지 않습니다.'], HARAKA_HOSTNAME: ['메일 서버 이름', 'EHLO·MX 에 쓰는 이름. 비우면 공개 호스트 이름(웹 메일 주소)과 같습니다. 이 이름의 A 레코드와 역방향(PTR)이 이 서버를 가리켜야 합니다.'], HARAKA_DKIM_SELECTOR: ['DKIM 셀렉터', 'DNS 레코드 이름의 앞부분(기본 arimail). 바꾸면 키를 다시 만들고 DNS 도 바꿔야 합니다.'], MAIL_FROM_NAME: ['발신 표시 이름', '계정에 이름이 없을 때 보낸 사람 이름으로 쓰는 값'], SITE_TITLE: ['브라우저 탭 제목', '비우면 서비스 이름(도메인 기준, 예: ARIHAKDANG 메일)'], MAILBOX_OWNER_LOGIN: ['기본 메일함 소유 www 관리자', 'www 연동일 때 admin 메일함을 가진 www 관리자(로그인 ID 또는 이메일)'], RESEND_REGION: ['Resend 지역', '도메인을 등록할 발송 지역. 수신 MX 값도 지역에 따라 다릅니다(한국은 ap-northeast-1 권장).'], DNS_PROVIDER: ['DNS 서비스', 'none = 자동 적용 없이 레코드만 안내 · google = Google Cloud DNS · route53 = AWS Route 53'], DNS_GOOGLE_CREDENTIALS: ['서비스 계정 JSON', 'Cloud DNS 관리자 역할을 가진 서비스 계정의 키 파일(JSON) 내용을 그대로 붙여 넣으세요.'], DNS_GOOGLE_PROJECT: ['프로젝트 ID', '비우면 JSON 의 project_id'], DNS_GOOGLE_ZONE: ['존 이름(선택)', '비우면 도메인으로 자동 검색'], DNS_AWS_ACCESS_KEY_ID: ['AWS Access Key ID', 'Route 53 변경 권한(ListHostedZonesByName, ChangeResourceRecordSets 등)이 있는 IAM 키'], DNS_AWS_SECRET_ACCESS_KEY: ['AWS Secret Access Key', ''], DNS_AWS_HOSTED_ZONE_ID: ['호스팅 존 ID(선택)', '비우면 도메인으로 자동 검색'], STATS_TIMEZONE: ['통계 시간대', '메일 현황(일·주·월)의 기준 시간대. 예: Asia/Seoul'], SYNC_INTERVAL_MIN: ['Resend 동기화 주기(분)', 'Resend 목록을 다시 훑는 간격(기본 60분)'], }; const SRC = { config: '설정 파일', env: '환경 파일 값(아직 설정 파일에 없음)', default: '기본값' }; let D = null; // 마지막으로 읽은 설정 응답 let PLAN = null, planBusy = false; // 자동 설정 계획(/api/setup/plan)과 실행 중 표시(1.0.5) const STATE = { ok: ['✅', '완료', 'ok'], todo: ['▶', '자동 실행 가능', ''], wait: ['⏳', '반영 대기', 'warn'], action: ['✍️', '사용자 작업', 'warn'], error: ['❌', '오류', 'warn'], info: ['ℹ️', '참고', 'off'], skip: ['—', '해당 없음', 'off'] }; /** 자동 설정 카드 HTML. PLAN 이 없으면 읽기 단추만. */ function planCard() { if (!PLAN) return `

자동 설정

고른 수·발신 방식과 도메인에 맞게 Resend 도메인·웹훅, DNS 레코드, 내장 Haraka 준비 상태를 진단하고 할 수 있는 일은 자동으로 합니다.

Resend·DNS 조회를 하므로 몇 초 걸립니다.
`; 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 ? `
${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('')}
` : ''; const btns = (s.actions || []).map((a) => ``).join('') + (s.auto && s.state === 'todo' ? `` : ''); return `
  • ${icon}
    ${esc(s.title)} ${esc(word)}
    ${s.detail ? `
    ${esc(s.detail)}
    ` : ''}${btns ? `
    ${btns}
    ` : ''}${inputsHtml}
  • `; }).join(''); const recs = (p.records || []).map((r) => `${r.check && r.check.ok ? '✅' : r.optional ? '◦' : '⚠️'}${esc(r.type)}${esc(r.name)}${esc((r.priority !== undefined ? r.priority + ' ' : '') + r.values.join(' / '))}${esc(r.purpose)}${r.check && !r.check.ok && r.check.found && r.check.found.length ? `
    지금 값: ${esc(r.check.found.join(' / '))}
    ` : ''}`).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 `

    자동 설정

    수신 ${esc(MODES.inbound[p.inbound][0])} · 발신 ${esc(MODES.outbound[p.outbound][0])} · 도메인 ${esc(p.domain)} · 메일 서버 이름 ${esc(p.host)}

    ${esc(p.readyMessage)}
    진단 ${esc(new Date(p.checkedAt).toLocaleString('ko-KR'))}
    ${planBusy ? '실행 중…' : '자동 실행 가능한 단계(▶)를 순서대로 처리하고 다시 진단합니다.'}

    필요한 DNS 레코드 (${esc(p.domain)})

    ${esc(dnsLine)} 공개 DNS(8.8.8.8 / 1.1.1.1) 기준으로 반영 여부를 봅니다.

    ${recs ? `${recs}
    종류이름값용도
    ` : '

    이 구성에는 추가할 DNS 레코드가 없습니다.

    '} ${PLAN.log && PLAN.log.length ? `

    마지막 실행 기록

    ${esc(PLAN.log.map((l) => `${l.id}: ${l.result}${l.detail ? ' — ' + l.detail : ''}`).join('\n'))}
    ` : ''}
    `; } /** * 도메인 카드(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 = [``, ``]; if (!d.primary) btns.push(``, ``); return `${esc(d.domain)}${d.primary ? ' 기본' : ''}${d.note ? `
    ${esc(d.note)}
    ` : ''}${d.hosts.map(esc).join('
    ')}${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}${d.operating && d.operating !== 'active' ? `
    운영 상태: ${esc({ migrating_out: '이전 중(보내는 쪽)', migrating_in: '이전 중(받는 쪽)', frozen: '동결' }[d.operating] || d.operating)}
    ` : ''}${d.ariweb ? `
    Ariweb: ${esc(d.ariweb.kind === 'site_bound' ? '사이트 대표 메일' : '독립 메일 서비스')}${d.ariweb.siteId ? ' · 사이트 ' + esc(d.ariweb.siteId) : ''}
    ` : ''}${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `
    ${esc(d.auth.wwwDbFile)}
    ` : ''}${d.auth.mode === 'webmaker' ? `
    ${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}
    ${d.external && d.external.error ? `
    읽기 실패: ${esc(d.external.error)}
    ` : ''}` : ''}${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `
    초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}
    ` : ''}${btns.join('')}`; }).join(''); const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary; const form = `

    ${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}

    ${editing ? '' : ``} ${isPrimaryEdit ? '' : ` ${editing ? '' : ` `}`}
    ${editing ? '' : ''}
    `; return `

    도메인 ${DOMAINS.domains.length}개

    이 서버가 메일을 다루는 도메인입니다. 도메인마다 계정·메일·설정이 따로 있고, 웹 메일은 각 호스트 이름으로, 메일 프로그램은 ${esc(DOMAINS.serverHost || '')} 에 아이디@도메인 으로 접속합니다. 아래 카드는 선택한 도메인의 설정입니다.

    ${rows}
    도메인웹 메일 호스트상태로그인수신 / 발신계정 · 메일
    ${form}
    `; } /** * 사용량 API 카드(1.0.9, 요구사항 1): 웹메이커 같은 외부 시스템이 /api/usage 로 디스크 사용량·발송량을 읽을 수 있게 비밀 키를 만들고(한 번만 표시), 허용 IP 를 정합니다. * 키는 서버 하나에 하나(기본 도메인 설정)이므로 기본 도메인을 보고 있을 때만 그립니다. 새로 만든 키는 USAGE_KEY_SHOWN 에 잠시 두어 화면에 한 번 보여 줍니다. */ let USAGE_KEY_SHOWN = null; function usageCard() { const m = D.meta, u = m.usageApi; if (!u || !m.primary) return ''; const v = D.values, s = D.sources; // 새 키는 이번 그리기에만 보이고 다음 그리기(다른 저장 등)부터는 사라집니다 — "지금만 보입니다" 그대로. const shown = USAGE_KEY_SHOWN; USAGE_KEY_SHOWN = null; const curl = `curl -H 'Authorization: Bearer <키>' '${u.endpoint}?days=30'`; return `

    사용량 API ${u.enabled ? '사용 중' : '꺼짐'}

    웹메이커 등 외부 관리 시스템이 도메인·계정별 디스크 사용량·메일 수·발송량(일/주/월·일별 추이)을 읽는 읽기 전용 API 입니다. 비밀 키(Bearer)와 접속 IP 제한으로 보호되며 메일 내용·비밀번호·API 키는 나가지 않습니다. 사용 방법은 저장소의 docs/USAGE-API.md.

    주소
    ${esc(u.endpoint)} (도메인 하나: /api/usage/<도메인>)
    키
    ${u.enabled ? `${esc(u.keyMasked)}${u.updatedAt ? ` (${esc(new Date(u.updatedAt).toLocaleString('ko-KR'))} 생성)` : ''}` : '없음 — 아래 단추로 만들면 API 가 켜집니다'}
    ${shown ? `
    새 키(지금만 보입니다 — 웹메이커 설정에 복사해 두세요):
    ${esc(shown)}

    예: ${esc(curl.replace('<키>', shown))}

    ` : ''}
    ${u.enabled ? '' : ''}
    ${field('USAGE_API_ALLOW', { value: v.USAGE_API_ALLOW || '', source: s.USAGE_API_ALLOW, placeholder: '203.0.113.10, 10.0.0.0/8' })}
    `; } /** DNS 모듈 카드(요구사항 1): 서비스 선택과 인증 정보, 연결 확인. */ function dnsCard() { const v = D.values, s = D.sources, prov = v.DNS_PROVIDER || 'none'; return `

    DNS 모듈

    도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.

    ${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 }) : ''}
    ${prov !== 'none' ? `` : ''}
    `; } function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) { const [label, help] = FIELD[key] || [key, '']; // 1.1.0 (HAL 0.4.0 대응): HAL 이 관리하는 항목은 잠그고 표시합니다(저장해도 서버가 409 로 거부). 잠긴 칸은 collect 가 건너뜁니다. const halKeys = (typeof D !== 'undefined' && D && D.halManaged && D.halManaged.keys) || []; if (halKeys.includes(key)) return ``; const src = `${esc(SRC[source] || source)}`; if (secret && multiline) return ``; if (secret) return ``; if (options) return ``; return ``; } const tag = (ok, onText, offText, cls = ok ? 'ok' : 'warn') => `${esc(ok ? onText : offText)}`; const statusTag = (st) => !st ? tag(false, '', '확인 중') : st.provider === 'ok' ? tag(true, '정상', '') : `${esc(ERRORS[st.provider] || st.provider)}`; /** 전체 화면 그리기. D(설정 응답)를 기준으로 카드를 만들고 이벤트를 붙입니다. */ function render() { const m = D.meta, v = D.values, s = D.sources, p = D.present; $('#top-meta').textContent = `${m.host} · ${m.mailbox} · v${m.version || ''}${MODE === 'token' && DOMAINS && DOMAINS.domains.length > 1 ? ` · 도메인 ${DOMAINS.domains.length}개` : ''}`; $('#top-links').innerHTML = `웹 메일 ↗관리자 페이지 ↗`; const h = m.haraka || {}, hs = h.status || {}; const harakaTag = !m.usesHaraka ? tag(false, '', '사용 안 함', 'off') : !h.installed ? tag(false, '', '미설치') : !h.status || h.stale ? tag(false, '', '서비스 꺼짐') : hs.running ? tag(true, '실행 중', '') : tag(false, '', '시작 대기'); const choices = (name, current) => `
    ${Object.entries(MODES[name]).map(([k, [t, d]]) => ``).join('')}
    `; const missing = checklist(D); const hal = D.halManaged && D.halManaged.managed ? `
    HAL 관리 — 이 서버의 다음 설정은 Ariweb HAL 통합 설정(HAL ${esc(D.halManaged.halCompat || '')})이 관리합니다: ${D.halManaged.keys.map(esc).join(', ')}. 여기서는 바꿀 수 없고 HAL 운영자 콘솔 › 통합 설정에서 바꿉니다. 도메인별 설정은 그대로 여기서 관리합니다.${D.halManaged.appliedAt ? `
    마지막 적용 ${esc(D.halManaged.appliedAt)}${(D.halManaged.errors || []).length ? ` · 적용 오류: ${D.halManaged.errors.map((x) => esc(x.key)).join(', ')}` : ''}
    ` : ''}
    ` : ''; // 1.1.0 $('#main').innerHTML = ` ${hal}${domainsCard()}

    현재 상태${MODE === 'token' ? ` — ${esc(m.domain)}${m.primary ? ' 기본 도메인' : ''}` : ''}

    수신
    ${esc(MODES.inbound[m.inbound][0])}
    발신
    ${esc(MODES.outbound[m.outbound][0])}
    발신 서비스 연결
    ${statusTag(m.outboundStatus)}
    ${m.outboundStatus && m.outboundStatus.detail ? `
    ${esc(m.outboundStatus.detail)}
    ` : ''}${m.outboundStatus && m.outboundStatus.domain ? `
    Resend 도메인 ${esc(m.outboundStatus.domain.name)}: ${esc(m.outboundStatus.domain.status)}
    ` : ''}
    내장 Haraka
    ${harakaTag}
    설정 파일
    ${esc(D.file)}
    ${D.updatedAt ? '마지막 저장 ' + esc(new Date(D.updatedAt).toLocaleString('ko-KR')) : ''}${D.importedFromEnv && D.importedFromEnv.length ? ` · 환경 파일에서 가져온 값 ${D.importedFromEnv.length}개` : ''}
    초기 설정
    ${tag(v.SETUP_DONE === true, '완료 표시됨', '진행 중', v.SETUP_DONE === true ? 'ok' : '')}

    메일을 받고 보내는 방식

    받는 길과 보내는 길을 따로 고를 수 있습니다. 예: 수신은 내장 Haraka, 발신은 Resend. 저장하면 바로 적용되며, Haraka 를 고르면 별도 서비스(haraka)가 설정을 보고 스스로 뜹니다.

    수신

    ${choices('inbound', m.inbound)}

    발신

    ${choices('outbound', m.outbound)}
    ${planCard()} ${dnsCard()} ${usageCard()} ${m.usesResend ? `

    Resend

    Resend 를 ${m.inbound === 'resend' && m.outbound === 'resend' ? '수신과 발신에' : m.inbound === 'resend' ? '수신에' : '발신에'}씁니다. 도메인 ${esc(m.domain)} 이 Resend 에 등록·검증되어 있어야 합니다.

    ${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 }) : ''}
    ${m.inbound === 'resend' ? `
    웹훅 엔드포인트: ${esc(m.webhookUrl)} · 이벤트 email.received
    ` : ''}
    ` : ''} ${m.outbound === 'smtp' ? `

    SMTP 중계 서버

    보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(${esc(m.domain)})를 그 서버가 허용해야 합니다.

    ${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 })}
    ` : ''} ${m.usesHaraka ? `

    내장 Haraka

    Haraka 는 이 서버에서 함께 도는 메일 서버(MTA)입니다. 게이트웨이와는 별도 systemd 서비스로 돌며, 여기 설정을 보고 자기 설정 파일을 만들어 스스로 켜고 끕니다.

    ${!h.installed ? `
    Haraka 가 설치되어 있지 않습니다. 서버에서 한 번 실행하세요(root): sudo bash ${esc((h.dir || '').replace(/\/haraka$/, ''))}/repo/deploy/haraka-apply.sh — 서비스 등록과 npm 설치(Haraka ${esc(hs.version || '3.x')})를 합니다. 설치 뒤 이 화면을 새로 고치세요.
    ` : !h.status || h.stale ? `
    Haraka 관리 서비스가 돌고 있지 않습니다. sudo systemctl start <서비스 이름>-haraka 또는 deploy/haraka-apply.sh 를 다시 실행하세요.
    ` : hs.running ? `
    실행 중 (pid ${esc(hs.pid)}, ${esc(hs.startedAt ? new Date(hs.startedAt).toLocaleString('ko-KR') : '')} 부터) · 대기 포트: ${esc((hs.listen || []).join(', '))}${hs.restarts ? ` · 재시작 ${esc(hs.restarts)}회` : ''}
    ` : `
    ${hs.enabled ? '시작을 준비하고 있습니다…' : '설정이 저장되면 몇 초 안에 시작합니다.'}${hs.lastExit ? ` 마지막 종료: 코드 ${esc(hs.lastExit.code)} (${esc(hs.lastExit.at)})` : ''}
    `} ${m.primary ? '' : `
    Haraka 는 서버에 하나이므로 수신 포트·제출 포트·메일 서버 이름은 기본 도메인의 설정을 따릅니다. 이 도메인에서는 DKIM 셀렉터만 정합니다.
    `}
    ${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 })}

    DNS 와 발신 준비

    ${m.inbound === 'haraka' ? 'MX 를 이 서버로 바꾸면 그때부터 메일이 이 서버로 옵니다(이전 MX 의 메일은 더 오지 않습니다). ' : ''}${m.outbound === 'haraka' ? 'SPF·DKIM 이 없으면 보낸 메일이 스팸으로 분류되기 쉽습니다. 서버 IP 의 역방향 DNS(PTR)가 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요. ' : ''}필요한 레코드와 반영 상태는 위 "자동 설정" 카드에 있습니다.

    ${m.outbound === 'haraka' ? `
    ${p.HARAKA_DKIM_PRIVATE ? `DKIM 키 있음` : `자동 설정 실행이 대신 만들어 줍니다`}
    ` : ''}

    서비스 로그(최근)

    ${hs.lastLog && hs.lastLog.length ? `
    ${esc(hs.lastLog.join('\n'))}
    ` : `

    아직 기록이 없습니다. 서버에서 journalctl -u <서비스 이름>-haraka -f 로 볼 수 있습니다.

    `}
    ` : ''}

    사이트 정보

    배포에는 필요 없지만 운영 중 바꿀 수 있는 값입니다.

    ${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 }) : ''}

    마무리

    ${v.SETUP_DONE === true ? `` : ``}${m.systemd ? `` : ''}저장한 설정은 재시작 없이 적용됩니다. 재시작은 포트(SETUP_PORT 등 환경 파일) 변경 뒤에만 필요합니다.

    ${MODE === 'token' ? '이 화면은 전용 포트에서 토큰으로 열었습니다. 운영 중에는 관리자 페이지(/admin) → "메일 서비스 설정" 으로도 같은 화면을 열 수 있습니다.' : '이 화면은 관리자 세션으로 열었습니다. 설치 직후에는 배포 출력에 나온 전용 포트 주소(…:' + esc(m.setupPort) + '/?token=…)로도 열 수 있습니다.'}

    `; bind(); } /** 점검표: 고른 방식에 꼭 필요한 값이 있는지. */ function checklist(d) { const m = d.meta, v = d.values, p = d.present, out = []; if (m.usesResend) { out.push({ ok: !!v.RESEND_API_KEY, required: true, text: 'Resend API 키' + (v.RESEND_API_KEY ? '' : ' — 없음') }); if (m.inbound === 'resend') out.push({ ok: !!v.RESEND_WEBHOOK_SECRET, required: true, text: 'Resend 웹훅 서명 비밀키' + (v.RESEND_WEBHOOK_SECRET ? '' : ' — 없음(웹훅 거부, 1시간 동기화만 동작)') }); } if (m.outbound === 'smtp') out.push({ ok: !!v.SMTP_RELAY_HOST, required: true, text: 'SMTP 중계 서버 주소' + (v.SMTP_RELAY_HOST ? ` — ${v.SMTP_RELAY_HOST}:${v.SMTP_RELAY_PORT}` : ' — 없음') }); if (m.usesHaraka) { const h = m.haraka || {}; out.push({ ok: !!h.installed, required: true, text: 'Haraka 설치' + (h.installed ? '' : ' — deploy/haraka-apply.sh 실행 필요') }); out.push({ ok: !!(h.status && !h.stale && h.status.running), required: true, text: 'Haraka 실행' }); if (m.outbound === 'haraka') out.push({ ok: !!p.HARAKA_DKIM_PRIVATE, required: false, text: 'DKIM 키' + (p.HARAKA_DKIM_PRIVATE ? '(DNS 에 TXT 레코드를 올렸는지 확인)' : ' — 아직 만들지 않음') }); if (m.inbound === 'haraka') out.push({ ok: true, required: false, text: `DNS MX → ${m.harakaHost} 로 바꾸고 방화벽 ${v.HARAKA_PORT || 25} 포트 열기(수동 확인)` }); } // 1.0.11: 서버가 만든 원인 문장(detail — 예: 어느 DNS 레코드 값이 다른지)을 함께 보입니다. if (m.outboundStatus) out.push({ ok: m.outboundStatus.provider === 'ok', required: false, text: '발신 서비스 연결 확인' + (m.outboundStatus.provider === 'ok' ? '' : ' — ' + (ERRORS[m.outboundStatus.provider] || m.outboundStatus.provider) + (m.outboundStatus.detail ? ` (${m.outboundStatus.detail})` : '')) }); return out; } /** 폼에서 바뀐 값만 모읍니다. 비밀 칸은 입력했을 때만, "비우기" 를 눌렀으면 '' 로. */ function collect(form) { const patch = {}; for (const el of form.elements) { if (el.disabled) continue; // 1.1.0: HAL 관리 칸 if (!el.name) continue; if (el.type === 'radio') { if (el.checked) patch[el.name] = el.value; continue; } if (el.dataset.secret) { if (el.dataset.clear === '1') patch[el.name] = ''; else if (el.value) patch[el.name] = el.value; continue; } if (el.name === 'SYNC_INTERVAL_MIN') { patch.SYNC_INTERVAL_MS = String(Math.max(1, Number(el.value) || 60) * 60000); continue; } patch[el.name] = el.value; } return patch; } async function save(form, patch) { const btn = $('button[type=submit]', form), st = $('.status', form); if (btn) btn.disabled = true; if (st) st.textContent = '저장 중…'; try { const before = D.meta.inbound + D.meta.outbound; D = await api('config', 'PUT', { values: patch }); if (PLAN && (before !== D.meta.inbound + D.meta.outbound || patch.DNS_PROVIDER !== undefined)) PLAN = null; toast('저장했습니다'); render(); } catch (e) { if (st) st.textContent = errText(e); toast(errText(e), true); if (btn) btn.disabled = false; } } /** 계획 API 호출 뒤 카드 다시 그리기. */ async function planCall(fn) { planBusy = true; render(); try { const r = await fn(); PLAN = r.plan; PLAN.log = r.log || []; D = await api('config'); } catch (e) { toast(errText(e), true); } finally { planBusy = false; render(); if ($('#card-plan')) $('#card-plan').scrollIntoView({ block: 'start', behavior: 'smooth' }); } } /** 도메인 카드의 동작(1.0.7): 선택·수정·중지/사용·삭제·추가. 목록을 다시 읽고 화면을 그립니다. */ function bindDomains() { const f = $('#f-domain'); if (!f) return; const refresh = async () => { DOMAINS = await api('domains'); if (!DOMAINS.domains.some((d) => d.domain === DOMAIN)) DOMAIN = DOMAINS.primary; D = await api('config'); PLAN = null; render(); }; // 기본 도메인을 수정할 때는 로그인 방식 칸이 없습니다(환경 파일이 정함) — 없으면 건너뜁니다. const syncAuth = () => { if (!f.elements.authMode) return; const mode = f.elements.authMode.value; $$('.www-only', f).forEach((el) => { el.hidden = mode !== 'www'; }); $$('.webmaker-only', f).forEach((el) => { el.hidden = mode !== 'webmaker'; }); $$('.local-only', f).forEach((el) => { el.hidden = mode !== 'local'; }); }; if (f.elements.authMode) f.elements.authMode.onchange = syncAuth; syncAuth(); f.onsubmit = async (e) => { e.preventDefault(); const st = $('.status', f), btn = $('button[type=submit]', f); btn.disabled = true; st.textContent = '처리 중…'; const body = { hosts: f.elements.hosts.value, note: f.elements.note.value, ...(f.elements.authMode ? { auth: { mode: f.elements.authMode.value, wwwDbFile: f.elements.wwwDbFile ? f.elements.wwwDbFile.value : '', ownerLogin: f.elements.ownerLogin ? f.elements.ownerLogin.value : '', webmakerDir: f.elements.webmakerDir ? f.elements.webmakerDir.value : '', webmakerUser: f.elements.webmakerUser ? f.elements.webmakerUser.value : '', siteId: f.elements.siteId ? f.elements.siteId.value : '' } } : {}) }; try { if (EDIT) { await api('domains/' + encodeURIComponent(EDIT.domain), 'PATCH', body); toast('도메인 정보를 저장했습니다'); EDIT = null; } else { const r = await api('domains', 'POST', { ...body, domain: f.elements.domain.value.trim().toLowerCase(), mailbox: f.elements.mailbox.value.trim().toLowerCase(), adminPassword: f.elements.adminPassword.value, copyFromPrimary: f.elements.copyFromPrimary.value === '1' }); DOMAIN = r.domain.domain; toast(r.initialAdmin ? `도메인 ${r.domain.domain} 을 추가했습니다. 초기 관리자 ${r.initialAdmin.login} / ${r.initialAdmin.password}` : `도메인 ${r.domain.domain} 을 추가했습니다`); } await refresh(); } catch (err) { st.textContent = errText(err); toast(errText(err), true); btn.disabled = false; } }; if ($('#domain-cancel')) $('#domain-cancel').onclick = () => { EDIT = null; render(); }; $$('[data-domain-select]').forEach((b) => { b.onclick = async () => { DOMAIN = b.dataset.domainSelect; EDIT = null; try { D = await api('config'); PLAN = null; render(); } catch (err) { toast(errText(err), true); } }; }); $$('[data-domain-edit]').forEach((b) => { b.onclick = () => { EDIT = DOMAINS.domains.find((d) => d.domain === b.dataset.domainEdit) || null; render(); $('#f-domain').scrollIntoView({ block: 'start', behavior: 'smooth' }); }; }); $$('[data-domain-toggle]').forEach((b) => { b.onclick = async () => { const on = b.dataset.enabled === '1'; if (on && !confirm(`${b.dataset.domainToggle} 의 메일 서비스를 중지합니다. 웹 메일·메일 프로그램 로그인과 수신이 모두 막히고 화면에는 "활성화되지 않았습니다" 가 보입니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainToggle), 'PATCH', { enabled: !on }); toast(on ? '중지했습니다' : '다시 사용합니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; }); $$('[data-domain-remove]').forEach((b) => { b.onclick = async () => { if (!confirm(`${b.dataset.domainRemove} 을 등록에서 지웁니다. 데이터 디렉터리(domains/${b.dataset.domainRemove})는 서버에 남으며, 지우려면 서버에서 직접 삭제합니다. 계속할까요?`)) return; try { await api('domains/' + encodeURIComponent(b.dataset.domainRemove), 'DELETE'); toast('등록을 지웠습니다'); await refresh(); } catch (err) { toast(errText(err), true); } }; }); } function bind() { bindDomains(); $$('form:not(.plan-input):not(#f-domain)').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); save(f, collect(f)); }; }); // 자동 설정(1.0.5): 진단·실행·단계 단추·입력 저장 if ($('#plan-load')) $('#plan-load').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: [] })); if ($('#plan-reload')) $('#plan-reload').onclick = () => planCall(async () => ({ plan: (await api('plan')).plan, log: PLAN && PLAN.log })); if ($('#plan-run')) $('#plan-run').onclick = () => planCall(() => api('plan/run', 'POST', {})); $$('[data-plan-run]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.planRun })); }); $$('[data-plan-action]').forEach((b) => { b.onclick = () => planCall(() => api('plan/run', 'POST', { step: b.dataset.step, action: b.dataset.planAction })); }); $$('form.plan-input').forEach((f) => { f.onsubmit = (e) => { e.preventDefault(); planCall(() => api('plan/run', 'POST', { step: f.dataset.step, input: collect(f) })); }; }); // 사용량 API 키(1.0.9): 만들기(교체) / 삭제. 만든 키는 응답에만 있으므로 화면에 한 번 보여 주고 다음 그리기부터는 가린 값만 남습니다. if ($('#usage-generate')) $('#usage-generate').onclick = async (e) => { const btn = e.currentTarget; if (D.meta.usageApi.enabled && !confirm('기존 키는 바로 무효가 됩니다. 웹메이커 쪽 설정도 새 키로 바꿔야 합니다. 계속할까요?')) return; btn.disabled = true; try { const r = await api('usage-key', 'POST', { action: 'generate' }); USAGE_KEY_SHOWN = r.key; D = await api('config'); render(); toast('사용량 API 키를 만들었습니다 — 지금 복사하세요'); $('#card-usage').scrollIntoView({ block: 'start', behavior: 'smooth' }); } catch (err) { toast(errText(err), true); btn.disabled = false; } }; if ($('#usage-revoke')) $('#usage-revoke').onclick = async () => { if (!confirm('사용량 API 키를 지워 API 를 끕니다. 웹메이커의 사용량 조회가 멈춥니다. 계속할까요?')) return; try { await api('usage-key', 'POST', { action: 'revoke' }); USAGE_KEY_SHOWN = null; D = await api('config'); render(); toast('키를 지웠습니다'); } catch (err) { toast(errText(err), true); } }; if ($('#dns-probe')) $('#dns-probe').onclick = async (e) => { e.currentTarget.disabled = true; try { const r = await api('dns/probe', 'POST', {}); toast(r.probe.ok ? `연결 정상 — 존 ${r.probe.zone}` : (ERRORS[r.probe.error] || r.probe.error) + (r.probe.detail ? ' — ' + r.probe.detail : ''), !r.probe.ok); } catch (err) { toast(errText(err), true); } finally { e.target.disabled = false; } }; $$('.choice input').forEach((r) => { r.onchange = () => { $$('.choice', r.closest('.choices')).forEach((c) => c.classList.toggle('selected', $('input', c).checked)); }; }); $$('[data-clear]').forEach((b) => { b.onclick = () => { const input = $(`input[name="${b.dataset.clear}"]`, b.closest('form')); input.dataset.clear = input.dataset.clear === '1' ? '' : '1'; input.value = ''; input.placeholder = input.dataset.clear === '1' ? '(저장하면 지워집니다)' : '(그대로 둠)'; b.textContent = input.dataset.clear === '1' ? '취소' : '비우기'; }; }); $$('[data-copy]').forEach((b) => { b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); toast('복사했습니다'); } catch { prompt('복사:', b.dataset.copy); } }; }); const check = async (btn, what) => { btn.disabled = true; try { const r = await api('check', 'POST', { what }); if (what === 'outbound') { D.meta.outboundStatus = r.outboundStatus; toast(r.outboundStatus.provider === 'ok' ? '연결 정상' : errText(new Error(r.outboundStatus.provider)) + (r.outboundStatus.detail ? ' — ' + r.outboundStatus.detail : ''), r.outboundStatus.provider !== 'ok'); } else { D.meta.haraka = r.haraka; } render(); } catch (e) { toast(errText(e), true); btn.disabled = false; } }; for (const id of ['check-outbound', 'check-resend', 'check-smtp']) if ($('#' + id)) $('#' + id).onclick = (e) => check(e.currentTarget, 'outbound'); if ($('#check-haraka')) $('#check-haraka').onclick = (e) => check(e.currentTarget, 'haraka'); const dkim = async (force) => { if (force && !confirm('DKIM 키를 다시 만들면 DNS 의 TXT 레코드도 새 값으로 바꿔야 합니다. 계속할까요?')) return; try { await api('dkim', 'POST', { force }); toast('DKIM 키를 만들었습니다. DNS 레코드를 올리세요.'); D = await api('config'); render(); } catch (e) { toast(errText(e), true); } }; if ($('#dkim-gen')) $('#dkim-gen').onclick = () => dkim(false); if ($('#dkim-regen')) $('#dkim-regen').onclick = () => dkim(true); if ($('#setup-done')) $('#setup-done').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: true } }); toast('완료로 표시했습니다'); render(); } catch (e) { toast(errText(e), true); } }; if ($('#setup-undone')) $('#setup-undone').onclick = async () => { try { D = await api('config', 'PUT', { values: { SETUP_DONE: false } }); render(); } catch (e) { toast(errText(e), true); } }; if ($('#restart')) $('#restart').onclick = async () => { if (!confirm('서비스를 재시작합니다. 몇 초 동안 접속이 끊깁니다.')) return; try { await api('restart', 'POST', {}); toast('재시작 중… 10초 뒤 화면을 다시 읽습니다'); setTimeout(() => location.reload(), 10000); } catch (e) { toast(errText(e), true); } }; } function showTokenPrompt(message) { $('#main').innerHTML = `

    접속 토큰

    ${esc(message)}

    토큰은 서버의 데이터 디렉터리 setup-token 파일에 있습니다(배포 계정만 읽을 수 있음). 배포 스크립트가 끝에 전체 주소를 출력합니다.

    `; $('#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 = `

    관리자 로그인 필요

    이 화면은 관리자 페이지에서 로그인한 뒤 열 수 있습니다.

    `; return; } // CSP(script-src 'self')가 인라인 onclick 을 막으므로 단추는 코드로 묶습니다. $('#main').innerHTML = `

    설정을 읽지 못했습니다

    ${esc(errText(e))}

    `; $('#retry').onclick = () => location.reload(); } } load(); })();