/* 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 = `${d.services.map((s) => ``).join('')}
서비스종류 · 사이트대표 메일 도메인상태메일함저장 공간송수신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 = `

1. 종류

사이트마다 대표 메일 도메인은 하나입니다
도메인 화면에서 소유를 확인한 도메인만 메일에 쓸 수 있습니다. 도메인으로 이동

4. 보장량 배정

구매 총량 안에서 이 서비스에 배정합니다. 다른 서비스 배정·진행 중 예약을 뺀 미배정량까지만 가능합니다.

송신과 수신의 합계, 최종 수신자 한 명당 한 통
${o.servers.length > 1 ? `
` : ''}

만든 뒤 5) DNS 값 확인 → 6) 도메인·DNS 확인 → 7) 서비스 준비 → 8) 실제 외부 송수신 확인 → 9) 활성화 순서로 진행합니다. 결제·승인 전에는 활성화되지 않습니다. 메일 백업·복원 기능은 제공되지 않습니다.

`; 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(r.type)}${copy(r.name)}${copy(r.value)}${!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)}`; }).join('') : `${d.records?.error === 'service_unreachable' ? '메일 서버에 연결할 수 없어 DNS 값을 가져오지 못했습니다(정보 확인 지연)' : esc(d.records?.error || '먼저 메일 서버 준비가 필요합니다')}`; root.innerHTML = `

상태

서비스
${stBadge(s.status)}
종류
${esc(s.kindLabel)}${s.site ? ` · ${esc(s.site.name)}` : ''}
송신 / 수신
${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)}
DNS
${s.dns_state === 'ok' ? badge('ok', '정상') : s.dns_state === 'error' ? badge('failed', '잘못된 값') : badge('warning', '확인 필요')}${d.dns ? ` 마지막 확인 ${esc(fmt(d.dns.checkedAt))}` : ''}
메일함
${f.mailboxes.count} / ${f.mailboxes.allocated}
경고
${lvlBadge(f.warnLevel)}
${s.prepError ? `
서버 준비
${s.prepError === 'service_unreachable' ? '메일 서버에 연결할 수 없습니다(정보 확인 지연)' : esc(s.prepError)}
` : ''}

작업

${s.status !== 'active' && s.status !== 'cancelled' && d.canManage ? '' : ''}${d.canManage ? '' : ''}
${d.lastTest ? `

마지막 외부 확인: ${esc({ ok: '정상', failed: '실패', needs_setup: '검증 주소 미설정', running: '진행 중', stale: '정보 확인 지연' }[d.lastTest.status] || d.lastTest.status)} (${esc(fmt(d.lastTest.ended_at || d.lastTest.created_at))})

    ${d.lastTest.steps.map((x) => `
  • ${x.state === 'done' ? '✔' : x.state === 'failed' ? '✘' : '!'}${esc({ outbound_ready: '발신 서비스 준비', send: '외부로 송신', receive: '외부 회신 수신', reply: '회신 확인', round_trip: '왕복 확인', error: '오류' }[x.id] || x.id)}${x.detail ? ` — ${esc(x.detail)}` : ''}
  • `).join('')}
` : '

아직 외부 송수신을 확인하지 않았습니다

'}

DNS 값 (복사해서 DNS 관리 화면에 넣으세요)

${dnsRows}
종류이름값상태

메일 프로그램 설정

${d.program ? `
IMAP
${esc(d.program.imap.host)} · ${d.program.imap.ports.tls}(TLS) / ${d.program.imap.ports.starttls}(STARTTLS)
POP3
${esc(d.program.pop3.host)} · ${d.program.pop3.ports.tls}(TLS) / ${d.program.pop3.ports.starttls}(STARTTLS)
SMTP
${esc(d.program.smtp.host)} · ${d.program.smtp.ports.tls}(TLS) / ${d.program.smtp.ports.starttls}(STARTTLS)
사용자 이름
메일 주소 전체(예: ${esc(d.program.username)})
비밀번호
메일함 비밀번호(여기서 다시 보여 주지 않습니다)
` : '

메일 서버가 준비되면 표시됩니다

'}
`; 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 = '

진행 중인 작업 없음

'; 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 ? `${m.mailboxes.map((b) => ``).join('')}
주소표시 이름상태공간마지막 로그인자동 응답·전달비밀번호
${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', '설정됨')}
` : state(document.createElement('div'), 'empty') || `
비어 있음

메일함이 없습니다

메일함 만들기
`}`; } 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 = '') => `

${label} ${lvlBadge(x.level)}

${unit === 'B' ? B(x.used) : N(x.used)} / ${unit === 'B' ? B(x.allocated) : N(x.allocated)} (${x.percent}%)
${bar(x.used, x.allocated, x.level)}

${extra}

`; root.innerHTML = `${u.usageError ? `
정보 확인 지연

메일 서버 사용량을 읽지 못했습니다(${esc(u.usageError)}). 마지막 계측값을 보여 줍니다. 오래된 정보로 신규 증가를 허용하지 않습니다

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

발송 제한

${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}명`}

메일함별 사용량

${u.mailboxes.map((b) => ``).join('') || ''}
주소사용한도경고
${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(fmt(r.checkedAt) || '')}

${r.items.map((i) => ``).join('')}
항목상태설명
${esc(i.label)}${i.state === 'ok' ? badge('ok', '정상') : i.state === 'recommended' ? badge('warning', '권장 개선') : i.state === 'stale' ? badge('warning', '정보 확인 지연') : badge('failed', '오류')}${esc(i.text)}

필요한 DNS 값

${r.records.map((x) => ``).join('')}
종류이름필요한 값현재 값상태
${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)}
`; } if (tab === 'settings') { const st = await api('GET', `/api/mail/services/${id}/settings`); root.innerHTML = `

POP3 · IMAP

${st.protocols.stale ? '

메일 서버 상태: 정보 확인 지연

' : ''}

${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일 동안 읽기·내보내기만 가능하며 그 뒤 삭제됩니다. 메일 백업·복원 기능은 제공되지 않습니다.

`; 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 = `

적용됨 — 접속 확인: ${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)이 가능해집니다. 다음 항목이 항목별로 유지되는지 확인합니다.

${c.checklist.items.map((i) => ``).join('')}
항목유지내용확인
${esc(i.label)}${i.keep === true ? '유지' : i.keep === false ? '종료' : '조건'}${esc(i.detail)}${i.link ? ` 이동` : ''}${i.ok ? badge('ok', '충족') : badge('failed', i.note || '미충족')}

전환 전후에 실제 송수신을 확인하며 누락이 있으면 완료하지 않고 사이트 수를 반환하지 않습니다.

`; 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 = `

생명주기 — ${esc(l.label)}

공개 서비스
${esc(l.publicService)}
관리자 접근
${esc(l.adminAccess)}
권리 소비
${esc(l.consumes)}
청구
${esc(l.billing)}
도메인·메일
${esc(l.domainMail)}
백업
${esc(l.backup)}
내보내기
${esc(l.exportable)}
재개
${esc(l.resumable)}
되돌릴 수 없는 시점
${esc(fmt(l.irreversibleAt) || '해당 없음')}
보존 종료
${esc(fmt(l.retentionUntil) || '해당 없음')}
삭제 예정일
${esc(fmt(l.deleteAfter) || '해당 없음')}
다음 알림
${esc(fmt(l.nextNotice) || '해당 없음')}
${s.status === 'cancelled' ? '
' : ''}
`; root.querySelector('#exp')?.addEventListener('click', async () => { try { const r = await api('POST', `/api/mail/services/${id}/export`, {}); root.querySelector('#expr').innerHTML = `

${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 = `

남은 메일함 ${c.mailboxes.remaining}개 · 남은 저장 공간 ${B(c.storage.remaining)}

@${esc(domain)}
영문 소문자·숫자로 시작, 1~64자(영문, 숫자, . _ -)

웹메일·POP3·IMAP 접속 안내는 만든 뒤 표시됩니다. ${esc(m.notes.suspendedCounts)}

`; 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 = `

만들었습니다: ${esc(r.mailbox.address)}

${r.initialPassword ? `

초기 비밀번호: ${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)} 로 보냈습니다` : ''}

` : ''}
웹메일
${esc(r.program.webmail)}
IMAP
${esc(r.program.imap.host)} ${r.program.imap.ports.tls}(TLS)/${r.program.imap.ports.starttls}(STARTTLS)
POP3
${esc(r.program.pop3.host)} ${r.program.pop3.ports.tls}(TLS)/${r.program.pop3.ports.starttls}(STARTTLS)
SMTP
${esc(r.program.smtp.host)} ${r.program.smtp.ports.tls}(TLS)/${r.program.smtp.ports.starttls}(STARTTLS)
사용자 이름
${esc(r.mailbox.address)}
메일함 목록으로
`; } catch (err) { f.querySelector('#err').innerHTML = esc(err.message) + (err.detail?.link ? ` 이동` : ''); } }; } 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 = `

상태

상태
${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) : '제한 없음'}` : `${b.quotaBytes ? B(b.quotaBytes) : '제한 없음'} (계측 전)`} ${lvlBadge(b.level)}
마지막 로그인
${esc(fmt(b.lastLoginAt) || '없음')}
비밀번호
${b.mustChangePassword ? '변경 필요(초기·재설정 비밀번호 사용 중)' : '설정됨'}
초대
${d.invite ? `${{ pending: '대기(만료 ' + fmt(d.invite.expires_at) + ')', used: '사용 완료', expired: '만료' }[d.invite.state]}${d.invite.resent_count ? ` · 재발송 ${d.invite.resent_count}회` : ''}` : '없음'}
자동 응답·전달
웹메일 설정에서 관리(기존 AriMail 기능)

메일 프로그램 설정

IMAP
${esc(d.program.imap.host)} ${d.program.imap.ports.tls}(TLS)/${d.program.imap.ports.starttls}(STARTTLS)
POP3
${esc(d.program.pop3.host)} ${d.program.pop3.ports.tls}(TLS)/${d.program.pop3.ports.starttls}(STARTTLS)
SMTP
${esc(d.program.smtp.host)} ${d.program.smtp.ports.tls}(TLS)/${d.program.smtp.ports.starttls}(STARTTLS)
사용자 이름
${esc(b.address)}
비밀번호
다시 표시하지 않습니다
${ro ? '
권한 없음

설정 변경은 그룹 관리자 또는 사이트 "도메인과 메일 설정" 권한이 필요합니다

' : `

설정

별칭·전달 전용 주소

${d.policy.aliases.deferred ? '계산 방식은 최신 상품정책(미확정 — ' + esc(d.notes.policyDeferred) + ')에서 정하며 무제한으로 제공하지 않습니다.' : `정책: 메일함당 ${d.policy.aliases.perMailbox}개`} 이번 버전에서는 생성을 지원하지 않습니다. 전달은 웹메일 설정의 "메일 전달" 을 쓰세요.

보안·정지·삭제

${esc(d.notes.suspendedCounts)}

${b.inviteState === 'pending' || b.inviteState === 'expired' ? '' : ''}
`}`; 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 = `

새 임시 비밀번호: ${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)}

`; } }; } })();