Files
AriWeb-Suite/portal/mail/ui.js
T

129 lines
40 KiB
JavaScript

/* portal/mail/ui.js — 메일 영역 화면 스크립트(서버 틀 + /api/mail/* JSON). 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음을 aw.state 로 보인다. 360px 에서 표는 .r 로 카드처럼 접힌다. */
(() => {
const { api, esc, state, toast, confirmDanger, poll, job, fmt } = window.aw;
const root = document.querySelector('[data-view]'); if (!root) return;
const view = root.dataset.view; const B = (n) => (n >= 1024 ** 3 ? (n / 1024 ** 3).toFixed(1) + ' GB' : n >= 1024 ** 2 ? (n / 1024 ** 2).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : (n || 0) + ' B');
const N = (n) => Number(n || 0).toLocaleString('ko-KR');
const bar = (used, limit, level) => `<div class="bar w${level || 0}"><i style="width:${limit ? Math.min(100, (used / limit) * 100) : 0}%"></i></div>`;
const badge = (kind, label) => `<span class="badge ${kind}">${{ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' }[kind] || '○'} ${esc(label)}</span>`;
const stBadge = (s) => badge(s === 'active' ? 'ok' : ['cancelled', 'deleted'].includes(s) ? 'failed' : s === 'activating' ? 'running' : 'warning', { active: '운영 중', activating: '활성화 중', needs_setup: '설정 필요', cancelled: '읽기 전용 보존', deleted: '삭제 완료', suspended: '일시 중지' }[s] || s);
const lvlBadge = (l) => (l >= 100 ? badge('failed', '100% 제한') : l >= 90 ? badge('failed', l + '% 경고') : l >= 70 ? badge('warning', l + '% 경고') : badge('ok', '정상'));
const fail = (el, e) => state(el, e.status === 403 ? 'forbidden' : e.code === 'service_unreachable' ? 'stale' : 'failed', e.message, { retry: () => location.reload() });
const copy = (v) => `<span class="copy mono" data-action="copy" data-copy="${esc(v)}" title="복사">${esc(v)}</span>`;
const jobActions = async (a, j, id) => { try { await api('POST', `/api/mail/services/${id}/job/${a}`); toast('요청했습니다'); } catch (e) { toast(e.message); } };
// ---------------------------------------------------------------- 목록(§15.2)
if (view === 'list') (async () => {
try { const d = await api('GET', '/api/mail/services'); if (!d.services.length) return state(root, 'empty', '메일 서비스가 없습니다. 사이트 대표 메일 또는 독립 메일 서비스를 만드세요', { href: '/mail/new', hrefLabel: '메일 서비스 만들기' });
root.innerHTML = `<table class="r"><thead><tr><th>서비스</th><th>종류 · 사이트</th><th>대표 메일 도메인</th><th>상태</th><th>메일함</th><th>저장 공간</th><th>송수신</th><th>DNS 보안</th><th>최근 경고</th></tr></thead><tbody>${d.services.map((s) => `<tr><td data-l="서비스"><a href="/mail/${s.id}">${esc(s.name)}</a></td><td data-l="종류">${esc(s.kindLabel)}${s.site ? `<div class="sub"><a href="/sites/${s.site.id}">${esc(s.site.name)}</a></div>` : ''}</td><td data-l="도메인" class="mono">${esc(s.domain)}</td><td data-l="상태">${stBadge(s.status)}${s.prepError ? ` <span class="badge warning">! 서버 준비 실패</span>` : ''}</td><td data-l="메일함">${s.mailboxes.count} / ${s.mailboxes.allocated}</td><td data-l="저장 공간">${B(s.storage.used)} / ${B(s.storage.allocated)}${s.stale ? ' <span class="sub">(정보 지연)</span>' : ''}</td><td data-l="송수신">${s.send_state === 'ok' && s.receive_state === 'ok' ? badge('ok', '정상') : s.send_state === 'stopped' ? badge('failed', '중지') : s.send_state === 'stale' ? badge('warning', '정보 확인 지연') : badge('warning', '확인 전')}</td><td data-l="DNS">${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '오류') : badge('warning', '확인 필요')}</td><td data-l="경고">${lvlBadge(s.warnLevel)}</td></tr>`).join('')}</tbody></table><p class="sub">${esc(d.notes.retention)}</p>`;
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 생성(§15.3)
if (view === 'new') (async () => {
try { const o = await api('GET', '/api/mail/options'); const q = o.quota;
root.innerHTML = `<form id="f" class="card" data-dirty-guard>
<h2>1. 종류</h2><div class="f"><label><input type="radio" name="kind" value="site_bound" checked> 사이트 대표 메일 — 사이트 하나에 대표 메일 도메인 하나. 운영 사이트 수를 더 쓰지 않습니다</label><label><input type="radio" name="kind" value="standalone"> 독립 메일 서비스 — 사이트 없이 메일만. 독립 메일 옵션 1개를 씁니다(사용 가능 ${q.mail_services.unallocated}개, 구매 ${q.mail_services.purchased}개)</label></div>
<div class="f" id="site-f"><label for="siteId">2. 사이트</label><select id="siteId" name="siteId">${o.sites.map((s) => `<option value="${esc(s.id)}" ${s.hasPrimaryMail ? 'disabled' : ''}>${esc(s.name)}${s.hasPrimaryMail ? ' (이미 대표 메일 있음)' : ''}</option>`).join('') || '<option value="">사이트가 없습니다</option>'}</select><span class="hint">사이트마다 대표 메일 도메인은 하나입니다</span></div>
<div class="f"><label for="domain">3. 도메인</label><select id="domain" name="domain">${o.domains.map((d) => `<option value="${esc(d.name)}" data-site="${esc(d.site_id || '')}" ${d.inUse ? 'disabled' : ''}>${esc(d.name)}${d.inUse ? ' (다른 메일 서비스 사용 중)' : d.ownership_state !== 'verified' ? ' (소유 확인 전)' : ''}</option>`).join('') || '<option value="">등록된 도메인이 없습니다 — 먼저 도메인 화면에서 등록하세요</option>'}</select><span class="hint">도메인 화면에서 소유를 확인한 도메인만 메일에 쓸 수 있습니다. <a href="/domains">도메인으로 이동</a></span></div>
<div class="f"><label for="name">서비스 이름(선택)</label><input id="name" name="name" maxlength="80"></div>
<h2>4. 보장량 배정</h2><p class="hint">구매 총량 안에서 이 서비스에 배정합니다. 다른 서비스 배정·진행 중 예약을 뺀 미배정량까지만 가능합니다.</p>
<div class="f"><label for="mailboxes">메일함 수 (미배정 ${N(q.mailboxes.unallocated)} / 구매 ${N(q.mailboxes.purchased)})</label><input id="mailboxes" name="mailboxes" type="number" min="1" max="${q.mailboxes.unallocated}" value="${Math.min(1, q.mailboxes.unallocated)}" required></div>
<div class="f"><label for="storage">저장 공간 GB (미배정 ${B(q.mail_storage.unallocated)} / 구매 ${B(q.mail_storage.purchased)})</label><input id="storage" name="storage" type="number" min="0" step="0.1" max="${(q.mail_storage.unallocated / 1024 ** 3).toFixed(1)}" value="${Math.min(1, q.mail_storage.unallocated / 1024 ** 3).toFixed(1)}"></div>
<div class="f"><label for="monthly">월간 송신·수신 통수 (미배정 ${N(q.mail_msgs.unallocated)} / 구매 ${N(q.mail_msgs.purchased)})</label><input id="monthly" name="monthly" type="number" min="0" max="${q.mail_msgs.unallocated}" value="${Math.min(1000, q.mail_msgs.unallocated)}"><span class="hint">송신과 수신의 합계, 최종 수신자 한 명당 한 통</span></div>
${o.servers.length > 1 ? `<div class="f"><label for="serverId">메일 서버</label><select id="serverId" name="serverId">${o.servers.map((s) => `<option value="${esc(s.id)}">${esc(s.name)}</option>`).join('')}</select></div>` : ''}
<p class="hint">만든 뒤 5) DNS 값 확인 → 6) 도메인·DNS 확인 → 7) 서비스 준비 → 8) 실제 외부 송수신 확인 → 9) 활성화 순서로 진행합니다. 결제·승인 전에는 활성화되지 않습니다. 메일 백업·복원 기능은 제공되지 않습니다.</p>
<div class="actions"><button class="btn primary">만들기</button></div><div id="err" class="err"></div></form>`;
const f = root.querySelector('#f'); const kindOf = () => f.querySelector('input[name=kind]:checked').value; const sync = () => { f.querySelector('#site-f').style.display = kindOf() === 'site_bound' ? '' : 'none'; }; f.querySelectorAll('input[name=kind]').forEach((r) => (r.onchange = sync)); sync();
f.onsubmit = async (e) => { e.preventDefault(); const b = { kind: kindOf(), siteId: kindOf() === 'site_bound' ? f.siteId.value : null, domain: f.domain.value, name: f.name.value, alloc: { mailboxes: Number(f.mailboxes.value), storageBytes: Math.round(Number(f.storage.value) * 1024 ** 3), monthlyMessages: Number(f.monthly.value) }, serverId: f.serverId ? f.serverId.value : null }; try { const r = await api('POST', '/api/mail/services', b); window.aw.markClean(); location.href = `/mail/${r.service.id}`; } catch (err) { f.querySelector('#err').innerHTML = esc(err.message) + (err.detail?.link ? ` <a href="${esc(err.detail.link)}">이동</a>` : ''); } };
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 상세
if (view === 'detail') (async () => {
const id = root.dataset.id, tab = root.dataset.tab;
document.querySelector('[data-action=webmail]')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/webmail`, {}); toast(r.note); window.open(r.url, '_blank'); } catch (e) { toast(e.message); } });
try {
const d = await api('GET', `/api/mail/services/${id}`); const s = d.service, f = d.figures;
if (tab === 'overview') {
const dnsRows = d.records && !d.records.error ? d.records.records.map((r) => { const st = (d.dns?.items || []).find((i) => i.name === r.name && i.type === r.type); return `<tr><td data-l="종류">${esc(r.type)}</td><td data-l="이름">${copy(r.name)}</td><td data-l="값">${copy(r.value)}</td><td data-l="상태">${!st ? badge('neutral', '확인 전') : st.state === 'ok' ? badge('ok', '반영됨') : st.state === 'wrong' ? badge('failed', '잘못된 값') : st.state === 'missing' ? badge('warning', '미반영') : st.state === 'recommended' ? badge('warning', '권장') : badge('neutral', st.state)}</td></tr>`; }).join('') : `<tr><td colspan="4">${d.records?.error === 'service_unreachable' ? '메일 서버에 연결할 수 없어 DNS 값을 가져오지 못했습니다(정보 확인 지연)' : esc(d.records?.error || '먼저 메일 서버 준비가 필요합니다')}</td></tr>`;
root.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>서비스</dt><dd>${stBadge(s.status)}</dd><dt>종류</dt><dd>${esc(s.kindLabel)}${s.site ? ` · <a href="/sites/${s.site.id}">${esc(s.site.name)}</a>` : ''}</dd><dt>송신 / 수신</dt><dd>${esc({ ok: '정상', unknown: '확인 전', unverified: '검증 주소 미설정', error: '오류', stale: '정보 확인 지연', stopped: '중지' }[s.send_state] || s.send_state)} / ${esc({ ok: '정상', unknown: '확인 전', unverified: '검증 주소 미설정', error: '오류', stale: '정보 확인 지연', stopped: '중지' }[s.receive_state] || s.receive_state)}</dd><dt>DNS</dt><dd>${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '잘못된 값') : badge('warning', '확인 필요')}${d.dns ? ` <span class="sub">마지막 확인 ${esc(fmt(d.dns.checkedAt))}</span>` : ''}</dd><dt>메일함</dt><dd>${f.mailboxes.count} / ${f.mailboxes.allocated}</dd><dt>경고</dt><dd>${lvlBadge(f.warnLevel)}</dd>${s.prepError ? `<dt>서버 준비</dt><dd class="err">${s.prepError === 'service_unreachable' ? '메일 서버에 연결할 수 없습니다(정보 확인 지연)' : esc(s.prepError)} <button class="btn" data-x="prepare">다시 준비</button></dd>` : ''}</dl></div>
<div class="card"><h2>작업</h2><div id="job"></div><div class="actions">${s.status !== 'active' && s.status !== 'cancelled' && d.canManage ? '<button class="btn primary" data-x="activate">활성화 시작</button>' : ''}${d.canManage ? '<button class="btn" data-x="dns">DNS 확인</button><button class="btn" data-x="verify">외부 송수신 확인</button>' : ''}</div><div id="test">${d.lastTest ? `<p class="sub">마지막 외부 확인: ${esc({ ok: '정상', failed: '실패', needs_setup: '검증 주소 미설정', running: '진행 중', stale: '정보 확인 지연' }[d.lastTest.status] || d.lastTest.status)} (${esc(fmt(d.lastTest.ended_at || d.lastTest.created_at))})</p><ul class="steps">${d.lastTest.steps.map((x) => `<li class="${esc(x.state === 'needs_setup' ? 'needs_user' : x.state)}"><span class="s">${x.state === 'done' ? '✔' : x.state === 'failed' ? '✘' : '!'}</span><span>${esc({ outbound_ready: '발신 서비스 준비', send: '외부로 송신', receive: '외부 회신 수신', reply: '회신 확인', round_trip: '왕복 확인', error: '오류' }[x.id] || x.id)}${x.detail ? ` <span class="sub">— ${esc(x.detail)}</span>` : ''}</span></li>`).join('')}</ul>` : '<p class="sub">아직 외부 송수신을 확인하지 않았습니다</p>'}</div></div></div>
<div class="card"><h2>DNS 값 (복사해서 DNS 관리 화면에 넣으세요)</h2><table class="r"><thead><tr><th>종류</th><th>이름</th><th>값</th><th>상태</th></tr></thead><tbody>${dnsRows}</tbody></table></div>
<div class="card"><h2>메일 프로그램 설정</h2>${d.program ? `<dl class="kv"><dt>IMAP</dt><dd>${esc(d.program.imap.host)} · ${d.program.imap.ports.tls}(TLS) / ${d.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(d.program.pop3.host)} · ${d.program.pop3.ports.tls}(TLS) / ${d.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(d.program.smtp.host)} · ${d.program.smtp.ports.tls}(TLS) / ${d.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>메일 주소 전체(예: ${esc(d.program.username)})</dd><dt>비밀번호</dt><dd>메일함 비밀번호(여기서 다시 보여 주지 않습니다)</dd></dl>` : '<p class="sub">메일 서버가 준비되면 표시됩니다</p>'}</div>`;
const renderJob = async () => { try { const r = await api('GET', `/api/mail/services/${id}/job`); if (r.job) { job('#job', r.job, { onAction: (a, j) => jobActions(a, j, id).then(renderJob) }); return !r.job.terminal; } document.getElementById('job').innerHTML = '<p class="sub">진행 중인 작업 없음</p>'; return false; } catch (_) { return false; } };
poll(renderJob, 4000);
root.querySelectorAll('[data-x]').forEach((b) => (b.onclick = async () => { b.disabled = true; try { const x = b.dataset.x; if (x === 'activate') { await api('POST', `/api/mail/services/${id}/activate`); toast('활성화 작업을 시작했습니다'); renderJob(); } if (x === 'dns') { const r = await api('POST', `/api/mail/services/${id}/dns-check`); toast(r.result.ok ? 'DNS 반영 확인 완료' : `미반영 ${r.result.summary.missing} · 잘못된 값 ${r.result.summary.wrong}`); location.reload(); } if (x === 'verify') { toast('외부 송수신 확인 중…(최대 90초)'); const r = await api('POST', `/api/mail/services/${id}/verify`); toast(r.test.status === 'ok' ? '외부 송수신 정상' : r.test.status === 'needs_setup' ? '외부 검증 주소가 설정되지 않았습니다' : '외부 송수신 확인 실패'); location.reload(); } if (x === 'prepare') { await api('POST', `/api/mail/services/${id}/prepare`); location.reload(); } } catch (e) { toast(e.message); b.disabled = false; } }));
}
if (tab === 'mailboxes') { const m = await api('GET', `/api/mail/services/${id}/mailboxes`); const c = m.capacity;
root.innerHTML = `<p class="sub">남은 메일함 ${c.mailboxes.remaining}개(배정 ${c.mailboxes.allocated}) · 남은 저장 공간 ${B(c.storage.remaining)}(배정 ${B(c.storage.allocated)}) · ${esc(m.notes.suspendedCounts)}</p>${m.mailboxes.length ? `<table class="r"><thead><tr><th>주소</th><th>표시 이름</th><th>상태</th><th>공간</th><th>마지막 로그인</th><th>자동 응답·전달</th><th>비밀번호</th></tr></thead><tbody>${m.mailboxes.map((b) => `<tr><td data-l="주소"><a href="/mail/${id}/mailboxes/${b.id}">${esc(b.address)}</a></td><td data-l="이름">${esc(b.displayName || '')}</td><td data-l="상태">${b.status === 'active' ? badge('ok', '활성') : b.status === 'suspended' ? badge('warning', '정지') : badge('failed', b.statusLabel)}</td><td data-l="공간">${b.usage ? `${B(b.usage.bytes)} / ${b.usage.limit ? B(b.usage.limit) : '제한 없음'} ${bar(b.usage.bytes, b.usage.limit, b.level)}` : `${b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} <span class="sub">(계측 전)</span>`}</td><td data-l="로그인">${esc(fmt(b.lastLoginAt) || '없음')}</td><td data-l="설정"><span class="sub">웹메일 설정에서 관리</span></td><td data-l="비밀번호">${b.inviteState === 'pending' ? badge('waiting', '초대 대기') : b.mustChangePassword ? badge('warning', '변경 필요') : badge('ok', '설정됨')}</td></tr>`).join('')}</tbody></table>` : state(document.createElement('div'), 'empty') || `<div class="aw-state empty"><b>비어 있음</b><p>메일함이 없습니다</p><a class="btn" href="/mail/${id}/mailboxes/new">메일함 만들기</a></div>`}`;
}
if (tab === 'usage') { const u = await api('GET', `/api/mail/services/${id}/usage`); const lu = u.lastUsage; const g = u.figures;
const row = (label, x, unit, extra = '') => `<div class="card"><h2>${label} ${lvlBadge(x.level)}</h2><div class="stat">${unit === 'B' ? B(x.used) : N(x.used)} <span class="sub">/ ${unit === 'B' ? B(x.allocated) : N(x.allocated)} (${x.percent}%)</span></div>${bar(x.used, x.allocated, x.level)}<p class="sub">${extra}</p></div>`;
root.innerHTML = `${u.usageError ? `<div class="aw-state stale"><b>정보 확인 지연</b><p>메일 서버 사용량을 읽지 못했습니다(${esc(u.usageError)}). 마지막 계측값을 보여 줍니다. 오래된 정보로 신규 증가를 허용하지 않습니다</p></div>` : ''}<div class="cards">${row('메일 저장 공간', g.storage, 'B', esc(u.notes.limit100.storage))}${row('월간 송신·수신 통수', g.monthly, 'n', `${esc(u.notes.counting)} ${lu ? `송신 ${N(lu.sent)} · 수신 ${N(lu.received)}.` : ''} 다음 KST 월 초기화 ${esc(fmt(u.nextReset))}. ${esc(u.notes.limit100.monthly)}`)}${row('메일함 수', { ...g.mailboxes, used: g.mailboxes.count }, 'n', esc(u.notes.limit100.mailboxes))}</div>
<div class="card"><h2>발송 제한</h2><p>${esc(u.notes.sendLimit)}</p>${u.sendLimits.stale ? '<p class="sub">현재 제한 상태: 정보 확인 지연</p>' : u.sendLimits.throttled.length ? `<ul>${u.sendLimits.throttled.map((x) => `<li>${esc(x.key)} — 초당 ${x.rate}건 제출로 제한, 해제 예상 ${esc(fmt(x.until))}</li>`).join('')}</ul>` : '<p class="sub">현재 제한 없음</p>'}<p class="sub">문의: ${esc(u.sendLimits.contact)}</p></div>
<div class="card"><h2>대용량 첨부</h2><p>${u.policy.largeAttachment.deferred ? '허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정합니다(임의로 고정하지 않음). 시스템 안전 상한과 악용 방지 제한은 상품보다 우선합니다' : `허용 크기 ${B(u.policy.largeAttachment.maxBytes)} · 다운로드 ${u.policy.largeAttachment.maxDownloads}회 · 보관 ${u.policy.largeAttachment.maxDays}일`}. 대용량 첨부는 별도 옵션이며 저장된 파일은 메일 저장 공간을 소비합니다(업로드 전 예상 사용량 표시).</p><p class="sub">일반 첨부 최대 크기·메일당 최대 최종 수신자 수: ${u.policy.attachment.deferred ? '후속 상품정책(시스템 상한 25MB·50명)' : `${B(u.policy.attachment.maxBytes)} · ${u.policy.attachment.maxRecipients}명`}</p></div>
<div class="card"><h2>메일함별 사용량</h2><table class="r"><thead><tr><th>주소</th><th>사용</th><th>한도</th><th>경고</th></tr></thead><tbody>${u.mailboxes.map((b) => `<tr><td data-l="주소">${esc(b.address)}</td><td data-l="사용">${b.usage ? B(b.usage.bytes) : '계측 전'}</td><td data-l="한도">${b.usage?.limit ? B(b.usage.limit) : b.quotaBytes ? B(b.quotaBytes) : '제한 없음'}</td><td data-l="경고">${lvlBadge(b.level)}</td></tr>`).join('') || '<tr><td colspan="4">메일함 없음</td></tr>'}</tbody></table></div>
<p class="sub">적용 정책: ${esc(u.policy.note)} · 마지막 계측 ${esc(fmt(u.measuredAt) || '없음')}${u.stale ? ' (정보 확인 지연)' : ''} · <button class="btn" id="refresh">지금 계측</button> · ${esc(u.notes.noBackup)}</p>`;
root.querySelector('#refresh').onclick = async () => { try { await api('GET', `/api/mail/services/${id}/usage?refresh=1`); location.reload(); } catch (e) { toast(e.message); } };
}
if (tab === 'security') { const r = (await api('GET', `/api/mail/services/${id}/security`)).report;
root.innerHTML = `${r.stale ? '<div class="aw-state stale"><b>정보 확인 지연</b><p>DNS 조회에 실패해 마지막 결과를 보여 줍니다</p></div>' : ''}<div class="card"><h2>도메인 보안 점검 <span class="sub">${esc(fmt(r.checkedAt) || '')}</span></h2><table class="r"><thead><tr><th>항목</th><th>상태</th><th>설명</th></tr></thead><tbody>${r.items.map((i) => `<tr><td data-l="항목">${esc(i.label)}</td><td data-l="상태">${i.state === 'ok' ? badge('ok', '정상') : i.state === 'recommended' ? badge('warning', '권장 개선') : i.state === 'stale' ? badge('warning', '정보 확인 지연') : badge('failed', '오류')}</td><td data-l="설명">${esc(i.text)}</td></tr>`).join('')}</tbody></table></div><div class="card"><h2>필요한 DNS 값</h2><table class="r"><thead><tr><th>종류</th><th>이름</th><th>필요한 값</th><th>현재 값</th><th>상태</th></tr></thead><tbody>${r.records.map((x) => `<tr><td data-l="종류">${esc(x.type)}</td><td data-l="이름">${copy(x.name)}</td><td data-l="필요한 값">${copy(x.value)}</td><td data-l="현재 값" class="mono">${esc((x.found || []).join(', ') || '없음')}</td><td data-l="상태">${x.state === 'ok' ? badge('ok', '정상') : x.state === 'wrong' ? badge('failed', '값이 다름') : x.state === 'missing' ? badge('warning', '미반영') : badge('neutral', x.state)}</td></tr>`).join('')}</tbody></table></div>`;
}
if (tab === 'settings') { const st = await api('GET', `/api/mail/services/${id}/settings`);
root.innerHTML = `<div class="card"><h2>POP3 · IMAP</h2>${st.protocols.stale ? '<p class="sub">메일 서버 상태: 정보 확인 지연</p>' : ''}<p>${esc(st.notes.protocolImpact)}</p><label><input type="checkbox" id="pop" ${st.protocols.pop ? 'checked' : ''}> POP3 사용</label><br><label><input type="checkbox" id="imap" ${st.protocols.imap ? 'checked' : ''}> IMAP 사용</label><div class="actions"><button class="btn primary" id="save-p">적용 후 접속 확인</button></div><div id="pres"></div></div>
<div class="card"><h2>대용량 첨부(링크 방식)</h2><p>${st.policy.largeAttachment.deferred ? '구체 수치는 후속 상품정책에서 정합니다. 시스템 안전 상한: ' + (st.systemLargeFiles ? `${B(st.systemLargeFiles.bytes)} · ${st.systemLargeFiles.days}일 · ${st.systemLargeFiles.downloads}회` : '확인 전') : `허용 ${B(st.policy.largeAttachment.maxBytes)} · ${st.policy.largeAttachment.maxDownloads}회 · ${st.policy.largeAttachment.maxDays}일`}</p><p class="sub">현재 메일 서버 설정: ${st.largeFiles ? (st.largeFiles.enabled ? `사용 · 최대 ${B(st.largeFiles.maxBytes)} · ${st.largeFiles.maxDays}일 · ${st.largeFiles.maxDownloads}회` : '사용 안 함') : '정보 확인 지연'}</p></div>
<div class="card"><h2>별칭·전달 전용 주소</h2><p class="sub">계산 방식은 최신 상품정책을 따릅니다(${st.policy.aliases.deferred ? esc(st.notes.policyDeferred) + ' — 미확정이면 무제한 제공하지 않습니다' : `메일함당 ${st.policy.aliases.perMailbox}개`}). 생성은 이번 버전에서 지원하지 않으며, 전달은 각 메일함의 웹메일 설정을 쓰세요.</p></div>
<div class="card"><h2>해지</h2><p class="sub">해지하면 송수신이 즉시 중지되고 30일 동안 읽기·내보내기만 가능하며 그 뒤 삭제됩니다. 메일 백업·복원 기능은 제공되지 않습니다.</p><button class="btn danger" id="cancel-svc">메일 서비스 해지</button></div>`;
root.querySelector('#save-p').onclick = async () => { const pop = root.querySelector('#pop').checked, imap = root.querySelector('#imap').checked; if (!(await confirmDanger({ title: 'POP3 · IMAP 설정 변경', target: s.domain, impacts: [st.notes.protocolImpact, `POP3 ${pop ? '사용' : '중지'} · IMAP ${imap ? '사용' : '중지'}`], confirmLabel: '적용' }))) return; try { const r = await api('POST', `/api/mail/services/${id}/protocols`, { pop, imap, confirmed: true }); root.querySelector('#pres').innerHTML = `<p>적용됨 — 접속 확인: ${r.check.summary === 'ok' ? badge('ok', '모든 포트 응답') : r.check.summary === 'stale' ? badge('warning', '정보 확인 지연') : badge('warning', '일부 포트 응답 없음')} ${Object.entries(r.check.ports).map(([k, v]) => `${k}:${v.port} ${v.reachable === null ? '?' : v.reachable ? '✔' : '✘'}`).join(' · ')}</p>`; } catch (e) { toast(e.message); } };
root.querySelector('#cancel-svc').onclick = async () => { if (!(await confirmDanger({ title: '메일 서비스 해지', target: s.domain, impacts: ['송수신 즉시 중지', '30일 읽기·내보내기 뒤 삭제(되돌릴 수 없음)', '배정 용량은 삭제 때 반환', '메일 백업·복원 미제공'], typeToConfirm: s.domain, confirmLabel: '해지' }))) return; try { await api('POST', `/api/mail/services/${id}/cancel`, { confirm: s.domain, acknowledgeSite: true }); location.href = `/mail/${id}?tab=lifecycle`; } catch (e) { toast(e.message); } };
}
if (tab === 'convert') { const c = await api('GET', `/api/mail/services/${id}/convert`);
root.innerHTML = `<div class="card"><h2>사이트 대표 메일 → 독립 메일 서비스 전환</h2><p class="sub">전환하면 사이트와 분리되어 사이트 수 반환(§14)이 가능해집니다. 다음 항목이 항목별로 유지되는지 확인합니다.</p><table class="r"><thead><tr><th>항목</th><th>유지</th><th>내용</th><th>확인</th></tr></thead><tbody>${c.checklist.items.map((i) => `<tr><td data-l="항목">${esc(i.label)}</td><td data-l="유지">${i.keep === true ? '유지' : i.keep === false ? '종료' : '조건'}</td><td data-l="내용">${esc(i.detail)}${i.link ? ` <a href="${esc(i.link)}">이동</a>` : ''}</td><td data-l="확인">${i.ok ? badge('ok', '충족') : badge('failed', i.note || '미충족')}</td></tr>`).join('')}</tbody></table><p class="sub">전환 전후에 실제 송수신을 확인하며 누락이 있으면 완료하지 않고 사이트 수를 반환하지 않습니다.</p><div class="actions"><button class="btn primary" id="go" ${c.checklist.canConvert ? '' : 'disabled'}>전환 시작</button></div><div id="job"></div></div>`;
root.querySelector('#go').onclick = async () => { if (!(await confirmDanger({ title: '독립 메일 서비스로 전환', target: s.domain, impacts: ['독립 메일 옵션 1개 소비', '사이트와 분리(사이트 해지 뒤에도 메일 유지)', '주소·메일함·별칭·전달·비밀번호·DNS 유지'], typeToConfirm: s.domain, confirmLabel: '전환' }))) return; try { await api('POST', `/api/mail/services/${id}/convert`, { confirm: s.domain }); toast('전환 작업을 시작했습니다'); } catch (e) { toast(e.message); } };
poll(async () => { const r = await api('GET', `/api/mail/services/${id}/job`); if (r.job && r.job.kind === 'mail.convert') { job('#job', r.job, { onAction: (a, j) => jobActions(a, j, id) }); return !r.job.terminal; } return true; }, 4000);
}
if (tab === 'lifecycle') { const l = d.lifecycle;
root.innerHTML = `<div class="card"><h2>생명주기 — ${esc(l.label)}</h2><dl class="kv"><dt>공개 서비스</dt><dd>${esc(l.publicService)}</dd><dt>관리자 접근</dt><dd>${esc(l.adminAccess)}</dd><dt>권리 소비</dt><dd>${esc(l.consumes)}</dd><dt>청구</dt><dd>${esc(l.billing)}</dd><dt>도메인·메일</dt><dd>${esc(l.domainMail)}</dd><dt>백업</dt><dd>${esc(l.backup)}</dd><dt>내보내기</dt><dd>${esc(l.exportable)}</dd><dt>재개</dt><dd>${esc(l.resumable)}</dd><dt>되돌릴 수 없는 시점</dt><dd>${esc(fmt(l.irreversibleAt) || '해당 없음')}</dd><dt>보존 종료</dt><dd>${esc(fmt(l.retentionUntil) || '해당 없음')}</dd><dt>삭제 예정일</dt><dd>${esc(fmt(l.deleteAfter) || '해당 없음')}</dd><dt>다음 알림</dt><dd>${esc(fmt(l.nextNotice) || '해당 없음')}</dd></dl>${s.status === 'cancelled' ? '<div class="actions"><button class="btn" id="exp">내보내기 안내</button></div><div id="expr"></div>' : ''}</div>`;
root.querySelector('#exp')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/export`, {}); root.querySelector('#expr').innerHTML = `<ul>${r.export.guidance.map((g) => `<li>${esc(g)}</li>`).join('')}</ul><p class="sub">${esc(fmt(r.export.until))} 까지</p>`; } catch (e) { toast(e.message); } });
}
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 메일함 만들기(§16.1)
if (view === 'mailbox-new') (async () => {
const id = root.dataset.id, domain = root.dataset.domain;
try { const m = await api('GET', `/api/mail/services/${id}/mailboxes`); const c = m.capacity;
if (c.mailboxes.remaining <= 0) return state(root, 'failed', `메일함 수가 한도(${c.mailboxes.allocated}개)에 찼습니다. 사용량 화면에서 배정을 늘리거나 상품을 추가하세요`, { href: '/usage', hrefLabel: '사용량으로' });
root.innerHTML = `<form id="f" class="card" data-dirty-guard><p class="sub">남은 메일함 ${c.mailboxes.remaining}개 · 남은 저장 공간 ${B(c.storage.remaining)}</p><div class="f"><label for="local">메일 주소</label><div style="display:flex;gap:6px;align-items:center"><input id="local" required pattern="[a-z0-9][a-z0-9._-]{0,63}" style="max-width:240px"><span>@${esc(domain)}</span></div><span class="hint">영문 소문자·숫자로 시작, 1~64자(영문, 숫자, . _ -)</span></div><div class="f"><label for="dn">표시 이름</label><input id="dn" maxlength="80"></div><div class="f"><label for="q">저장 공간 (MB, 0 = 서비스 배정 안에서 제한 없음)</label><input id="q" type="number" min="0" value="0" max="${Math.floor(c.storage.remaining / 1024 ** 2)}"></div><div class="f"><label>비밀번호 방식</label><label><input type="radio" name="mode" value="password" checked> 초기 비밀번호(한 번만 표시, 첫 로그인에서 변경)</label><label><input type="radio" name="mode" value="invite"> 초대 링크(사용자가 비밀번호를 정함, 3일 유효)</label></div><div class="f" id="inv" style="display:none"><label for="ie">초대 보낼 이메일(선택)</label><input id="ie" type="email"></div><p class="hint">웹메일·POP3·IMAP 접속 안내는 만든 뒤 표시됩니다. ${esc(m.notes.suspendedCounts)}</p><div class="actions"><button class="btn primary">만들기</button></div><div id="err" class="err"></div></form><div id="res"></div>`;
const f = root.querySelector('#f'); f.querySelectorAll('input[name=mode]').forEach((r) => (r.onchange = () => { f.querySelector('#inv').style.display = f.querySelector('input[name=mode]:checked').value === 'invite' ? '' : 'none'; }));
f.onsubmit = async (e) => { e.preventDefault(); try { const r = await api('POST', `/api/mail/services/${id}/mailboxes`, { local: f.querySelector('#local').value.trim().toLowerCase(), displayName: f.querySelector('#dn').value, quotaBytes: Number(f.querySelector('#q').value) * 1024 ** 2, mode: f.querySelector('input[name=mode]:checked').value, inviteEmail: f.querySelector('#ie').value }); window.aw.markClean(); f.remove();
root.querySelector('#res').innerHTML = `<div class="card"><h2>만들었습니다: ${esc(r.mailbox.address)}</h2>${r.initialPassword ? `<p><b>초기 비밀번호:</b> ${copy(r.initialPassword)}</p><p class="err">${esc(m.notes.initialPasswordOnce)}</p>` : ''}${r.invite ? `<p><b>초대 링크:</b> ${copy(r.invite.url)}</p><p class="sub">만료 ${esc(fmt(r.invite.expiresAt))}${r.invite.email ? ` · ${esc(r.invite.email)} 로 보냈습니다` : ''}</p>` : ''}<dl class="kv"><dt>웹메일</dt><dd><a href="${esc(r.program.webmail)}" target="_blank">${esc(r.program.webmail)}</a></dd><dt>IMAP</dt><dd>${esc(r.program.imap.host)} ${r.program.imap.ports.tls}(TLS)/${r.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(r.program.pop3.host)} ${r.program.pop3.ports.tls}(TLS)/${r.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(r.program.smtp.host)} ${r.program.smtp.ports.tls}(TLS)/${r.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>${esc(r.mailbox.address)}</dd></dl><a class="btn" href="/mail/${id}?tab=mailboxes">메일함 목록으로</a></div>`; } catch (err) { f.querySelector('#err').innerHTML = esc(err.message) + (err.detail?.link ? ` <a href="${esc(err.detail.link)}">이동</a>` : ''); } };
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 메일함 설정(§16.2)
if (view === 'mailbox') (async () => {
const id = root.dataset.id, mid = root.dataset.mid;
document.querySelector('[data-action=webmail-box]')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/webmail`, { mailboxId: mid }); toast(r.note); window.open(r.url, '_blank'); } catch (e) { toast(e.message); } });
try { const d = await api('GET', `/api/mail/services/${id}/mailboxes/${mid}`); const b = d.mailbox; const ro = !d.canManage;
root.innerHTML = `<div class="cards"><div class="card"><h2>상태</h2><dl class="kv"><dt>상태</dt><dd>${b.status === 'active' ? badge('ok', '활성') : b.status === 'suspended' ? badge('warning', '정지') : badge('failed', b.statusLabel)}</dd><dt>공간</dt><dd>${b.usage ? `${B(b.usage.bytes)} / ${b.usage.limit ? B(b.usage.limit) : '제한 없음'}` : `${b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} (계측 전)`} ${lvlBadge(b.level)}</dd><dt>마지막 로그인</dt><dd>${esc(fmt(b.lastLoginAt) || '없음')}</dd><dt>비밀번호</dt><dd>${b.mustChangePassword ? '변경 필요(초기·재설정 비밀번호 사용 중)' : '설정됨'}</dd><dt>초대</dt><dd>${d.invite ? `${{ pending: '대기(만료 ' + fmt(d.invite.expires_at) + ')', used: '사용 완료', expired: '만료' }[d.invite.state]}${d.invite.resent_count ? ` · 재발송 ${d.invite.resent_count}회` : ''}` : '없음'}</dd><dt>자동 응답·전달</dt><dd>웹메일 설정에서 관리(기존 AriMail 기능)</dd></dl></div>
<div class="card"><h2>메일 프로그램 설정</h2><dl class="kv"><dt>IMAP</dt><dd>${esc(d.program.imap.host)} ${d.program.imap.ports.tls}(TLS)/${d.program.imap.ports.starttls}(STARTTLS)</dd><dt>POP3</dt><dd>${esc(d.program.pop3.host)} ${d.program.pop3.ports.tls}(TLS)/${d.program.pop3.ports.starttls}(STARTTLS)</dd><dt>SMTP</dt><dd>${esc(d.program.smtp.host)} ${d.program.smtp.ports.tls}(TLS)/${d.program.smtp.ports.starttls}(STARTTLS)</dd><dt>사용자 이름</dt><dd>${esc(b.address)}</dd><dt>비밀번호</dt><dd>다시 표시하지 않습니다</dd></dl></div></div>
${ro ? '<div class="aw-state forbidden"><b>권한 없음</b><p>설정 변경은 그룹 관리자 또는 사이트 "도메인과 메일 설정" 권한이 필요합니다</p></div>' : `<form id="f" class="card" data-dirty-guard><h2>설정</h2><div class="f"><label for="dn">표시 이름</label><input id="dn" value="${esc(b.displayName || '')}" maxlength="80"></div><div class="f"><label for="q">저장 공간 (MB, 0 = 제한 없음, 서비스 배정 ${B(d.capacity.storage.allocated)} 안)</label><input id="q" type="number" min="0" value="${Math.round((b.quotaBytes || 0) / 1024 ** 2)}"></div><div class="actions"><button class="btn primary">저장</button></div></form>
<div class="card"><h2>별칭·전달 전용 주소</h2><p class="sub">${d.policy.aliases.deferred ? '계산 방식은 최신 상품정책(미확정 — ' + esc(d.notes.policyDeferred) + ')에서 정하며 무제한으로 제공하지 않습니다.' : `정책: 메일함당 ${d.policy.aliases.perMailbox}개`} 이번 버전에서는 생성을 지원하지 않습니다. 전달은 웹메일 설정의 "메일 전달" 을 쓰세요.</p></div>
<div class="card"><h2>보안·정지·삭제</h2><p class="sub">${esc(d.notes.suspendedCounts)}</p><div class="actions"><button class="btn" data-x="reset">비밀번호 재설정</button><button class="btn" data-x="end">모든 세션 종료</button>${b.inviteState === 'pending' || b.inviteState === 'expired' ? '<button class="btn" data-x="invite">초대 재발송</button>' : ''}<button class="btn" data-x="${b.status === 'suspended' ? 'resume' : 'suspend'}">${b.status === 'suspended' ? '정지 해제' : '정지'}</button><button class="btn danger" data-x="delete">삭제</button></div><div id="xres"></div></div>`}`;
const f = root.querySelector('#f'); if (f) f.onsubmit = async (e) => { e.preventDefault(); try { await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { displayName: f.querySelector('#dn').value, quotaBytes: Number(f.querySelector('#q').value) * 1024 ** 2 }); window.aw.markClean(); toast('저장했습니다'); } catch (err) { toast(err.message); } };
root.querySelectorAll('[data-x]').forEach((btn) => (btn.onclick = async () => { const x = btn.dataset.x; const out = root.querySelector('#xres'); try {
if (x === 'reset') { if (!(await confirmDanger({ title: '비밀번호 재설정', target: b.address, impacts: ['새 임시 비밀번호가 한 번만 표시됩니다', '기존 비밀번호와 메일 프로그램 연결이 즉시 무효가 됩니다'], confirmLabel: '재설정' }))) return; const r = await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { resetPassword: true }); out.innerHTML = `<p><b>새 임시 비밀번호:</b> ${copy(r.newPassword)}</p><p class="err">지금 한 번만 보입니다. 첫 로그인에서 바꾸세요</p>`; }
if (x === 'end') { if (!(await confirmDanger({ title: '모든 세션 종료', target: b.address, impacts: ['웹메일·IMAP·POP3·SMTP 의 모든 로그인이 끊깁니다', '비밀번호가 재설정되어 사용자가 다시 정해야 합니다'], confirmLabel: '종료' }))) return; const r = await api('POST', `/api/mail/services/${id}/mailboxes/${mid}/sessions/end`, {}); out.innerHTML = `<p>${esc(r.note)}</p>`; }
if (x === 'invite') { const r = await api('POST', `/api/mail/services/${id}/mailboxes/${mid}/invite`, {}); out.innerHTML = `<p>초대 링크: ${copy(r.invite.url)} <span class="sub">만료 ${esc(fmt(r.invite.expiresAt))}</span></p>`; }
if (x === 'suspend' || x === 'resume') { if (x === 'suspend' && !(await confirmDanger({ title: '메일함 정지', target: b.address, impacts: ['로그인·송수신이 막힙니다(받는 메일은 발신 서버가 재시도)', '정지 중에도 메일함 수와 저장 공간을 소비합니다'], confirmLabel: '정지' }))) return; await api('PATCH', `/api/mail/services/${id}/mailboxes/${mid}`, { status: x === 'suspend' ? 'suspended' : 'active' }); location.reload(); }
if (x === 'delete') { if (!(await confirmDanger({ title: '메일함 삭제', target: b.address, impacts: ['메일함의 모든 메일·첨부가 삭제되며 복구할 수 없습니다', '메일 백업·복원 기능은 제공되지 않습니다', '메일함 수·저장 공간이 반환됩니다'], typeToConfirm: b.address, confirmLabel: '삭제' }))) return; await api('DELETE', `/api/mail/services/${id}/mailboxes/${mid}`, { confirm: b.address }); location.href = `/mail/${id}?tab=mailboxes`; }
} catch (e) { toast(e.message); } }));
} catch (e) { fail(root, e); }
})();
// ---------------------------------------------------------------- 초대 수락
if (view === 'invite') { const f = root.querySelector('form'); f.onsubmit = async (e) => { e.preventDefault(); const pw = f.querySelector('#pw').value, pw2 = f.querySelector('#pw2').value; if (pw !== pw2) return toast('비밀번호가 서로 다릅니다'); try { const r = await api('POST', '/api/mail/invite/accept', { token: root.dataset.token, password: pw }); window.aw.markClean(); f.remove(); root.querySelector('#invite-result').innerHTML = `<p>${esc(r.address)} 의 비밀번호를 설정했습니다.</p><a class="btn primary" href="${esc(r.webmail)}">웹메일 열기</a>`; } catch (err) { root.querySelector('#invite-result').innerHTML = `<p class="err">${esc(err.message)}</p>`; } }; }
})();