/* 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) => `
`; const badge = (kind, label) => `${{ ok: '●', done: '✔', failed: '✘', warning: '!', running: '◌', waiting: '◔', neutral: '○' }[kind] || '○'} ${esc(label)}`; 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) => `${esc(v)}`; 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 = `| 서비스 | 종류 · 사이트 | 대표 메일 도메인 | 상태 | 메일함 | 저장 공간 | 송수신 | DNS 보안 | 최근 경고 |
|---|---|---|---|---|---|---|---|---|
| ${esc(s.name)} | ${esc(s.kindLabel)}${s.site ? `` : ''} | ${esc(s.domain)} | ${stBadge(s.status)}${s.prepError ? ` ! 서버 준비 실패` : ''} | ${s.mailboxes.count} / ${s.mailboxes.allocated} | ${B(s.storage.used)} / ${B(s.storage.allocated)}${s.stale ? ' (정보 지연)' : ''} | ${s.send_state === 'ok' && s.receive_state === 'ok' ? badge('ok', '정상') : s.send_state === 'stopped' ? badge('failed', '중지') : s.send_state === 'stale' ? badge('warning', '정보 확인 지연') : badge('warning', '확인 전')} | ${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '오류') : badge('warning', '확인 필요')} | ${lvlBadge(s.warnLevel)} |
${esc(d.notes.retention)}
`; } 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 = ``; 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 ? ` 이동` : ''); } }; } 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 `마지막 외부 확인: ${esc({ ok: '정상', failed: '실패', needs_setup: '검증 주소 미설정', running: '진행 중', stale: '정보 확인 지연' }[d.lastTest.status] || d.lastTest.status)} (${esc(fmt(d.lastTest.ended_at || d.lastTest.created_at))})
아직 외부 송수신을 확인하지 않았습니다
'}| 종류 | 이름 | 값 | 상태 |
|---|
메일 서버가 준비되면 표시됩니다
'}진행 중인 작업 없음
'; 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 = `남은 메일함 ${c.mailboxes.remaining}개(배정 ${c.mailboxes.allocated}) · 남은 저장 공간 ${B(c.storage.remaining)}(배정 ${B(c.storage.allocated)}) · ${esc(m.notes.suspendedCounts)}
${m.mailboxes.length ? `| 주소 | 표시 이름 | 상태 | 공간 | 마지막 로그인 | 자동 응답·전달 | 비밀번호 |
|---|---|---|---|---|---|---|
| ${esc(b.address)} | ${esc(b.displayName || '')} | ${b.status === 'active' ? badge('ok', '활성') : b.status === 'suspended' ? badge('warning', '정지') : badge('failed', b.statusLabel)} | ${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) : '제한 없음'} (계측 전)`} | ${esc(fmt(b.lastLoginAt) || '없음')} | 웹메일 설정에서 관리 | ${b.inviteState === 'pending' ? badge('waiting', '초대 대기') : b.mustChangePassword ? badge('warning', '변경 필요') : badge('ok', '설정됨')} |
메일함이 없습니다
메일함 만들기${extra}
메일 서버 사용량을 읽지 못했습니다(${esc(u.usageError)}). 마지막 계측값을 보여 줍니다. 오래된 정보로 신규 증가를 허용하지 않습니다
${esc(u.notes.sendLimit)}
${u.sendLimits.stale ? '현재 제한 상태: 정보 확인 지연
' : u.sendLimits.throttled.length ? `현재 제한 없음
'}문의: ${esc(u.sendLimits.contact)}
${u.policy.largeAttachment.deferred ? '허용 크기·다운로드 횟수·보관 기한은 후속 상품정책에서 정합니다(임의로 고정하지 않음). 시스템 안전 상한과 악용 방지 제한은 상품보다 우선합니다' : `허용 크기 ${B(u.policy.largeAttachment.maxBytes)} · 다운로드 ${u.policy.largeAttachment.maxDownloads}회 · 보관 ${u.policy.largeAttachment.maxDays}일`}. 대용량 첨부는 별도 옵션이며 저장된 파일은 메일 저장 공간을 소비합니다(업로드 전 예상 사용량 표시).
일반 첨부 최대 크기·메일당 최대 최종 수신자 수: ${u.policy.attachment.deferred ? '후속 상품정책(시스템 상한 25MB·50명)' : `${B(u.policy.attachment.maxBytes)} · ${u.policy.attachment.maxRecipients}명`}
| 주소 | 사용 | 한도 | 경고 |
|---|---|---|---|
| ${esc(b.address)} | ${b.usage ? B(b.usage.bytes) : '계측 전'} | ${b.usage?.limit ? B(b.usage.limit) : b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} | ${lvlBadge(b.level)} |
| 메일함 없음 | |||
적용 정책: ${esc(u.policy.note)} · 마지막 계측 ${esc(fmt(u.measuredAt) || '없음')}${u.stale ? ' (정보 확인 지연)' : ''} · · ${esc(u.notes.noBackup)}
`; 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 ? 'DNS 조회에 실패해 마지막 결과를 보여 줍니다
| 항목 | 상태 | 설명 |
|---|---|---|
| ${esc(i.label)} | ${i.state === 'ok' ? badge('ok', '정상') : i.state === 'recommended' ? badge('warning', '권장 개선') : i.state === 'stale' ? badge('warning', '정보 확인 지연') : badge('failed', '오류')} | ${esc(i.text)} |
| 종류 | 이름 | 필요한 값 | 현재 값 | 상태 |
|---|---|---|---|---|
| ${esc(x.type)} | ${copy(x.name)} | ${copy(x.value)} | ${esc((x.found || []).join(', ') || '없음')} | ${x.state === 'ok' ? badge('ok', '정상') : x.state === 'wrong' ? badge('failed', '값이 다름') : x.state === 'missing' ? badge('warning', '미반영') : badge('neutral', x.state)} |
메일 서버 상태: 정보 확인 지연
' : ''}${esc(st.notes.protocolImpact)}
${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}일`}
현재 메일 서버 설정: ${st.largeFiles ? (st.largeFiles.enabled ? `사용 · 최대 ${B(st.largeFiles.maxBytes)} · ${st.largeFiles.maxDays}일 · ${st.largeFiles.maxDownloads}회` : '사용 안 함') : '정보 확인 지연'}
계산 방식은 최신 상품정책을 따릅니다(${st.policy.aliases.deferred ? esc(st.notes.policyDeferred) + ' — 미확정이면 무제한 제공하지 않습니다' : `메일함당 ${st.policy.aliases.perMailbox}개`}). 생성은 이번 버전에서 지원하지 않으며, 전달은 각 메일함의 웹메일 설정을 쓰세요.
해지하면 송수신이 즉시 중지되고 30일 동안 읽기·내보내기만 가능하며 그 뒤 삭제됩니다. 메일 백업·복원 기능은 제공되지 않습니다.
적용됨 — 접속 확인: ${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(' · ')}
`; } 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 = `전환하면 사이트와 분리되어 사이트 수 반환(§14)이 가능해집니다. 다음 항목이 항목별로 유지되는지 확인합니다.
| 항목 | 유지 | 내용 | 확인 |
|---|---|---|---|
| ${esc(i.label)} | ${i.keep === true ? '유지' : i.keep === false ? '종료' : '조건'} | ${esc(i.detail)}${i.link ? ` 이동` : ''} | ${i.ok ? badge('ok', '충족') : badge('failed', i.note || '미충족')} |
전환 전후에 실제 송수신을 확인하며 누락이 있으면 완료하지 않고 사이트 수를 반환하지 않습니다.
${esc(fmt(r.export.until))} 까지
`; } 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 = ``; 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 = `초기 비밀번호: ${copy(r.initialPassword)}
${esc(m.notes.initialPasswordOnce)}
` : ''}${r.invite ? `초대 링크: ${copy(r.invite.url)}
만료 ${esc(fmt(r.invite.expiresAt))}${r.invite.email ? ` · ${esc(r.invite.email)} 로 보냈습니다` : ''}
` : ''}설정 변경은 그룹 관리자 또는 사이트 "도메인과 메일 설정" 권한이 필요합니다
${d.policy.aliases.deferred ? '계산 방식은 최신 상품정책(미확정 — ' + esc(d.notes.policyDeferred) + ')에서 정하며 무제한으로 제공하지 않습니다.' : `정책: 메일함당 ${d.policy.aliases.perMailbox}개`} 이번 버전에서는 생성을 지원하지 않습니다. 전달은 웹메일 설정의 "메일 전달" 을 쓰세요.
${esc(d.notes.suspendedCounts)}
새 임시 비밀번호: ${copy(r.newPassword)}
지금 한 번만 보입니다. 첫 로그인에서 바꾸세요
`; } 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 = `${esc(r.note)}
`; } if (x === 'invite') { const r = await api('POST', `/api/mail/services/${id}/mailboxes/${mid}/invite`, {}); out.innerHTML = `초대 링크: ${copy(r.invite.url)} 만료 ${esc(fmt(r.invite.expiresAt))}
`; } 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 = `${esc(r.address)} 의 비밀번호를 설정했습니다.
웹메일 열기`; } catch (err) { root.querySelector('#invite-result').innerHTML = `${esc(err.message)}
`; } }; } })();