Ariweb Suite 0.1.1 기반: 공통 라이브러리(db·http·세션·작업 엔진·할당/예약·알림·감사·보관소), 포털 뼈대, Webmaker/AriMail 도입 소스와 manifest

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
zzabisagent-lab
2026-10-26 11:02:58 +00:00
co-authored by Claude Fable 5.1
parent 9ca4cee341
commit c7aea8d419
295 changed files with 90146 additions and 0 deletions
+534
View File
@@ -0,0 +1,534 @@
'use strict';
const A=JSON.parse(document.getElementById('strings').textContent);
// BASE(1.0.8): 이 화면의 기준 경로. 서버가 data-base 로 심어 줍니다 — 보통 "/", nginx 가 /mail/ 아래를 게이트웨이로 넘기는 사이트(site.ariweb.net/mail)면 "/mail/".
// 정적 파일·API·화면 사이 링크가 모두 이 값을 앞에 붙이므로 같은 코드가 어느 경로 아래에서도 돌아갑니다. REL 은 BASE 를 뗀 현재 경로(모드 판단용).
const BASE=(document.documentElement.dataset.base||'/').replace(/\/?$/,'/');
const REL=location.pathname.startsWith(BASE)?'/'+location.pathname.slice(BASE.length):location.pathname;
// MODE: 이 화면이 웹 메일(/)인지 관리자 페이지(/admin)인지. 주소로 정하며, 관리자 페이지는 별도 로그인(관리자 세션)을 거칩니다.
// 모든 API 호출에 X-Mail-Scope 헤더로 알려 서버가 알맞은 세션 쿠키를 보게 합니다.
const MODE=/^\/admin\/?$/.test(REL)?'admin':'mail';document.documentElement.dataset.mode=MODE;
// standalone(0.8.18~): www 사이트와 연동하지 않는 독립 설치. 서버가 data-standalone="1" 로 심어 주며, www 관리자 관련 안내를 숨기는 데 씁니다.
// auth(1.0.8): 로그인 방식 local | www | webmaker — 접속 안내의 문구를 고르는 데 씁니다.
const SITE={host:document.documentElement.dataset.host||location.host,domain:document.documentElement.dataset.domain||'',standalone:document.documentElement.dataset.standalone==='1',auth:document.documentElement.dataset.auth||'local',version:document.documentElement.dataset.version||''};
/**
* 배포 뒤 자동 새로고침(0.8.25~). 서버는 모든 응답에 X-App-Version 을 실어 보냅니다. 열어 둔 화면의 버전(SITE.version, 서버가 index.html 에 심음)과 다르면
* 옛 app.js 가 새 API 를 부르다 "처리하지 못했습니다" 로 끝나는 일이 생기므로 화면을 새로 고칩니다. 같은 버전으로 두 번 새로 고치지 않도록 sessionStorage 에 표시해 되풀이를 막습니다.
*/
function checkVersion(v){if(!v||!SITE.version||v===SITE.version)return;try{if(sessionStorage.getItem('mg:reloaded')===v)return;sessionStorage.setItem('mg:reloaded',v);}catch{}location.reload();}
/** 서버 오류 코드를 사람이 읽을 문구로. 아는 코드는 그 문구, 모르는 코드는 일반 문구에 코드(와 detail)를 덧붙여 원인을 추적할 수 있게 합니다(0.8.25). */
function errText(e){const code=e&&e.message||'error';
// 제한 오류(0.9.0): 서버가 limit(적용된 상한)을 붙여 보내면 "<코드>Limit" 문구로 구체적인 수치를 보여 줍니다(예: 받는 사람은 최대 20명).
if(e&&e.limit!==undefined&&A[code+'Limit'])return fill(code+'Limit',{limit:e.limit,mb:mbOf(e.limit),max:/Bytes$/.test(e.field||'')?mbOf(e.limit):String(e.limit),field:e.field&&A['limit_'+e.field]?tr('limit_'+e.field):''});
if(A[code])return tr(code);return fill('errorCode',{code:code+(e&&e.detail?': '+e.detail:'')});}
/** 바이트를 MB 문자열로(소수 1자리, 1MB 미만은 KB). 제한 값·사용량 표시에 씁니다. */
const mbOf=n=>n>=1048576?(n/1048576).toFixed(n%1048576?1:0)+' MB':Math.max(1,Math.round((n||0)/1024))+' KB';
const scopeHeaders=()=>MODE==='admin'?{'X-Mail-Scope':'admin'}:{};
const $=(s,r=document)=>r.querySelector(s),$$=(s,r=document)=>[...r.querySelectorAll(s)];
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const tr=k=>A[k]||k,text=k=>esc(tr(k)),fill=(k,vars)=>Object.entries(vars).reduce((s,[a,b])=>s.replaceAll('{'+a+'}',String(b)),tr(k));
document.querySelectorAll('[data-a]').forEach(e=>e.textContent=tr(e.dataset.a));
const S={me:null,mailboxes:[],mailbox:'',view:'mail',folder:'inbox',filter:'all',page:0,query:'',selected:new Set(),current:null,counts:[],rows:[],total:0,prefs:{remoteImages:false},status:null,syncBusy:false,quota:0,signupEnabled:false};
let viewVersion=0;
const addr=v=>{const m=String(v||'').match(/<([^<>]+)>\s*$/);return (m?m[1]:String(v||'')).trim();};
const nameOf=v=>{const s=String(v||'').trim();const m=s.match(/^"?(.*?)"?\s*<[^<>]+>$/);return m&&m[1]?m[1].trim():s.split('@')[0];};
const initial=v=>{const n=nameOf(v);return (n[0]||'?').toUpperCase();};
const colorOf=v=>'c'+([...String(v||'')].reduce((a,c)=>a+c.charCodeAt(0),0)%6);
function when(iso){if(!iso)return '';const d=new Date(iso),n=new Date();const p=x=>String(x).padStart(2,'0');if(d.toDateString()===n.toDateString())return p(d.getHours())+':'+p(d.getMinutes());if(d.getFullYear()===n.getFullYear())return (d.getMonth()+1)+'월 '+d.getDate()+'일';return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate());}
const fullDate=iso=>iso?new Date(iso).toLocaleString('ko-KR',{dateStyle:'medium',timeStyle:'short'}):'';
const kb=n=>n>1048576?(n/1048576).toFixed(1)+' MB':Math.max(1,Math.round((n||0)/1024))+' KB';
function notify(key,detail=''){const el=$('#feedback');el.textContent=tr(key)+(detail?' ('+detail+')':'');clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},4000);}
async function api(path,method='GET',body){const version=viewVersion;const r=await fetch(BASE+'api/'+path,{method,headers:{...scopeHeaders(),...(body instanceof FormData||body===undefined?{}:{'Content-Type':'application/json'})},body:body instanceof FormData?body:body===undefined?undefined:JSON.stringify(body)});checkVersion(r.headers.get('x-app-version'));const d=await r.json().catch(()=>({error:'error'}));if(method==='GET'&&version!==viewVersion)return new Promise(()=>{});if(!r.ok){if(r.status===401&&path!=='login'){showLogin();return new Promise(()=>{});}const err=new Error(d.error||'error');if(d.detail)err.detail=String(d.detail).slice(0,300);if(d.problems){err.problems=d.problems;err.policy=d.policy;}if(d.limit!==undefined)err.limit=d.limit;if(d.field)err.field=d.field;throw err;}return d;}
const fail=e=>{const el=$('#feedback');el.textContent=errText(e)+(A[e&&e.message]&&e.detail?' ('+e.detail+')':'');clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},6000);};
// 메일 상태에 맞는 이동 대상 폴더(서버 api.mjs 의 movableFolders 와 같은 규칙). 받은 메일 → 받은 메일함·휴지통,
// 발송한 메일 → 보낸 메일함·휴지통, 초안·발송 실패 → 임시 보관함·휴지통.
const movable=m=>m.direction==='inbound'?['inbox','trash']:m.state==='sent'?['sent','trash']:['drafts','trash'];
// 0.9.0: 관리자의 다른 메일함 열람(?mailbox=)을 없앴습니다. 모든 메일 API 는 로그인한 계정의 메일함만 다룹니다.
// ------------------------------------------------------------------ 세션
function showLogin(){viewVersion++;S.me=null;$('#compose').open&&$('#compose').close();$('#detail').open&&$('#detail').close();$('#workspace').hidden=true;$('#login').hidden=false;const mode=$('#login-mode');mode.hidden=MODE!=='admin';if(MODE==='admin')mode.textContent=tr('adminLoginTitle');
// 회원 가입(0.9.0): 관리자가 켜 두었을 때만 로그인 단추 아래에 "회원 가입" 을 보여 줍니다(관리자 페이지 로그인에는 없음).
$('#signup-btn').hidden=!(S.signupEnabled&&MODE==='mail');$('#login input').focus();}
/**
* 회원 가입 창(0.9.0). 로그인 ID·이름·비밀번호(2회, 눈 단추)를 받아 POST /api/signup 으로 보냅니다.
* 결과의 pending 이 true 면 "관리자 승인 후 사용" 안내, 아니면 바로 로그인하라고 안내합니다. 오류(ID 중복·정책 위반·계정 수 제한)는 창 안에 보여 줍니다.
*/
function signupDialog(){$('#dialog-title').textContent=tr('signupTitle');$('#dialog-feedback').textContent='';
$('#dialog-body').innerHTML=`<form id="signup-form"><p class="muted">${esc(fill('signupIntro',{domain:SITE.domain}))}</p><div class="grid2"><label><span>${text('userLogin')}</span><input name="login" required autocomplete="username" pattern="[a-z0-9][a-z0-9._\\-]{1,63}" autocapitalize="off"></label><label><span>${text('userName')}</span><input name="name" autocomplete="name"></label><label><span>${text('userMailbox')}</span><input id="signup-mailbox" value="@${esc(SITE.domain)}" readonly tabindex="-1"></label><span></span>${pwField('password',text('userPassword'),'autocomplete="new-password" required')}${pwField('passwordConfirm',text('userPasswordConfirm'),'autocomplete="new-password" required')}</div><p class="meta">${text('signupHelp')}</p><div class="actions"><button class="primary" type="submit">${text('signupSubmit')}</button><button type="button" class="ghost" id="signup-cancel">${text('close')}</button></div></form>`;
const dlg=$('#detail'),form=$('#signup-form');bindEyes(form);$('#signup-cancel').onclick=()=>dlg.close();
form.elements.login.oninput=()=>{$('#signup-mailbox').value=form.elements.login.value.trim().toLowerCase()+'@'+SITE.domain;};
form.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',form);b.disabled=true;$('#dialog-feedback').textContent='';try{const f=form.elements;if(f.password.value!==f.passwordConfirm.value){$('#dialog-feedback').textContent=tr('password_mismatch');return;}
const r=await api('signup','POST',{login:f.login.value,name:f.name.value,password:f.password.value,passwordConfirm:f.passwordConfirm.value});dlg.close();$('#login-feedback').textContent=tr(r.pending?'signupPending':'signupDone');$('#login-feedback').classList.add('info');$('#login input').value=f.login.value.trim().toLowerCase();}
catch(err){$('#dialog-feedback').textContent=passwordError(err);}finally{b.disabled=false;}};
if(!dlg.open)dlg.showModal();}
$('#signup-btn').onclick=signupDialog;
/** 비밀번호 입력 칸 + 눈 단추(0.8.25~, 0.9.0 에서 공용 도우미로). bindEyes(form) 가 단추를 묶어 글자를 보이거나 숨깁니다. */
const pwField=(name,label,attrs='')=>`<label><span>${label}</span><span class="pw-wrap"><input name="${name}" type="password" ${attrs}><button type="button" class="eye" data-eye="${name}" aria-label="${text('showPassword')}" title="${text('showPassword')}">👁</button></span></label>`;
function bindEyes(form){$$('[data-eye]',form).forEach(btn=>btn.onclick=()=>{const i=form.elements[btn.dataset.eye];const show=i.type==='password';i.type=show?'text':'password';btn.textContent=show?'🙈':'👁';btn.title=btn.ariaLabel=tr(show?'hidePassword':'showPassword');i.focus();});}
// 로그인 화면 오른쪽 위 (?) — 이 도메인의 메일 서비스라는 점과, 부여받은 계정 또는 도메인을 포함한 이메일 주소로 로그인하며
// 웹 접속 계정과 같은 아이디·비밀번호를 쓴다는 안내를 팝업으로 보여 줍니다. 문구의 {host}/{domain}/{www} 는 서버가 심어 둔 값입니다.
function loginHelp(){const v={host:SITE.host,domain:SITE.domain,www:'www.'+SITE.domain,example:'info'};
// 3번 항목: www 연동이면 www 관리자 안내, 웹메이커 연동(1.0.8)이면 웹메이커 계정 안내, 독립 실행이면 없음.
const items=[1,2,3,4,5].filter(n=>!(n===3&&SITE.standalone&&SITE.auth!=='webmaker')).map(n=>`<li>${esc(fill(n===3&&SITE.auth==='webmaker'?'loginHelpWebmaker':'loginHelp'+n,v))}</li>`).join('');
$('#dialog-title').textContent=fill('loginHelpTitle',v);$('#dialog-feedback').textContent='';$('#dialog-body').innerHTML=`<p>${esc(fill('loginHelpIntro',v))}</p><ol class="steps">${items}</ol>${MODE==='admin'?`<p class="muted">${esc(fill('adminLoginHelp',v))}</p>`:''}<p class="meta">${esc(fill('loginHelpFooter',v))}</p>`;const dlg=$('#detail');if(!dlg.open)dlg.showModal();}
$('#login-help').onclick=loginHelp;
async function start(){try{const res=await fetch(BASE+'api/session',{cache:'no-store',headers:scopeHeaders()});checkVersion(res.headers.get('x-app-version'));const d=await res.json();S.signupEnabled=!!d.signup;if(!d.user){showLogin();return;}S.me=d.user;S.mailboxes=[d.mailbox];S.mailbox=d.mailbox;S.prefs={remoteImages:false,pop:true,imap:true,...(d.prefs||{})};S.autoReplyActive=!!d.autoReplyActive;S.initialPassword=!!d.initialPassword;S.largeFiles=d.largeFiles||{enabled:false};S.attachmentBytes=d.attachmentBytes||0;$('#login').hidden=true;$('#workspace').hidden=false;
// 관리자 페이지: 메일함 대신 계정 관리·서버 상태만 보여 줍니다. 검색·메일 쓰기·폴더는 CSS([data-mode=admin])로 숨깁니다.
if(MODE==='admin'){$('#brand-mode').hidden=false;$('#brand-mode').textContent=' · '+tr('adminTitle');$('#whoami').textContent=(S.me.name||S.me.login)+' · '+tr('adminTitle');S.view='admin';renderSidebar();await adminPage();return;}
// 0.9.0: 메일함 선택(관리자의 다른 메일함 열람)을 없앴습니다. 관리자도 자기 메일함만 봅니다.
$('#whoami').textContent=(S.me.name||S.me.login)+' · '+S.me.mailbox;renderSidebar();await renderMail(true);}catch(e){showLogin();}}
$('#login').onsubmit=async e=>{e.preventDefault();const f=e.target,b=$('button[type=submit],button:not([type])',f);b.disabled=true;$('#login-feedback').textContent='';$('#login-feedback').classList.remove('info');try{await api('login','POST',Object.fromEntries(new FormData(f)));f.reset();await start();}catch(err){$('#login-feedback').textContent=errText(err);f.elements.password.value='';}finally{b.disabled=false;}};
$('#logout').onclick=async()=>{await fetch(BASE+'api/session',{method:'DELETE',headers:scopeHeaders()});location.replace(MODE==='admin'?BASE+'admin':BASE);};
$('#menu-toggle').onclick=()=>$('#sidebar').classList.toggle('open');
document.addEventListener('click',e=>{if(window.innerWidth<=900&&!e.target.closest('#sidebar')&&!e.target.closest('#menu-toggle'))$('#sidebar').classList.remove('open');});
$('#global-search').placeholder=tr('searchPlaceholder');
const doSearch=()=>{S.query=$('#global-search').value.trim();S.page=0;S.view='mail';renderMail();};
$('#global-search-go').onclick=doSearch;$('#global-search').onkeydown=e=>{if(e.key==='Enter')doSearch();};
$('#compose-btn').onclick=()=>openCompose({});
$('.close',$('#detail')).onclick=()=>$('#detail').close();
// ------------------------------------------------------------------ 사이드바
const FOLDERS=[['inbox','📥','folder_inbox'],['starred','⭐','folder_starred'],['sent','📤','folder_sent'],['drafts','📝','folder_drafts'],['trash','🗑','folder_trash']];
function renderSidebar(){
// 관리자 페이지 모드: 왼쪽에는 계정 관리·서버 상태·메일함 열기만 둡니다.
if(MODE==='admin'){$('#tool-nav').innerHTML=`<button data-view="admin" aria-current="${S.view==='admin'}"><span class="ic">👥</span>${text('nav_admin')}</button><button data-view="status" aria-current="${S.view==='status'}"><span class="ic">📊</span>${text('nav_status')}</button><a class="nav-link" href="${BASE}setup"><span class="ic">🛠</span>${text('nav_mailConfig')}</a><a class="nav-link" href="${BASE}" target="_blank" rel="noopener"><span class="ic">✉</span>${text('openMailbox')}</a>`;$$('[data-view]').forEach(b=>b.onclick=()=>{S.view=b.dataset.view;renderSidebar();S.view==='status'?statusPage():adminPage();});return;}
const count=f=>S.counts.find(c=>c.folder===f);
$('#folder-nav').innerHTML=FOLDERS.map(([f,ic,label])=>{const c=f==='inbox'?count('inbox')?.unread:f==='drafts'?count('drafts')?.n:0;return `<button data-folder="${f}" aria-current="${S.view==='mail'&&S.folder===f}"><span class="ic">${ic}</span>${text(label)}${c?`<span class="badge">${c}</span>`:''}</button>`;}).join('');
$('#filter-nav').innerHTML=[['all','filterAll'],['unread','filterUnread'],['attach','filterAttachments']].map(([k,l])=>`<button data-filter="${k}" aria-current="${S.filter===k}">${text(l)}</button>`).join('');
// 관리자에게는 관리자 페이지(/admin) 링크만 둡니다. 계정 관리는 거기서 따로 로그인한 뒤 씁니다(0.8.4~).
$('#tool-nav').innerHTML=`<button data-view="settings" aria-current="${S.view==='settings'}"><span class="ic">⚙</span>${text('nav_settings')}</button>${S.me.role==='admin'?`<a class="nav-link" href="${BASE}admin" target="_blank" rel="noopener"><span class="ic">👥</span>${text('nav_adminPage')} ↗</a>`:''}`;
// 폴더를 누르면 검색어도 지웁니다(0.9.9). 예전에는 검색 뒤 다른 폴더를 눌러도 검색어가 남아 "0–0 / 0" 으로 비어 보였습니다(검색 칸의 글자가 유일한 단서).
$$('[data-folder]').forEach(b=>b.onclick=()=>{S.view='mail';S.folder=b.dataset.folder;S.page=0;S.query='';$('#global-search').value='';S.selected.clear();S.current=null;$('#read-pane').classList.remove('open');renderMail();});
$$('[data-filter]').forEach(b=>b.onclick=()=>{S.filter=b.dataset.filter;S.page=0;S.view='mail';renderMail();});
$$('[data-view]').forEach(b=>b.onclick=()=>{S.view=b.dataset.view;renderSidebar();settingsPage();});
// 자동 처리 상태(0.8.16): 전달·자동 응답이 켜져 있으면 사이드바 아래에 알려 줍니다(켜 둔 것을 잊고 메일이 계속 나가는 일을 막기 위해).
const auto=`${S.prefs.forward?.enabled?`<div class="auto-note" title="${esc(S.prefs.forward.to)}">↪ ${text('sidebarForward')}</div>`:''}${S.autoReplyActive?`<div class="auto-note">⏰ ${text('sidebarAutoReply')}</div>`:''}${S.initialPassword?`<div class="auto-note warn" title="${esc(tr('initialPasswordWarn'))}">⚠ ${text('initialPasswordShort')}</div>`:''}`;
// 사용량(0.9.0): MIME 기준 사용량과 적용 중인 메일함 최대 크기(quota, 0 = 제한 없음). 제한이 있으면 막대와 비율을, 90% 이상이면 경고색을 보여 줍니다.
if(S.usage!==undefined){const q=S.quota||0,pct=q?Math.min(100,(S.usage||0)/q*100):0;$('#usage').innerHTML=`<div>${text('usageLabel')} · ${kb(S.usage)}${q?' / '+kb(q):''}</div>${q?`<div class="bar ${pct>=90?'full':''}"><i style="width:${pct.toFixed(1)}%"></i></div><div>${esc(fill('quotaPercent',{percent:Math.round(pct)}))}</div>`:`<div>${text('quotaUnlimited')}</div>`}<div>${text('retentionShort')}</div>${auto}`;}
}
// ------------------------------------------------------------------ 목록
function showPanes(page){$('#page-pane').hidden=!page;$('#list-pane').hidden=!!page;$('#read-pane').hidden=!!page;$('#rz-list').hidden=!!page;}
async function renderMail(sync=false){
viewVersion++;const version=viewVersion;showPanes(false);renderSidebar();
if(sync&&!S.syncBusy){S.syncBusy=true;api('mail/sync','POST',{quick:true}).then(r=>{if(r.started)setTimeout(()=>{if(S.view==='mail'&&S.me)renderMail();},8000);}).catch(()=>{}).finally(()=>{S.syncBusy=false;});}
const folder=S.folder==='starred'?'inbox':S.folder;const q=`mail?folder=${folder}&page=${S.page}&q=${encodeURIComponent(S.query)}${S.filter==='unread'?'&unread=1':''}${S.filter==='attach'?'&attach=1':''}${S.folder==='starred'?'&flagged=1':''}`;
const [d,status]=await Promise.all([api(q),S.status?Promise.resolve(S.status):api('mail/status').catch(()=>null)]);if(version!==viewVersion)return;
// 마지막 페이지의 메일을 모두 지우거나 옮기면 범위를 벗어난 페이지("51–50 / 50")에 남습니다. 마지막 유효 페이지로 돌아가 다시 그립니다.
if(S.page>0&&S.page*d.size>=d.total){S.page=Math.max(0,Math.ceil(d.total/d.size)-1);return renderMail();}
// 첨부 필터는 서버(attach=1)가 걸러 주므로 받은 목록을 그대로 씁니다(예전에는 한 페이지 안에서만 걸러 합계와 어긋났음).
S.status=status;S.counts=d.counts;S.total=d.total;S.usage=d.usageBytes;S.quota=d.quotaBytes||0;S.rows=d.messages;renderSidebar();
// 연결 오류 표시: 문구는 오류 코드별(ko.json), 서버가 만든 원인 문장(detail — 1.0.11: 검증되지 않은 도메인이면 어느 DNS 레코드가 다른지)은 마우스를 올리면 보입니다. 전체 문장은 관리자 › 서버 상태.
const statusTag=status?status.provider==='ok'?`<span class="tag ok">${text('mailServiceOk')}</span>`:`<span class="tag warn" title="${esc(status.detail||'')}">${text('mailServiceError')}: ${text(status.provider)}</span>`:'';
$('#list-pane').innerHTML=`<div class="list-head"><label class="select-all"><input type="checkbox" id="select-all" aria-label="${esc(tr('selectAll'))}" title="${esc(tr('selectAll'))}"></label><span class="folder-name">${text('folder_'+S.folder)}</span>${S.query?`<span class="tag search-chip" title="${esc(tr('searchClear'))}">${esc(fill('searchChip',{q:S.query}))}<button type="button" id="search-clear" aria-label="${esc(tr('searchClear'))}">✕</button></span>`:''}<span id="sel-count"></span><span class="spacer"></span>${d.syncing?`<span class="tag">${text('syncing')}</span>`:''}${statusTag}</div><div class="list-toolbar"><button id="act-refresh" title="${esc(tr('refresh'))}">↻</button><span class="spacer"></span>${S.folder==='trash'?`<button id="act-restore">${text('actionRestore')}</button><button id="act-purge" class="danger">${text('actionDeleteForever')}</button>`:`<button id="act-trash">🗑 ${text('actionDelete')}</button>`}<button id="act-read">${text('actionMarkRead')}</button><button id="act-unread">${text('actionMarkUnread')}</button><select id="act-move" aria-label="${esc(tr('actionMove'))}"><option value="">${text('actionMove')}…</option>${['inbox','sent','drafts','trash'].filter(f=>f!==folder).map(f=>`<option value="${f}">${text('folder_'+f)}</option>`).join('')}</select></div><div class="mail-list" id="mail-list">${S.rows.map(row).join('')||`<div class="empty"><div class="big">📭</div>${text('emptyFolder')}</div>`}</div><div class="pager"><button id="pg-prev" ${S.page===0?'disabled':''}>‹</button><span>${S.page*d.size+Math.min(1,d.total)}–${Math.min((S.page+1)*d.size,d.total)} / ${d.total}</span><button id="pg-next" ${(S.page+1)*d.size>=d.total?'disabled':''}>›</button></div>`;
$('#act-refresh').onclick=()=>renderMail(true);$('#pg-prev').onclick=()=>{S.page--;renderMail();};$('#pg-next').onclick=()=>{S.page++;renderMail();};
$('#select-all').onchange=e=>{S.selected=new Set(e.target.checked?S.rows.map(m=>m.id):[]);$$('.mail-row input[type=checkbox]').forEach(c=>c.checked=e.target.checked);selCount();};
// 검색 칩(0.9.9): 검색 중이면 목록 머리에 "검색: …" 과 ✕ 가 보이고, ✕ 를 누르면 검색을 풀고 폴더 전체를 다시 보여 줍니다.
if($('#search-clear'))$('#search-clear').onclick=()=>{S.query='';$('#global-search').value='';S.page=0;renderMail();};
$$('.mail-row').forEach(r=>{r.onclick=e=>{if(e.target.closest('input,button'))return;openMessage(r.dataset.id);};$('input[type=checkbox]',r).onchange=e=>{e.target.checked?S.selected.add(r.dataset.id):S.selected.delete(r.dataset.id);selCount();};// 별표 전환. e.currentTarget 은 await 뒤에 null 이 되므로(이벤트 처리가 끝나면 비워짐) 버튼을 btn 에 먼저 잡아 둡니다.
$('.star',r).onclick=async e=>{e.stopPropagation();const btn=e.currentTarget,on=!btn.classList.contains('on');try{await api('mail/'+r.dataset.id,'PATCH',{flagged:on});if(S.current===r.dataset.id)openMessage(r.dataset.id);btn.classList.toggle('on',on);btn.textContent=on?'★':'☆';}catch(err){fail(err);}};});
const bulk=async(body,confirmKey)=>{if(!S.selected.size)return;if(confirmKey&&!confirm(tr(confirmKey)))return;const ids=[...S.selected];try{const r=await api('mail/bulk','POST',{ids,...body});S.selected.clear();if(body.action==='move'&&r.done<ids.length)notify('moveSkipped');const affected=S.current&&ids.includes(S.current);if(affected&&['trash','restore','delete','move'].includes(body.action)){S.current=null;emptyRead();}await renderMail();// 열려 있던 메일을 다시 열어 갱신합니다. 단 읽음/읽지 않음 처리 뒤에는 다시 열지 않습니다(다시 열면 서버가 읽음으로 되돌려 "읽지 않음"이 유지되지 않았음).
if(affected&&S.current&&!['read','unread'].includes(body.action))openMessage(S.current);}catch(e){fail(e);}};
if($('#act-trash'))$('#act-trash').onclick=()=>bulk({action:'trash'});if($('#act-restore'))$('#act-restore').onclick=()=>bulk({action:'restore'});if($('#act-purge'))$('#act-purge').onclick=()=>bulk({action:'delete'},'confirmDeleteForever');
$('#act-read').onclick=()=>bulk({action:'read'});$('#act-unread').onclick=()=>bulk({action:'unread'});$('#act-move').onchange=e=>{if(e.target.value)bulk({action:'move',target:e.target.value});};
selCount();if(S.current&&d.messages.find(m=>m.id===S.current))$(`.mail-row[data-id="${S.current}"]`)?.setAttribute('aria-selected','true');else if(S.current&&S.filter==='all'&&!S.query&&d.total<=d.size){S.current=null;emptyRead();}else if(!S.current)emptyRead();
}
async function refreshCounts(){try{const d=await api('mail/counts');S.counts=d.counts;S.usage=d.usageBytes;S.quota=d.quotaBytes||0;renderSidebar();}catch{}}
function selCount(){$('#sel-count').textContent=S.selected.size?fill('selected',{count:S.selected.size}):'';}
function row(m){const who=m.direction==='inbound'?m.sender:JSON.parse(m.recipients_json||'[]').join(', ');const unread=!m.read_at&&m.direction==='inbound';return `<div class="mail-row ${unread?'unread':''}" data-id="${esc(m.id)}" aria-selected="${m.id===S.current}"><input type="checkbox" ${S.selected.has(m.id)?'checked':''} aria-label="select"><div class="avatar ${colorOf(addr(who))}">${esc(initial(who))}</div><span class="who">${esc(m.direction==='inbound'?nameOf(who):(tr('toMe')+': '+who))}</span><time>${esc(when(m.received_at))}</time><span class="subject">${esc(m.subject||tr('noSubject'))}</span><span class="icons">${m.attachment_count?'📎':''}${m.state==='failed'?' ⚠':''}${m.pop_delivered?' <span class="tag">POP</span>':''}</span><span class="snippet">${esc(m.preview||'')}</span><button class="star ${m.flagged?'on':''}" title="${esc(tr('actionStar'))}">${m.flagged?'★':'☆'}</button></div>`;}
// ------------------------------------------------------------------ 읽기
// 자동 처리 기록 한 줄(0.8.16): "전달됨 → 주소 (시각)", "자동 응답 보냄 → 주소", 건너뛴 경우는 이유(되풀이 방지·오래된 메일·자동 발송 메일 …)를 풀어 씁니다.
function autoLine(a){const reason=a.detail&&A['reason_'+a.detail]?tr('reason_'+a.detail):a.detail||'';const key='auto_'+a.kind+'_'+a.status;return esc(fill(A[key]?key:'auto_pending',{target:a.target||'',detail:reason}))+' <span class="meta">'+esc(fullDate(a.created_at))+'</span>';}
function emptyRead(){$('#read-pane').innerHTML=`<div class="empty"><div class="big">✉</div>${text('noSelection')}</div>`;$('#read-pane').classList.remove('open');}
function frame(html,allowRemote){const t=document.createElement('template');t.innerHTML=html;let remote=0;t.content.querySelectorAll('img').forEach(img=>{try{if(new URL(img.getAttribute('src'),location.href).origin!==location.origin){remote++;if(!allowRemote)img.removeAttribute('src');}}catch{img.removeAttribute('src');}});const doc='<!doctype html><html><head><meta http-equiv="Content-Security-Policy" content="default-src \'none\'; style-src \'unsafe-inline\'; img-src data: '+location.origin+(allowRemote?' https:':'')+'; base-uri \'none\'; form-action \'none\'"><style>body{font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif;color:#1d2733;overflow-wrap:anywhere;margin:8px 2px}img{max-width:100%;height:auto}blockquote{border-left:3px solid #c8d2e0;margin:8px 0;padding-left:12px;color:#4a5566}a{color:#2b64c8}table{max-width:100%}</style></head><body>'+t.innerHTML+'</body></html>';return {html:`<iframe class="mail-frame" sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox" referrerpolicy="no-referrer" title="${esc(tr('mailBody'))}" srcdoc="${esc(doc)}"></iframe>`,remote};}
// 파일 첨부인지(본문에 끼운 그림이 아닌지). Content-ID 가 없는 inline 은 본문에서 참조할 수 없으므로 일반 첨부로 봅니다(0.9.9).
// Apple Mail 등은 PDF 를 "inline; filename=…" 으로 보내는데, 예전에는 읽기 창에는 칩이 보이면서 전달 창에서는 빠졌습니다.
const isFile=a=>a.disposition!=='inline'||!a.content_id;
async function openMessage(id){
try{S.current=id;$$('.mail-row').forEach(r=>r.setAttribute('aria-selected',String(r.dataset.id===id)));
const m=(await api('mail/'+id)).message;
// 경쟁 방지(0.9.9): 느린 응답 사이에 다른 줄을 눌렀으면(S.current 가 바뀜) 이 응답은 버립니다. 예전에는 먼저 누른 메일의 늦은 응답이 나중에 그려져,
// 목록은 두 번째 메일이 선택돼 있는데 읽기 창은 첫 메일을 보여 주고 답장·삭제 단추도 첫 메일에 동작했습니다.
if(S.current!==id)return;
// 초안 상태(또는 발송 실패)인 메일만 작성 창으로 엽니다. 임시 보관함에 들어온 받은 메일(옛 IMAP MOVE 등)은 보통 읽기 창으로 보여 줍니다(0.8.24).
// 휴지통에 있는 메일은 발송 실패한 메일이라도 읽기 창(복원·영구 삭제)으로 엽니다. 예전에는 잠긴 작성 창이 열려 할 수 있는 일이 없었습니다(0.8.25).
if(m.folder!=='trash'&&((m.folder==='drafts'&&m.state==='draft')||m.state==='failed')){openCompose(m);return;}
const rowEl=$(`.mail-row[data-id="${id}"]`);const wasUnread=rowEl&&rowEl.classList.contains('unread');if(rowEl)rowEl.classList.remove('unread');const rowData=S.rows.find(x=>x.id===id);if(rowData&&!rowData.read_at)rowData.read_at=new Date().toISOString();
if(wasUnread){const c=S.counts.find(x=>x.folder===m.folder);if(c&&c.unread>0)c.unread--;renderSidebar();refreshCounts();}
const to=JSON.parse(m.recipients_json||'[]'),cc=JSON.parse(m.cc_json||'[]'),replyTo=JSON.parse(m.reply_to_json||'[]');let details=false,remote=!!S.prefs.remoteImages;
const paint=()=>{if(S.current!==id)return;const body=frame(m.previewHtml||m.html||'<p>'+esc(m.text)+'</p>',remote);
$('#read-pane').innerHTML=`<div class="read-head"><div class="read-actions"><button id="rd-back" class="ghost" style="display:none">‹ ${text('back')}</button><button id="rd-reply" class="primary">↩ ${text('reply')}</button><button id="rd-reply-all">↩↩ ${text('replyAll')}</button><button id="rd-forward">↪ ${text('forward')}</button>${m.folder==='trash'?`<button id="rd-restore">${text('restore')}</button><button id="rd-purge" class="danger">${text('deleteForever')}</button>`:`<button id="rd-trash">🗑 ${text('actionDelete')}</button>`}<button id="rd-star" class="${m.flagged?'primary':''}">${m.flagged?'★ '+text('actionUnstar'):'☆ '+text('actionStar')}</button>${m.direction==='inbound'?`<button id="rd-unread">${text('markUnread')}</button>`:''}<select id="rd-move" aria-label="${esc(tr('actionMove'))}"><option value="">${text('actionMove')}…</option>${movable(m).filter(f=>f!==m.folder).map(f=>`<option value="${f}">${text('folder_'+f)}</option>`).join('')}</select><a class="chip" href="${BASE}api/mail/${encodeURIComponent(id)}/original" download>⬇ ${text('original')}</a></div><h2>${esc(m.subject||tr('noSubject'))}${m.flagged?` <span class="tag accent">${text('flagged')}</span>`:''}${m.answered?` <span class="tag">${text('answered')}</span>`:''}</h2><div class="sender-line"><div class="avatar ${colorOf(addr(m.sender))}">${esc(initial(m.sender))}</div><div class="lines"><div class="name">${esc(nameOf(m.sender))}</div><div class="addr">&lt;${esc(addr(m.sender))}&gt; → ${esc(to.join(', '))}${cc.length?' · cc '+esc(cc.join(', ')):''} <button class="ghost" id="rd-details" style="min-height:0;padding:0 6px;font-size:12px">${text(details?'hideDetails':'details')}</button></div></div><time>${esc(fullDate(m.received_at))}</time></div>${details?`<div class="detail-box"><span>${text('from')}</span><span>${esc(m.sender)}</span><span>${text('to')}</span><span>${esc(to.join(', '))}</span>${cc.length?`<span>${text('cc')}</span><span>${esc(cc.join(', '))}</span>`:''}${replyTo.length?`<span>Reply-To</span><span>${esc(replyTo.join(', '))}</span>`:''}<span>${text('receivedAt')}</span><span>${esc(fullDate(m.received_at))} · ${text('source_'+m.source)} · ${text(m.raw_status==='stored'?'originalStored':m.raw_status==='built'?'originalBuilt':'originalNone')}</span><span>POP</span><span>${m.popDeliveries.length?m.popDeliveries.map(p=>esc(fullDate(p.delivered_at))+' ('+esc(p.via)+')').join(', ')+` <button class="ghost" id="rd-pop-reset" style="min-height:0;padding:0 6px;font-size:12px">${text('popReset')}</button>`:text('popNotDelivered')}</span>${m.message_id?`<span>Message-ID</span><span>${esc(m.message_id)}</span>`:''}${(m.autoActions||[]).length?`<span>${text('autoActions')}</span><span>${m.autoActions.map(autoLine).join('<br>')}</span>`:''}</div>`:''}</div><div class="read-body">${body.remote?`<button type="button" id="rd-remote" class="corner-btn" title="${esc(tr(remote?'hideRemote':'remoteBlocked'))}">${remote?'🚫 '+text('hideRemoteShort'):'🖼 '+text('showRemoteShort')+' ('+body.remote+')'}</button>`:''}${body.html}</div>${m.attachments.length?`<div class="attach-list">${m.attachments.map(a=>a.available?`<a class="chip" href="${BASE}api/mail/${encodeURIComponent(id)}/attachments/${encodeURIComponent(a.id)}" download>📎 ${esc(a.filename)} <span class="sz">${kb(a.size)}</span></a>`:`<span class="chip">📎 ${esc(a.filename)} <span class="tag warn">${text('attachmentUnavailable')}</span></span>`).join('')}</div>`:''}${(m.largeFiles||[]).length?`<div class="attach-list large-list"><span class="meta">☁ ${text('largeFileHeading')}</span>${m.largeFiles.map(f=>`<span class="chip large ${f.expired?'off':''}">☁ ${esc(f.filename)} <span class="sz">${kb(f.size)}</span> <span class="tag ${f.expired?'':'ok'}">${f.expired?text('largeExpired'):esc(fill('largeStatus',{remaining:f.remaining,max:f.max_downloads,until:fullDate(f.expires_at)}))}</span>${f.expired?'':`<button type="button" data-revoke="${esc(f.id)}" title="${esc(tr('largeRevoke'))}">✕</button>`}</span>`).join('')}</div>`:''}`;
$$('[data-revoke]').forEach(b=>b.onclick=async()=>{if(!confirm(tr('confirmRevoke')))return;try{await api('mail/'+id+'/files/'+b.dataset.revoke,'DELETE');openMessage(id);}catch(e){fail(e);}});
$('#read-pane').classList.add('open');if(window.innerWidth<=900){$('#rd-back').style.display='';$('#rd-back').onclick=()=>{$('#read-pane').classList.remove('open');};}
const fr=$('.mail-frame');fr.onload=()=>{try{fr.style.height=Math.max(300,fr.contentDocument.body.scrollHeight+30)+'px';}catch{}};
$('#rd-details').onclick=()=>{details=!details;paint();};if($('#rd-remote'))$('#rd-remote').onclick=()=>{remote=!remote;paint();};
const act=async(body,confirmKey,after)=>{if(confirmKey&&!confirm(tr(confirmKey)))return;try{await api('mail/'+id,body.action==='delete'?'DELETE':'PATCH',body.action==='delete'?undefined:body);if(after==='close'){S.current=null;emptyRead();}await renderMail();if(after==='reopen')openMessage(id);}catch(e){fail(e);}};
if($('#rd-trash'))$('#rd-trash').onclick=()=>act({folder:'trash'},null,'close');if($('#rd-restore'))$('#rd-restore').onclick=()=>act({restore:true},null,'close');if($('#rd-purge'))$('#rd-purge').onclick=()=>act({action:'delete'},'confirmDeleteForever','close');
$('#rd-star').onclick=()=>act({flagged:!m.flagged},null,'reopen');if($('#rd-unread'))$('#rd-unread').onclick=()=>act({unread:true},null,'close');$('#rd-move').onchange=e=>{if(e.target.value)act({action:'move',target:e.target.value},null,'close');};
if($('#rd-pop-reset'))$('#rd-pop-reset').onclick=()=>act({popReset:true},'popResetConfirm','reopen');
// 답장·전달 인용문. 읽기 창에서 외부 이미지를 막아 둔 상태(remote=false)면 인용문에서도 외부 이미지 주소를 지웁니다.
// 편집기는 iframe 이 아니라 이 페이지 안에 있으므로, 그대로 넣으면 답장 버튼을 누르는 순간 추적 이미지가 불러와졌습니다.
const quoteBody=()=>{const html=m.previewHtml||m.html||esc(m.text).replaceAll('\n','<br>');if(remote)return html;const t=document.createElement('template');t.innerHTML=html;t.content.querySelectorAll('img').forEach(img=>{try{if(new URL(img.getAttribute('src'),location.href).origin!==location.origin)img.removeAttribute('src');}catch{img.removeAttribute('src');}});return t.innerHTML;};
const quoted=`<p><br></p><p><br></p><blockquote>${esc(fill('quoteHeader',{date:fullDate(m.received_at),from:m.sender}))}<br>${quoteBody()}</blockquote>`;
const targets=m.direction==='inbound'?(replyTo.length?replyTo:[addr(m.sender)]):to;const re=(/^re:/i.test(m.subject)?'':'Re: ')+(m.subject||'');
// 전체 답장의 참조 목록(0.9.9): 내 주소와 받는 사람(targets)을 뺀 나머지인데, 비교는 소문자로 하고 같은 주소는 한 번만 넣습니다.
// 예전에는 대소문자만 다른 주소(From: Bob@Example.com, Cc: bob@example.com)가 받는 사람과 참조에 모두 들어가 두 번 발송됐습니다.
const lower=a=>String(a||'').toLowerCase();const ccList=()=>{const seen=new Set([S.mailbox,...targets].map(lower));return [...to,...cc].map(addr).filter(a=>{if(!a||seen.has(lower(a)))return false;seen.add(lower(a));return true;});};
$('#rd-reply').onclick=()=>openCompose({recipients_json:JSON.stringify(targets),subject:re,html:quoted,inReplyTo:m.message_id,title:'replyTitle'});
$('#rd-reply-all').onclick=()=>openCompose({recipients_json:JSON.stringify(targets),cc_json:JSON.stringify(ccList()),subject:re,html:quoted,inReplyTo:m.message_id,title:'replyTitle'});
// 전달(0.8.25): 원본의 일반 첨부(인라인 그림 제외)도 함께 보냅니다. openCompose 가 초안을 만든 뒤 forwardFrom 으로 서버에 복사를 요청합니다.
$('#rd-forward').onclick=()=>openCompose({forwardFrom:{id:m.id,ids:(m.attachments||[]).filter(a=>isFile(a)&&a.available).map(a=>a.id)},subject:(/^fwd?:/i.test(m.subject)?'':'Fwd: ')+(m.subject||''),html:`<p><br></p><p>${text('forwardedHeader')}<br>${text('from')}: ${esc(m.sender)}<br>${text('receivedAt')}: ${esc(fullDate(m.received_at))}<br>${text('mailSubject')}: ${esc(m.subject)}<br>${text('to')}: ${esc(to.join(', '))}</p>${quoteBody()}`,title:'forwardTitle'});
};paint();
}catch(e){fail(e);}
}
// ------------------------------------------------------------------ 편집기
const FONTS=['기본','Arial','Georgia','Verdana','Tahoma','Courier New','Malgun Gothic','Apple SD Gothic Neo','Nanum Gothic'];
// 도구 모음 아이콘(워드 프로세서와 같은 모양). 정렬: 긴 줄·짧은 줄을 번갈아 왼쪽/가운데/오른쪽에 맞춘 선. 들여쓰기/내어쓰기: 줄과 화살표.
const svg=(inner,box=16)=>`<svg viewBox="0 0 ${box} ${box}" width="16" height="16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="${box===16?1.6:2}" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
// 0.8.12: 링크·링크 제거·인용·이미지·서식 지우기도 일반 편집기(Word·Google Docs·Gmail)에서 쓰는 모양으로 통일합니다(24 격자).
// link: 사슬 두 고리 / unlink: 같은 사슬에 빗금(끊어진 링크) / quote: 큰따옴표 두 개(인용) / image: 그림 틀 + 해·산 / clear: T 에 빗금(서식 지우기 "Tx")
const ICON24={
link:svg('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/>',24),
unlink:svg('<path d="M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7"/><path d="M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7"/><path d="M3 3l18 18"/>',24),
quote:'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor"><path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/></svg>',
image:svg('<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="M21 16l-5-5L5 21"/>',24),
clear:'<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true" fill="currentColor"><path d="M3.27 5 2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21 18 19.73 3.55 5.27 3.27 5zM6 5v.18L8.82 8h2.4l-.72 1.68 2.1 2.1L14.21 8H20V5H6z"/></svg>',
};
const ICON={alignLeft:svg('<path d="M1.5 3h13M1.5 6h8M1.5 9h13M1.5 12h8"/>'),alignCenter:svg('<path d="M1.5 3h13M4 6h8M1.5 9h13M4 12h8"/>'),alignRight:svg('<path d="M1.5 3h13M6.5 6h8M1.5 9h13M6.5 12h8"/>'),
indent:svg('<path d="M1.5 2.5h13M7.5 6h7M7.5 9.5h7M1.5 13h13"/><path d="M1.8 5.5l3 2.25-3 2.25z" fill="currentColor" stroke="none"/>'),outdent:svg('<path d="M1.5 2.5h13M7.5 6h7M7.5 9.5h7M1.5 13h13"/><path d="M5 5.5l-3 2.25 3 2.25z" fill="currentColor" stroke="none"/>')};
// 기본 색상 8색. 글자색은 진한 색, 강조색은 연한 바탕색입니다. 더 많은 색은 브라우저 색상 선택창(RGB 입력·스포이드)으로 이어집니다.
const PALETTE={foreColor:[['#000000','color_black'],['#e53935','color_red'],['#fb8c00','color_orange'],['#fdd835','color_yellow'],['#43a047','color_green'],['#1e88e5','color_blue'],['#8e24aa','color_purple'],['#9e9e9e','color_gray']],
hiliteColor:[['#fff59d','color_yellow'],['#ffe0b2','color_orange'],['#ffcdd2','color_red'],['#c8e6c9','color_green'],['#bbdefb','color_blue'],['#e1bee7','color_purple'],['#e0e0e0','color_gray'],['#ffffff','color_white']]};
function toolbar(){const b=(cmd,label,icon)=>`<button type="button" data-cmd="${cmd}" title="${esc(tr(label))}">${icon}</button>`;return `<div class="editor-tools" role="toolbar">${b('undo','tb_undo','↶')}${b('redo','tb_redo','↷')}<span class="sep"></span><select data-format="fontName" title="${esc(tr('tb_font'))}">${FONTS.map((f,i)=>`<option value="${i?esc(f):''}">${esc(f)}</option>`).join('')}</select><select data-format="fontSize" title="${esc(tr('tb_size'))}"><option value="2">${text('size_small')}</option><option value="3" selected>${text('size_normal')}</option><option value="5">${text('size_large')}</option><option value="6">${text('size_huge')}</option></select><span class="sep"></span>${b('bold','tb_bold','<b>B</b>')}${b('italic','tb_italic','<i>I</i>')}${b('underline','tb_underline','<u>U</u>')}${b('strikeThrough','tb_strike','<s>S</s>')}<span class="color-tool"><button type="button" data-color="foreColor" title="${esc(tr('tb_color'))}"><span class="glyph">A</span><i class="swatch" style="background:#1d2733"></i></button></span><span class="color-tool"><button type="button" data-color="hiliteColor" title="${esc(tr('tb_highlight'))}"><span class="glyph">🖍</span><i class="swatch" style="background:#fff59d"></i></button></span><span class="sep"></span>${b('justifyLeft','tb_alignLeft',ICON.alignLeft)}${b('justifyCenter','tb_alignCenter',ICON.alignCenter)}${b('justifyRight','tb_alignRight',ICON.alignRight)}${b('insertOrderedList','tb_ol','1.')}${b('insertUnorderedList','tb_ul','•')}${b('outdent','tb_outdent',ICON.outdent)}${b('indent','tb_indent',ICON.indent)}<span class="sep"></span><button type="button" data-link title="${esc(tr('tb_link'))}">${ICON24.link}</button>${b('unlink','tb_unlink',ICON24.unlink)}<button type="button" data-quote title="${esc(tr('tb_quote'))}">${ICON24.quote}</button>${b('insertHorizontalRule','tb_hr','―')}<button type="button" data-image title="${esc(tr('tb_image'))}">${ICON24.image}</button><button type="button" data-clear title="${esc(tr('tb_clear'))}">${ICON24.clear}</button><button type="button" data-html title="${esc(tr('tb_html'))}">&lt;/&gt;</button></div>`;}
/**
* 편집기 바인딩. 핵심은 "선택 영역 기억/복원"입니다.
* contenteditable 의 선택 영역은 도구 모음의 다른 요소(색상 선택창, 글꼴 목록)가 포커스를 가져가면 브라우저에 따라 사라집니다.
* 예전에는 색상 <input type=color> 자체의 mousedown 에서만 선택을 기억해서, 글자 "A"(label)를 눌러 창을 열거나 브라우저의
* 색상 선택창이 포커스를 가져간 뒤에는 기억한 범위가 없거나 낡아, 복원 때 커서가 맨 앞으로 가고 색이 적용되지 않았습니다.
* 이제 (1) 도구 모음 어디를 누르든 포커스가 옮겨 가기 전(capture 단계 mousedown)에 기억하고, (2) 편집기 안에서 선택이 바뀔 때마다
* (selectionchange) 기억하며, (3) 색상 팔레트 버튼은 mousedown 을 막아 포커스를 아예 가져가지 않습니다.
*/
function bindEditor(form,onImage){const editor=$('[data-editor]',form);const tools=$('.editor-tools',form);let range=null;
const remember=()=>{const s=window.getSelection();if(s.rangeCount&&editor.contains(s.anchorNode)&&editor.contains(s.focusNode))range=s.getRangeAt(0).cloneRange();};
const restore=()=>{editor.focus({preventScroll:true});if(range){const s=window.getSelection();s.removeAllRanges();s.addRange(range);}};
const command=(cmd,value)=>{restore();document.execCommand('styleWithCSS',false,true);document.execCommand(cmd,false,value);remember();refreshState();};
const refreshState=()=>{$$('[data-cmd]',form).forEach(b=>{if(['bold','italic','underline','strikeThrough','insertOrderedList','insertUnorderedList','justifyLeft','justifyCenter','justifyRight'].includes(b.dataset.cmd))b.setAttribute('aria-pressed',String(document.queryCommandState(b.dataset.cmd)));});};
editor.addEventListener('keyup',()=>{remember();refreshState();});editor.addEventListener('mouseup',()=>{remember();refreshState();});editor.addEventListener('focus',remember);
document.addEventListener('selectionchange',()=>{if(document.activeElement===editor)remember();});
tools.addEventListener('mousedown',remember,true);
$$('[data-cmd]',form).forEach(b=>{b.onmousedown=e=>e.preventDefault();b.onclick=()=>command(b.dataset.cmd);});
// 글꼴 "기본" 은 글꼴만 상위 값으로 되돌립니다. 예전에는 removeFormat 을 불러 글꼴을 되돌리려다 굵게·색 등 다른 서식까지 지워졌습니다.
$$('[data-format]',form).forEach(el=>{el.onchange=()=>{if(el.dataset.format==='fontName'&&!el.value)command('fontName','inherit');else command(el.dataset.format,el.value);};});
// ---- 서식 지우기(0.8.12 에서 다시 구현)
// 브라우저의 removeFormat 은 굵게·기울임·글꼴·글자색 같은 글자 서식만 지우고, 강조색(배경색, Chrome), 정렬(text-align), 들여쓰기(여백),
// 인용(blockquote), 제목(h1~h4), 링크는 그대로 두며 커서만 있을 때(선택 없음)는 아무 일도 하지 않습니다. 그래서 "서식 지우기가 안 된다"고 느껴졌습니다.
// 동작: 선택이 없으면 커서가 있는 단락 전체를 대상으로 하고, (1) removeFormat·unlink 로 글자 서식과 링크를 지운 뒤
// (2) 경계에 걸친 인라인 요소는 선택 부분만 떼어 내고 (3) 범위 안의 요소에서 서식 속성(style·class·align·color·face·size·bgcolor)을 지우며
// 제목·pre 는 단락(p)으로, 인용·인라인 래퍼(span·font·b·i·u·s·code·a …)는 껍데기를 벗깁니다. 목록·표·이미지·구분선은 내용 구조라 그대로 둡니다.
const BLOCKS=new Set(['P','DIV','H1','H2','H3','H4','H5','H6','PRE','BLOCKQUOTE','LI','TD','TH','UL','OL','TABLE','TR','TBODY','THEAD']);
const INLINE_WRAPPERS=new Set(['SPAN','FONT','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','CODE','SUB','SUP','MARK','SMALL','BIG','TT','A']);
const FORMAT_ATTRS=['style','class','align','color','face','size','bgcolor'];
const blockOf=n=>{let e=n&&n.nodeType===1?n:n&&n.parentNode;while(e&&e!==editor&&!BLOCKS.has(e.tagName))e=e.parentNode;return e&&e!==editor?e:null;};
const unwrap=el=>{const p=el.parentNode;if(!p)return;while(el.firstChild)p.insertBefore(el.firstChild,el);p.removeChild(el);};
const containsNode=(r,node)=>{const nr=document.createRange();nr.selectNode(node);return r.compareBoundaryPoints(Range.START_TO_START,nr)<=0&&r.compareBoundaryPoints(Range.END_TO_END,nr)>=0;};
// 경계(container/offset)를 감싸는 인라인 래퍼 사슬을 그 지점에서 둘로 나눕니다. 선택 밖 부분은 복제한 껍데기에 담아 옆에 둬 서식을 유지합니다.
const splitEdge=(r,toStart)=>{const container=toStart?r.startContainer:r.endContainer,offset=toStart?r.startOffset:r.endOffset;let el=container.nodeType===1?container:container.parentNode;let top=null;while(el&&el!==editor&&INLINE_WRAPPERS.has(el.tagName)){top=el;el=el.parentNode;}
if(!top)return;const cut=document.createRange();if(toStart){cut.setStart(top,0);cut.setEnd(container,offset);}else{cut.setStart(container,offset);cut.setEnd(top,top.childNodes.length);}
if(cut.collapsed)return;const frag=cut.extractContents();if(!frag.textContent&&!frag.querySelector('img,br'))return;const clone=top.cloneNode(false);clone.appendChild(frag);top.parentNode.insertBefore(clone,toStart?top:top.nextSibling);};
const clearFormatting=()=>{restore();const sel=window.getSelection();if(!sel.rangeCount)return;let r=sel.getRangeAt(0);
if(r.collapsed){const b=blockOf(r.startContainer);if(!b)return;r=document.createRange();r.selectNodeContents(b);sel.removeAllRanges();sel.addRange(r);}
document.execCommand('styleWithCSS',false,true);document.execCommand('removeFormat');document.execCommand('unlink');
const work=sel.getRangeAt(0).cloneRange(); // Range 는 DOM 이 바뀌면 경계를 따라 움직이므로 아래 수정 중에도 선택 범위를 가리킵니다.
splitEdge(work,true);splitEdge(work,false);
const walker=document.createTreeWalker(editor,NodeFilter.SHOW_ELEMENT);const nodes=[];let n;while((n=walker.nextNode()))if(work.intersectsNode(n))nodes.push(n);
for(const el of nodes){if(!el.isConnected||['IMG','BR','HR'].includes(el.tagName))continue;const inline=INLINE_WRAPPERS.has(el.tagName);if(inline&&!containsNode(work,el))continue;
for(const a of FORMAT_ATTRS)el.removeAttribute(a);
if(/^H[1-6]$/.test(el.tagName)||el.tagName==='PRE'){const p=document.createElement('p');while(el.firstChild)p.appendChild(el.firstChild);el.replaceWith(p);}
else if(el.tagName==='BLOCKQUOTE'||inline)unwrap(el);}
sel.removeAllRanges();sel.addRange(work);remember();refreshState();
$$('[data-format]',form).forEach(el=>{el.selectedIndex=el.dataset.format==='fontSize'?1:0;});
editor.dispatchEvent(new Event('input',{bubbles:true}));}; // 자동 저장이 변경을 알아채게 합니다(직접 DOM 을 고쳐 input 이벤트가 나지 않음).
$('[data-clear]',form).onmousedown=e=>e.preventDefault();$('[data-clear]',form).onclick=clearFormatting;
// 색상 팔레트: 기본 8색을 먼저 보여 주고, "더 많은 색상…" 을 누르면 브라우저의 색상 선택창(RGB 입력·스포이드)으로 이어집니다.
// 팝업 안의 버튼은 mousedown 을 막아 편집기의 선택 영역이 유지되게 합니다. 선택창은 포커스를 가져가므로 기억한 범위로 복원해 적용합니다.
let pop=null;const closePalette=()=>{if(pop){pop.remove();pop=null;}};
$$('[data-color]',form).forEach(btn=>{const cmd=btn.dataset.color;btn.onmousedown=e=>e.preventDefault();btn.onclick=()=>{if(pop&&pop.dataset.cmd===cmd){closePalette();return;}closePalette();remember();
pop=document.createElement('div');pop.className='color-pop';pop.dataset.cmd=cmd;pop.setAttribute('role','dialog');
pop.innerHTML=`<div class="swatches">${PALETTE[cmd].map(([c,k])=>`<button type="button" data-pick="${c}" title="${esc(tr(k))}" aria-label="${esc(tr(k))}" style="background:${c}"></button>`).join('')}</div><div class="color-more">${cmd==='hiliteColor'?`<button type="button" data-pick="transparent">${text('tb_noHighlight')}</button>`:''}<button type="button" data-more>${text('tb_moreColors')}…</button><input type="color" value="#000000" aria-label="${esc(tr('tb_moreColors'))}"></div>`;
btn.parentElement.appendChild(pop);
const apply=c=>{command(cmd,c);btn.querySelector('.swatch').style.background=c==='transparent'?'#fff':c;};
$$('[data-pick]',pop).forEach(p=>{p.onmousedown=e=>e.preventDefault();p.onclick=()=>{apply(p.dataset.pick);closePalette();};});
const input=$('input[type=color]',pop);$('[data-more]',pop).onmousedown=e=>e.preventDefault();$('[data-more]',pop).onclick=()=>{remember();input.click();};
input.oninput=()=>apply(input.value);input.onchange=()=>{apply(input.value);closePalette();};};});
document.addEventListener('mousedown',e=>{if(pop&&!e.target.closest('.color-tool'))closePalette();});
$('[data-link]',form).onmousedown=e=>e.preventDefault();$('[data-link]',form).onclick=()=>{const url=prompt(tr('linkUrl'),'https://');if(!url)return;try{if(!['https:','http:','mailto:'].includes(new URL(url).protocol))throw Error();restore();if(window.getSelection().isCollapsed){const label=prompt(tr('linkText'),url)||url;document.execCommand('insertHTML',false,`<a href="${esc(url)}">${esc(label)}</a>`);}else command('createLink',url);}catch{notify('invalidUrl');}};
$('[data-quote]',form).onmousedown=e=>e.preventDefault();$('[data-quote]',form).onclick=()=>command('formatBlock','blockquote');
$('[data-image]',form).onmousedown=e=>e.preventDefault();$('[data-image]',form).onclick=()=>{const input=document.createElement('input');input.type='file';input.accept='image/png,image/jpeg,image/gif,image/webp';input.multiple=true;input.onchange=async()=>{for(const f of input.files){try{const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`<img src="${esc(url)}" alt="" style="max-width:100%"><br>`);remember();}}catch(e){fail(e);}}};input.click();};
$('[data-html]',form).onclick=()=>{const src=$('.html-source',form);if(src.hidden){src.value=editor.innerHTML;src.hidden=false;editor.hidden=true;$('[data-html]',form).setAttribute('aria-pressed','true');notify('htmlMode');}else{editor.innerHTML=src.value;src.hidden=true;editor.hidden=false;$('[data-html]',form).setAttribute('aria-pressed','false');}};
editor.onpaste=async e=>{e.preventDefault();remember();try{const files=[...e.clipboardData.files].filter(f=>/^image\//.test(f.type));if(files.length){for(const f of files){const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`<img src="${esc(url)}" alt="" style="max-width:100%"><br>`);}}return;}const html=e.clipboardData.getData('text/html');if(html){const clean=await api('mail/sanitize','POST',{html});restore();document.execCommand('insertHTML',false,clean.html);}else{restore();document.execCommand('insertText',false,e.clipboardData.getData('text/plain'));}}catch{notify('error');}};
// ---- 이미지 크기: 삽입 시 편집기 폭에 맞추고, 클릭하면 크기 도구와 모서리 손잡이를 보여 줍니다.
const editorWidth=()=>Math.max(120,editor.clientWidth-34);
// silent: 창을 열 때 하는 첫 맞춤에서는 input 이벤트를 내지 않습니다. 예전에는 이 맞춤이 "편집" 으로 잡혀, 인용문에 이미지가 있는 답장·전달 창을
// 열기만 해도 4초 뒤 자동 저장이 초안을 만들었습니다(아무것도 쓰지 않았는데 임시 보관함에 "Re: …" 가 남음).
const setWidth=(img,px,silent=false)=>{px=Math.round(Math.min(Math.max(40,px),editorWidth()));img.style.width=px+'px';img.style.height='auto';img.style.maxWidth='100%';img.setAttribute('width',String(px));img.removeAttribute('height');if(!silent)editor.dispatchEvent(new Event('input',{bubbles:true}));place();};
// 기본 크기: 글쓰기 창의 가로/세로 중 작은 쪽의 80% 상자 안에 가로·세로가 모두 들어가게 줄입니다(확대는 하지 않음).
const fitBox=()=>{const visible=Math.max(200,form.clientHeight-160);return Math.round(Math.min(editorWidth(),visible)*0.8);};
const fit=(img,silent=false)=>{const box=fitBox(),nw=img.naturalWidth||0,nh=img.naturalHeight||0;if(!nw||!nh){img.style.maxWidth='100%';return;}const scale=Math.min(1,box/nw,box/nh);
// 줄일 필요가 없는(상자보다 작은) 그림은 크기를 건드리지 않습니다(0.9.9). 예전에는 setWidth 의 최소 40px 때문에 인용문의 1×1 추적 그림·간격용 그림이 40px 로 커진 채 저장·발송됐습니다.
if(scale>=1){img.style.maxWidth='100%';return;}setWidth(img,Math.max(40,Math.round(nw*scale)),silent);};
const whenLoaded=(img,fn)=>{const run=()=>{if(img.naturalWidth)fn(img);};if(img.complete&&img.naturalWidth){run();return;}(img.decode?img.decode():Promise.reject()).then(run,()=>{img.addEventListener('load',run,{once:true});if(img.complete)run();});};
const sizer=document.createElement('div');sizer.className='img-sizer';sizer.hidden=true;sizer.innerHTML=`<span class="lbl">${text('imgSize')}</span>${[25,50,75,100].map(p=>`<button type="button" data-pct="${p}">${p}%</button>`).join('')}<button type="button" data-fit>${text('imgFit')}</button><button type="button" data-orig>${text('imgOriginal')}</button><label class="lbl" style="margin:0;display:inline-flex;align-items:center;gap:3px">${text('imgWidthPx')}<input type="number" min="40" step="10" data-px></label>`;
const handle=document.createElement('div');handle.className='img-handle';handle.hidden=true;form.append(sizer,handle);let selected=null;
const place=()=>{if(!selected||!selected.isConnected){hideSizer();return;}const r=selected.getBoundingClientRect(),f=form.getBoundingClientRect();sizer.style.left=Math.max(0,r.left-f.left)+'px';sizer.style.top=(r.bottom-f.top+form.scrollTop+6)+'px';handle.style.left=(r.right-f.left-7)+'px';handle.style.top=(r.bottom-f.top+form.scrollTop-7)+'px';$('[data-px]',sizer).value=Math.round(r.width);};
const hideSizer=()=>{if(selected)selected.classList.remove('selected');selected=null;sizer.hidden=true;handle.hidden=true;};
const selectImage=img=>{hideSizer();selected=img;img.classList.add('selected');sizer.hidden=false;handle.hidden=false;place();};
editor.addEventListener('click',e=>{if(e.target.tagName==='IMG')selectImage(e.target);else hideSizer();});
form.addEventListener('scroll',()=>{if(selected)place();});window.addEventListener('resize',()=>{if(selected)place();});
$$('[data-pct]',sizer).forEach(b=>b.onclick=()=>{if(selected)setWidth(selected,editorWidth()*Number(b.dataset.pct)/100);});
$('[data-fit]',sizer).onclick=()=>{if(selected)setWidth(selected,editorWidth());};$('[data-orig]',sizer).onclick=()=>{if(selected)setWidth(selected,selected.naturalWidth||editorWidth());};
$('[data-px]',sizer).onchange=e=>{if(selected)setWidth(selected,Number(e.target.value));};
handle.addEventListener('mousedown',e=>{if(!selected)return;e.preventDefault();const startX=e.clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.clientX-startX));const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);};document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);});
handle.addEventListener('touchstart',e=>{if(!selected)return;const startX=e.touches[0].clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.touches[0].clientX-startX));const up=()=>{document.removeEventListener('touchmove',mv);document.removeEventListener('touchend',up);};document.addEventListener('touchmove',mv,{passive:true});document.addEventListener('touchend',up);},{passive:true});
// 다시 연 초안: 사용자가 정한 크기(width)가 없는 이미지만 맞춥니다.
editor.querySelectorAll('img').forEach(img=>{if(!img.getAttribute('width')&&!img.style.width)whenLoaded(img,i=>fit(i,true));else img.style.maxWidth='100%';});
const insertImage=url=>{restore();document.execCommand('insertHTML',false,`<img src="${esc(url)}" alt="" style="max-width:100%"><br>`);remember();const img=[...editor.querySelectorAll('img')].reverse().find(i=>i.getAttribute('src')===url||i.src===new URL(url,location.href).href);if(img)whenLoaded(img,fit);};
return {html:()=>{hideSizer();editor.querySelectorAll('img.selected').forEach(i=>i.classList.remove('selected'));return $('.html-source',form).hidden?editor.innerHTML:$('.html-source',form).value;},editor,insertImage};
}
// ------------------------------------------------------------------ 작성
// 편집기에는 previewHtml(cid: 그림을 /api/mail/<id>/attachments/… 주소로 바꾼 본문)을 넣습니다. IMAP 으로 올린 초안의 그림이 예전에는 cid: 그대로라 깨져 보였습니다(0.8.16).
function openCompose(m){
const locked=m.state&&m.state!=='draft';let id=m.id||null;let files=m.attachments||[];let dirty=false,saving=null,timer=null;let closed=false;
// rev: 편집할 때마다 1씩 늘어나는 번호. 저장이 끝났을 때 저장을 시작한 시점의 번호와 같을 때만 dirty 를 끕니다.
// 예전에는 저장 요청 중에 입력한 내용까지 "저장됨"으로 처리해 그 편집이 사라질 수 있었습니다.
let rev=0;
// autoCreated: 사용자가 아무것도 하지 않았는데 코드가 만든 초안(전달 창의 첨부 복사용). touched: 사용자가 첨부·대용량 파일을 올렸음.
// 전달 창을 열고 그대로 닫으면 rev(편집 횟수)가 0 이고 touched 도 아니므로 자동 생성 초안을 지웁니다(0.9.9). 예전에는 "Fwd: …" 초안이 임시 보관함에 남았습니다.
let autoCreated=false,touched=false;
const dlg=$('#compose');const title=m.title?tr(m.title):id?tr('editDraft'):tr('composeTitle');const showCc=!!(m.cc_json&&JSON.parse(m.cc_json).length),showBcc=!!(m.bcc_json&&JSON.parse(m.bcc_json).length);
$('#compose-body').innerHTML=`<div class="compose-head"><h2>${esc(title)}</h2><button type="button" id="cp-close" title="${esc(tr('closeCompose'))}">✕</button></div><form class="compose-form" id="compose-form"><div class="addr-row"><span>${text('fromLabel')}</span><input value="${esc((S.me.name?S.me.name+' ':'')+'<'+S.mailbox+'>')}" readonly title="${esc(tr('composeFromHelp'))}"><span></span></div><div class="addr-row"><span>${text('to')}</span><input name="to" value="${esc(JSON.parse(m.recipients_json||'[]').join(', '))}" placeholder="name@example.com, ..." autocomplete="off"><span class="addr-links"><button type="button" class="link" id="cp-cc-toggle" ${showCc?'hidden':''}>${text('cc')}</button><button type="button" class="link" id="cp-bcc-toggle" ${showBcc?'hidden':''}>${text('bcc')}</button></span></div><div class="addr-row" id="cp-cc-row" ${showCc?'':'hidden'}><span>${text('cc')}</span><input name="cc" value="${esc(JSON.parse(m.cc_json||'[]').join(', '))}" autocomplete="off" placeholder="name@example.com, ..."><span></span></div><div class="addr-row" id="cp-bcc-row" ${showBcc?'':'hidden'}><span>${text('bcc')}</span><input name="bcc" value="${esc(JSON.parse(m.bcc_json||'[]').join(', '))}" autocomplete="off" placeholder="name@example.com, ..." title="${esc(tr('bccHelp'))}"><span></span></div><div class="addr-row"><span>${text('mailSubject')}</span><input name="subject" value="${esc(m.subject||'')}" autocomplete="off"><span></span></div>${toolbar()}<div class="html-editor" contenteditable="${locked?'false':'true'}" role="textbox" aria-multiline="true" data-editor>${m.previewHtml||m.html||'<p><br></p>'}</div><textarea class="html-source" hidden spellcheck="false"></textarea><div class="choice" id="cp-choice" hidden><strong id="cp-choice-text"></strong><button type="button" class="primary" id="cp-choice-inline">🖼 ${text('choiceInline')}</button><button type="button" id="cp-choice-attach">📎 ${text('choiceAttach')}</button><button type="button" class="ghost" id="cp-choice-cancel">${text('choiceCancel')}</button></div><div class="compose-attachments" id="cp-files"></div><div class="compose-attachments" id="cp-large-files"></div><div class="terms-row" id="cp-terms" hidden><span>☁ ${text('largeTerms')}</span><label>${text('largeDays')} <input type="number" name="largeDays" min="1" max="${Number(S.largeFiles.maxDays)||1}" value="${Number(S.largeFiles.maxDays)||1}"></label><label>${text('largeDownloads')} <input type="number" name="largeDownloads" min="1" max="${Number(S.largeFiles.maxDownloads)||1}" value="${Number(S.largeFiles.maxDownloads)||1}"></label><span class="meta">${esc(fill('largeTermsHelp',{days:S.largeFiles.maxDays||1,downloads:S.largeFiles.maxDownloads||1}))}</span></div><div class="drop-overlay" id="cp-drop" hidden>📎 ${text('dropHint')}</div><div class="compose-foot"><button type="button" class="primary" id="cp-send">${text(locked?'retrySend':'send')}</button>${locked?'':`<button type="button" id="cp-save">${text('saveDraft')}</button><button type="button" id="cp-attach">📎 ${text('attachFiles')}</button><input type="file" id="cp-file-input" multiple hidden>${S.largeFiles&&S.largeFiles.enabled?`<button type="button" id="cp-large" title="${esc(fill('largeAttachHelp',{mb:mbOf(S.largeFiles.maxBytes||0)}))}">☁ ${text('largeAttach')}</button><input type="file" id="cp-large-input" multiple hidden>`:''}`}<span class="spacer"></span><span class="status" id="cp-status" title="${esc(tr('dropTip')+' '+tr('imgResizeTip'))}"></span><button type="button" class="ghost" id="cp-discard">🗑 ${text('discard')}</button></div></form>`;
const form=$('#compose-form');
// 첨부·본문 이미지를 올리려면 초안 id 가 필요합니다. 직접 POST 하지 않고 save 를 거쳐, 자동 저장과 동시에 초안이 두 개 생기지 않게 합니다.
const ensureDraft=async()=>{if(id)return id;return save(true);};
const upload=async(file,inline)=>{touched=true;await ensureDraft();const data=new FormData();data.append('files',file);const d=await api('mail/'+id+'/attachments?'+(inline?'inline=1':''),'POST',data);files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();return inline?d.urls[0]:null;};
const ed=bindEditor(form,f=>upload(f,true));
const values=()=>({to:form.elements.to.value,cc:form.elements.cc.value,bcc:form.elements.bcc.value,subject:form.elements.subject.value,html:ed.html(),...(m.inReplyTo?{inReplyTo:m.inReplyTo}:{})});
const paintFiles=()=>{$('#cp-files').innerHTML=files.filter(isFile).map(a=>`<span class="chip">📎 ${esc(a.filename)} <span class="sz">${kb(a.size)}</span>${locked?'':`<button type="button" data-remove="${esc(a.id)}" title="${esc(tr('delete'))}">✕</button>`}</span>`).join('');$$('[data-remove]',$('#cp-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/attachments/'+b.dataset.remove,'DELETE');files=files.filter(a=>a.id!==b.dataset.remove);paintFiles();}catch(e){fail(e);}});};paintFiles();
// ---- 대용량 첨부파일(0.9.3): 메일에 붙이지 않고 서버에 두고 링크를 보냅니다. 올리면 칩에 "☁" 표시와 함께 보이고, 하나라도 있으면 발송 조건(보관 일수·다운로드 횟수) 줄이 나타납니다.
let large=m.largeFiles||[];
const paintLarge=()=>{const live=large.filter(f=>f.state!=='expired');$('#cp-large-files').innerHTML=live.map(f=>`<span class="chip large">☁ ${esc(f.filename)} <span class="sz">${kb(f.size)}</span>${locked?'':`<button type="button" data-remove-large="${esc(f.id)}" title="${esc(tr('delete'))}">✕</button>`}</span>`).join('');$('#cp-terms').hidden=!live.length;
$$('[data-remove-large]',$('#cp-large-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/files/'+b.dataset.removeLarge,'DELETE');large=large.filter(f=>f.id!==b.dataset.removeLarge);paintLarge();}catch(e){fail(e);}});};paintLarge();
// 업로드는 XMLHttpRequest 로(진행률 표시). 본문이 파일 바이트 그대로이고 이름은 X-File-Name 헤더로 보냅니다. 서버 상한을 넘으면 413 과 limit 이 돌아옵니다.
/**
* 대용량 파일 업로드(0.9.5 에서 진행 표시 추가). 올리는 동안 칩 자리에 파일 이름·진행 막대·퍼센트·보낸 양/전체·취소(✕) 단추가 보이고, 끝나면 보통 칩으로 바뀝니다.
* 오류는 원인을 나눠 알립니다: 서버가 JSON 으로 거부(코드 그대로), 프록시(nginx)가 413 으로 막음(upload_too_large_proxy), 그 밖의 HTTP 상태(upload_failed + 상태 코드), 네트워크 끊김(upload_network), 사용자가 취소(조용히).
*/
const uploadLarge=file=>new Promise(async(resolve,reject)=>{touched=true;try{await ensureDraft();}catch(e){reject(e);return;}
const x=new XMLHttpRequest();const row=document.createElement('span');row.className='chip large uploading';row.innerHTML=`☁ ${esc(file.name)} <span class="sz">${kb(file.size)}</span><progress max="100" value="0"></progress><span class="pct">0%</span><button type="button" title="${esc(tr('cancelUpload'))}">✕</button>`;$('#cp-large-files').appendChild(row);
const bar=row.querySelector('progress'),pct=row.querySelector('.pct');let cancelled=false,sent=false;
// 취소: 전송 중이면 abort 가 onabort 를 부르지만, 아직 send 전(초안 생성 중)이면 아무 이벤트도 나지 않으므로 직접 정리합니다.
row.querySelector('button').onclick=()=>{cancelled=true;x.abort();if(!sent){done();reject(new Error('upload_cancelled'));}};
x.open('POST',BASE+'api/files?draft='+encodeURIComponent(id));x.setRequestHeader('X-File-Name',encodeURIComponent(file.name));x.setRequestHeader('Content-Type',file.type||'application/octet-stream');
x.upload.onprogress=e=>{if(!e.lengthComputable)return;const p=Math.round(e.loaded/e.total*100);bar.value=p;pct.textContent=p+'% · '+kb(e.loaded)+' / '+kb(e.total);$('#cp-status').textContent='☁ '+p+'%';};
x.upload.onload=()=>{pct.textContent=tr('uploadFinishing');};
const done=()=>{row.remove();$('#cp-status').textContent='';};
x.onload=()=>{done();let d=null;try{d=JSON.parse(x.responseText);}catch{}if(x.status>=200&&x.status<300&&d&&d.file){large.push(d.file);paintLarge();resolve(d.file);return;}
const err=new Error(d&&d.error?d.error:x.status===413?'upload_too_large_proxy':'upload_failed');if(d&&d.limit!==undefined)err.limit=d.limit;if(!d||!d.error)err.detail='HTTP '+x.status;reject(err);};
x.onerror=()=>{done();reject(new Error('upload_network'));};x.onabort=()=>{done();reject(new Error(cancelled?'upload_cancelled':'upload_network'));};sent=true;x.send(file);});
if($('#cp-large')){$('#cp-large').onclick=()=>$('#cp-large-input').click();$('#cp-large-input').onchange=async e=>{$('#cp-large').disabled=true;try{for(const f of e.target.files)await uploadLarge(f);notify('largeAttached');}catch(err){if(err.message!=='upload_cancelled')fail(err);}finally{$('#cp-large').disabled=false;$('#cp-status').textContent='';e.target.value='';}};}
// 발송 조건: 보관 일수·다운로드 횟수(둘 다 필수, 상한 이내). 서버도 같은 검사를 합니다.
const largeTerms=()=>{if(!large.some(f=>f.state!=='expired'))return undefined;const t=$('#cp-terms');return {days:Number(t.querySelector('[name=largeDays]').value),downloads:Number(t.querySelector('[name=largeDownloads]').value)};};
// 저장은 한 번에 하나씩. 진행 중인 저장이 있으면 끝나기를 기다린 뒤 지금 내용으로 한 번 더 저장합니다
// (예전에는 진행 중인 저장을 그대로 돌려줘, 발송이 직전 편집이 빠진 본문으로 나갈 수 있었습니다).
const save=async(quiet)=>{if(locked)return id;if(saving){await saving.catch(()=>{});return save(quiet);}const startRev=rev;saving=(async()=>{try{if(id)await api('mail/'+id,'PATCH',values());else id=(await api('mail','POST',values())).id;if(rev===startRev)dirty=false;$('#cp-status').textContent=fill('draftAutosaved',{time:when(new Date().toISOString())});if(!quiet)notify('draftSaved');}finally{saving=null;}return id;})();return saving;};
const markDirty=()=>{dirty=true;rev++;clearTimeout(timer);timer=setTimeout(()=>{if(dirty&&!closed)save(true).catch(()=>{});},4000);};
form.oninput=markDirty;ed.editor.addEventListener('input',markDirty);
// 참조·숨은 참조(0.9.5): 받는 사람 줄의 "참조" / "숨은 참조" 를 누르면 각각의 입력 줄이 열리고 단추는 사라집니다(예전에는 참조 줄만 열리고 숨은 참조는 입력할 수 없었음).
const openRow=(btn,row)=>{$(btn).onclick=()=>{$(row).hidden=false;$(btn).hidden=true;$(row+' input').focus();};};openRow('#cp-cc-toggle','#cp-cc-row');openRow('#cp-bcc-toggle','#cp-bcc-row');
if($('#cp-save'))$('#cp-save').onclick=()=>save(false).catch(fail);
const askImageMode=n=>new Promise(res=>{const box=$('#cp-choice');box.hidden=false;$('#cp-choice-text').textContent=fill('imageChoiceTitle',{count:n});const done=v=>{box.hidden=true;res(v);};$('#cp-choice-inline').onclick=()=>done('inline');$('#cp-choice-attach').onclick=()=>done('attach');$('#cp-choice-cancel').onclick=()=>done(null);box.scrollIntoView({block:'nearest'});});
const handleFiles=async(list,{askImages=true}={})=>{list=[...list];if(!list.length||locked)return;
// 일반 첨부 상한(MIME 기준)을 넘는 파일은 대용량 첨부가 켜져 있으면 그쪽으로 올립니다(0.9.3). 꺼져 있으면 서버가 attachment_too_large 로 알립니다.
const over=S.largeFiles&&S.largeFiles.enabled&&S.attachmentBytes>0?list.filter(f=>Math.ceil(f.size/3)*4>S.attachmentBytes):[];if(over.length){list=list.filter(f=>!over.includes(f));try{for(const f of over)await uploadLarge(f);notify('largeAutoRouted');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}if(!list.length)return;}
const images=list.filter(f=>/^image\//.test(f.type)),others=list.filter(f=>!/^image\//.test(f.type));let mode='attach';if(images.length&&askImages){mode=await askImageMode(images.length);if(!mode)return;}$('#cp-status').textContent='⏳';try{for(const f of others)await upload(f,false);for(const f of images){if(mode==='inline'){const url=await upload(f,true);ed.insertImage(url);}else await upload(f,false);}notify(images.length&&mode==='inline'?'inlineAdded':'attached');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}};
if($('#cp-attach')){$('#cp-attach').onclick=()=>$('#cp-file-input').click();$('#cp-file-input').onchange=async e=>{$('#cp-attach').disabled=true;try{await handleFiles(e.target.files,{askImages:false});}finally{$('#cp-attach').disabled=false;e.target.value='';}};}
let dragDepth=0;const overlay=$('#cp-drop');const hasFiles=e=>[...(e.dataTransfer?.types||[])].includes('Files');
form.addEventListener('dragenter',e=>{if(!hasFiles(e)||locked)return;e.preventDefault();dragDepth++;overlay.hidden=false;});form.addEventListener('dragover',e=>{if(hasFiles(e)){e.preventDefault();e.dataTransfer.dropEffect='copy';}});form.addEventListener('dragleave',()=>{dragDepth=Math.max(0,dragDepth-1);if(!dragDepth)overlay.hidden=true;});form.addEventListener('drop',e=>{if(!hasFiles(e))return;e.preventDefault();dragDepth=0;overlay.hidden=true;handleFiles(e.dataTransfer.files);});
const close=()=>{closed=true;clearTimeout(timer);dlg.close();renderMail();};
// 닫기 전에 저장합니다. 저장에 실패하면 창을 닫지 않고 물어봅니다(예전에는 오류만 보이고 닫혀 작성한 내용이 사라졌습니다).
$('#cp-close').onclick=async()=>{if(dirty&&!locked){try{await save(true);}catch(e){fail(e);if(!confirm(tr('confirmCloseUnsaved')))return;}}
// 손대지 않은 자동 생성 초안은 휴지통을 거쳐 영구 삭제합니다(서버는 휴지통의 메일만 DELETE 를 받음). 실패해도 창은 닫습니다(초안이 남는 것뿐).
if(id&&autoCreated&&rev===0&&!touched){try{await api('mail/'+id,'PATCH',{folder:'trash'});await api('mail/'+id,'DELETE');}catch{}}
close();};
$('#cp-discard').onclick=async()=>{if(!confirm(tr('confirmDiscard')))return;try{if(id)await api('mail/'+id,'PATCH',{folder:'trash'});}catch(e){fail(e);}closed=true;clearTimeout(timer);dlg.close();renderMail();};
$('#cp-send').onclick=async()=>{const b=$('#cp-send');b.disabled=true;$('#cp-status').textContent=tr('sending');try{if(!locked)await save(true);await api('mail/'+id+'/send','POST',{largeFiles:largeTerms()});closed=true;clearTimeout(timer);dlg.close();S.folder='sent';S.page=0;S.current=null;await renderMail();notify('mailSent');}catch(e){fail(e);const msg=tr(A[e.message]?e.message:'error')+(e.detail?' ('+e.detail+')':'');$('#cp-status').textContent=msg;
// 발송 실패로 잠긴 메일은 "다시 보내기" 창으로 다시 그립니다. 오류 문구(statusText)를 함께 넘겨 새 창의 상태 줄에도 남깁니다(0.9.9). 예전에는 다시 그리면서 비어 6초짜리 알림만 남았습니다.
if(id){try{const cur=(await api('mail/'+id)).message;if(cur.state!=='draft'){openCompose({...cur,statusText:msg});return;}}catch{}}}finally{b.disabled=false;}};
dlg.oncancel=e=>{e.preventDefault();$('#cp-close').click();};
if(!dlg.open)dlg.showModal();if(!locked)(form.elements.to.value?ed.editor:form.elements.to).focus();
if(m.statusText)$('#cp-status').textContent=m.statusText;
// 전달 첨부 복사: 초안을 먼저 만들고(ensureDraft) 원본의 첨부 id 를 서버에 넘겨 복사한 뒤 칩 목록을 다시 그립니다.
if(m.forwardFrom&&m.forwardFrom.ids.length)(async()=>{try{if(!id)autoCreated=true;await ensureDraft();await api('mail/'+id+'/attachments','POST',{from:m.forwardFrom.id,ids:m.forwardFrom.ids});files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();}catch(e){fail(e);}})();
}
// ------------------------------------------------------------------ 설정
/**
* 웹 메일 "도구 → 설정". 0.9.0 에서 더한 것: 비밀번호 변경 카드(게이트웨이 계정만, 현재 비밀번호 + 새 비밀번호 2회 + 눈 단추),
* 메일함 사용량·적용 중인 제한(첨부 크기·받는 사람 수) 카드, 관리자가 POP/IMAP 서비스 자체를 껐을 때의 안내.
*/
async function settingsPage(){
viewVersion++;showPanes(true);const s=await api('settings');const kv=rows=>`<dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
// 메일 전달·자동 응답(0.8.16): 서버에 저장된 설정(prefs.forward / prefs.autoReply)으로 양식을 채웁니다.
// 시각은 서버에 ISO(UTC)로 저장돼 있어 datetime-local 입력 상자에는 브라우저 지역 시각(YYYY-MM-DDTHH:MM)으로 바꿔 보여 줍니다.
const fw=S.prefs.forward||{enabled:false,to:''},ar=S.prefs.autoReply||{enabled:false,noLimit:true,start:'',end:'',subject:'',message:''};
const localInput=iso=>{if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';const p=x=>String(x).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;};
const acct=`${kv([[text('popUser'),`<code>${esc(S.me.source==='www'?'admin':S.me.login)}</code> / <code>${esc(S.mailbox)}</code>`],[text('password'),text('popPasswordValue')]])}`;
const svc=s.services||{pop:true,imap:true},q=s.quota||{usageBytes:0,limit:0},lim=s.limits||{},policy=s.policy||{minLength:6};
const pct=q.limit>0?Math.min(100,Math.round(q.usageBytes/q.limit*100)):null;
$('#page-pane').innerHTML=`<h1 class="page-title">${text('settings')}</h1><p class="muted">${text('settingsIntro')}</p>
<section class="card"><h2>${text('passwordSection')}</h2>${s.canChangePassword?`<p>${text('passwordIntro')}</p><form id="pw-form" class="auto-form"><div class="grid2">${pwField('currentPassword',text('userCurrentPassword'),'autocomplete="current-password" required')}<span></span>${pwField('password',text('newPassword'),`autocomplete="new-password" required minlength="${Number(policy.minLength)||6}" title="${esc(passwordRule(policy))}"`)}${pwField('passwordConfirm',text('userPasswordConfirm'),'autocomplete="new-password" required')}</div><p class="meta">${esc(passwordRule(policy))} ${text('passwordChangeHelp')}</p><div class="actions"><button class="primary" type="submit">${text('changePassword')}</button></div></form>`:`<p class="muted">${text(s.managedBy==='webmaker'?'passwordWebmakerManaged':'passwordWwwManaged')}</p>`}</section>
<section class="card"><h2>${text('quotaSection')}</h2>${kv([[text('usageLabel'),`${kb(q.usageBytes)}${q.limit>0?` / ${kb(q.limit)} <span class="tag ${pct>=90?'warn':'ok'}">${esc(fill('quotaPercent',{percent:pct}))}</span>`:` · ${text('quotaUnlimited')}`}`],[text('limitAttachment'),esc(fill('limitAttachmentValue',{mb:mbOf(lim.attachmentBytes||0)}))],[text('limitRecipients'),esc(fill('limitRecipientsValue',{n:lim.recipients||0}))],...(s.largeFiles&&s.largeFiles.enabled?[[text('largeFileHeading'),esc(fill('largeLimitsValue',{mb:mbOf(s.largeFiles.maxBytes||0),days:s.largeFiles.maxDays,downloads:s.largeFiles.maxDownloads}))]]:[])])}<p class="meta">${text('mimeNote')}</p></section>
<section class="card"><h2>${text('prefsSection')}</h2><label class="switch"><input type="checkbox" id="pref-remote" ${S.prefs.remoteImages?'checked':''}><span>${text('prefRemoteImages')}</span></label><p class="meta">${text('prefRemoteImagesHelp')}</p></section>
<section class="card"><h2>${text('servicesSection')}</h2><p>${text('servicesIntro')}</p><label class="switch"><input type="checkbox" id="pref-pop" ${S.prefs.pop!==false?'checked':''} ${svc.pop===false?'disabled':''}><span>${text('prefPop')}</span>${svc.pop===false?`<span class="tag warn">${text('serviceOffByAdmin')}</span>`:''}</label><label class="switch"><input type="checkbox" id="pref-imap" ${S.prefs.imap!==false?'checked':''} ${svc.imap===false?'disabled':''}><span>${text('prefImap')}</span>${svc.imap===false?`<span class="tag warn">${text('serviceOffByAdmin')}</span>`:''}</label><p class="meta">${text('servicesHelp')}${svc.pop===false||svc.imap===false?' '+text('serviceOffByAdminHelp'):''}</p></section>
<section class="card"><h2>${text('forwardSection')}</h2><p>${text('forwardIntro')}</p><form id="fw-form" class="auto-form"><div class="switch-row"><label class="switch"><input type="checkbox" name="enabled" ${fw.enabled?'checked':''}><span>${text('prefForward')}</span></label><strong class="auto-on" id="fw-on" ${fw.enabled?'':'hidden'}>${text('forwardOnNote')}</strong></div><label><span>${text('forwardTo')}</span><input name="to" value="${esc(fw.to||'')}" placeholder="name@example.com, other@example.com" autocomplete="off"></label><p class="meta">${text('forwardHelp')}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>
<section class="card"><h2>${text('autoReplySection')}</h2><p>${text('autoReplyIntro')}</p><form id="ar-form" class="auto-form"><div class="switch-row"><label class="switch"><input type="checkbox" name="enabled" ${ar.enabled?'checked':''}><span>${text('prefAutoReply')}</span></label><strong class="auto-on" id="ar-on" ${ar.enabled?'':'hidden'}>${text('autoReplyOnNote')}</strong></div><p class="meta" id="ar-outside" hidden></p><label><span>${text('autoReplySubject')}</span><input name="subject" value="${esc(ar.subject||'')}" placeholder="${esc(tr('autoReplySubjectHelp'))}" maxlength="200" autocomplete="off"></label><label><span>${text('autoReplyMessage')}</span><textarea name="message" rows="6" maxlength="5000" placeholder="${esc(tr('autoReplyMessageHelp'))}">${esc(ar.message||'')}</textarea></label><fieldset class="services"><legend>${text('autoReplyPeriod')}</legend><label class="switch"><input type="radio" name="period" value="none" ${ar.noLimit!==false?'checked':''}><span>${text('autoReplyNoLimit')}</span></label><label class="switch"><input type="radio" name="period" value="range" ${ar.noLimit===false?'checked':''}><span>${text('autoReplyRange')}</span></label><div class="grid2 period"><label><span>${text('autoReplyStart')}</span><input type="datetime-local" name="start" value="${localInput(ar.start)}" ${ar.noLimit!==false?'disabled':''}></label><label><span>${text('autoReplyEnd')}</span><input type="datetime-local" name="end" value="${localInput(ar.end)}" ${ar.noLimit!==false?'disabled':''}></label></div><p class="meta">${text('autoReplyPeriodHelp')}</p></fieldset><p class="meta">${text('autoReplyHelp')}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>
<section class="card"><h2>${text('imapSettings')}</h2><p>${text('imapIntro')}</p>${kv([[text('imapHost'),`<code>${esc(s.imap.host)}</code>`],[text('imapPortTls'),`<code>${s.imap.ports.imaps}</code> <span class="tag ${s.imap.tls?'ok':'warn'}">${text(s.imap.tls?'imapTlsActive':'imapTlsMissing')}</span>`],[text('imapPortPlain'),`<code>${s.imap.ports.starttls}</code> <span class="tag ${s.imap.plaintext?'warn':'ok'}">${text(s.imap.plaintext?'popPlainAllowed':'popPlainRequired')}</span>`]])}${acct}${kv([[text('imapMailboxes'),text('imapMailboxesValue')]])}<h3>${text('imapSteps')}</h3><ol class="steps">${[1,2,3,4,5].map(n=>`<li>${text('imapStep'+n)}</li>`).join('')}</ol><h3>${text('imapNotes')}</h3><ul>${[1,2,3].map(n=>`<li>${text('imapNote'+n)}</li>`).join('')}</ul></section>
<section class="card"><h2>${text('smtpSettings')}</h2><p>${text('smtpIntro')}</p>${kv([[text('smtpHost'),`<code>${esc(s.smtp.host)}</code>`],[text('smtpPortSmtps'),`<code>${s.smtp.ports.smtps}</code> <span class="tag ${s.smtp.tls?'ok':'warn'}">${text(s.smtp.tls?'imapTlsActive':'smtpTlsMissing')}</span>`],[text('smtpPortStarttls'),`<code>${s.smtp.ports.starttls}</code>`]])}${acct}${kv([[text('smtpFrom'),`<code>${esc(s.smtp.from)}</code>`],[text('smtpAuth'),text('smtpAuthValue')]])}<h3>${text('smtpSteps')}</h3><ol class="steps">${[1,2,3,4,5].map(n=>`<li>${text('smtpStep'+n)}</li>`).join('')}</ol><h3>${text('smtpNotes')}</h3><ul>${[1,2,3].map(n=>`<li>${text('smtpNote'+n)}</li>`).join('')}</ul></section>
<section class="card"><h2>${text('popSettings')}</h2><p>${text('popIntro')}</p>${kv([[text('popHost'),`<code>${esc(s.pop.host)}</code>`],[text('popPortTls'),`<code>${s.pop.ports.pop3s}</code> <span class="tag ${s.pop.tls?'ok':'warn'}">${text(s.pop.tls?'popTlsActive':'popTlsMissing')}</span>`],[text('popPortPlain'),`<code>${s.pop.ports.starttls}</code> <span class="tag ${s.pop.plaintext?'warn':'ok'}">${text(s.pop.plaintext?'popPlainAllowed':'popPlainRequired')}</span>`]])}${acct}<p>${text('popDedupe')}</p><p class="meta">${text('popNoteGmail')}</p></section>
`;
// 비밀번호 변경(0.9.0): 새 비밀번호 두 입력이 같은지 화면에서 먼저 보고, 서버(PATCH /api/password)가 현재 비밀번호와 정책을 검사합니다.
// 성공하면 서버가 이 계정의 세션을 모두 끝내므로 로그인 화면으로 돌아가 다시 로그인하게 안내합니다.
if($('#pw-form')){const pf=$('#pw-form');bindEyes(pf);pf.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',pf);b.disabled=true;try{const f=pf.elements;if(f.password.value!==f.passwordConfirm.value){fail(new Error('password_mismatch'));return;}
await api('password','PATCH',{currentPassword:f.currentPassword.value,password:f.password.value,passwordConfirm:f.passwordConfirm.value});showLogin();$('#login-feedback').textContent=tr('passwordChangedRelogin');$('#login-feedback').classList.add('info');}
catch(err){const el=$('#feedback');el.textContent=passwordError(err);clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},6000);}finally{b.disabled=false;}};}
// 메일 프로그램 접속(POP/IMAP) 허용은 사용자가 직접 켜고 끕니다. 끄면 그 서버 로그인이 거부되고, 웹 메일에는 영향이 없습니다.
const toggle=(sel,key)=>{$(sel).onchange=async e=>{try{const r=await api('prefs','PATCH',{[key]:e.target.checked});S.prefs=r.prefs;notify('prefSaved');}catch(err){fail(err);e.target.checked=!e.target.checked;}};};toggle('#pref-pop','pop');toggle('#pref-imap','imap');
$('#pref-remote').onchange=async e=>{try{const r=await api('prefs','PATCH',{remoteImages:e.target.checked});S.prefs=r.prefs;notify('prefSaved');}catch(err){fail(err);e.target.checked=!e.target.checked;}};
// 메일 전달: 체크 상자와 주소를 저장 버튼 한 번으로 보냅니다(주소 검사는 서버가 하고 forward_invalid 로 알림).
// 켜져 있으면 체크 상자 옆에 "현재 메일은 다음 주소로 전달되고 있습니다" 를 굵게 보여 전달 중임을 분명히 합니다. 끄려면 체크를 풀고 저장합니다.
// (예전에는 저장 버튼 옆의 상태 글("전달하지 않음")이 버튼처럼 보여 혼동을 주었습니다.)
const fwForm=$('#fw-form');const fwNote=()=>{$('#fw-on').hidden=!S.prefs.forward?.enabled;};fwNote();
fwForm.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',fwForm);b.disabled=true;try{const r=await api('prefs','PATCH',{forward:{enabled:fwForm.elements.enabled.checked,to:fwForm.elements.to.value}});S.prefs=r.prefs;fwForm.elements.to.value=r.prefs.forward.to;fwForm.elements.enabled.checked=r.prefs.forward.enabled;fwNote();notify('prefSaved');renderSidebar();}catch(err){fail(err);}finally{b.disabled=false;}};
// 자동 응답: 기간 선택(기한 제한 없음 / 기간 지정)과 시작·종료 입력 상자를 처음부터 모두 보여 줍니다. "기한 제한 없음" 이면 시각 상자만 비활성화합니다.
// 켜고 끄는 것은 "자동 응답 사용" 체크 상자와 저장 버튼뿐이며, 켜져 있으면 옆에 "메일이 수신되면 자동으로 아래 메일을 발신자에게 회신합니다." 를 굵게 보여 줍니다.
// 켜져 있지만 지정한 기간 밖(시작 전·종료 후)이면 그 아래에 기간을 알려 지금은 보내지 않는다는 점을 덧붙입니다. 입력한 지역 시각은 ISO(UTC)로 바꿔 보냅니다.
const arForm=$('#ar-form');const periodInputs=()=>{const range=arForm.elements.period.value==='range';$$('.period input',arForm).forEach(i=>i.disabled=!range);};$$('[name=period]',arForm).forEach(rd=>rd.onchange=periodInputs);
const arNote=()=>{const a=S.prefs.autoReply||{};$('#ar-on').hidden=!a.enabled;const outside=$('#ar-outside');outside.hidden=!(a.enabled&&!S.autoReplyActive);if(!outside.hidden)outside.textContent=fill('autoReplyOutsideNote',{start:a.start?fullDate(a.start):tr('autoReplyNow'),end:a.end?fullDate(a.end):'-'});};arNote();
arForm.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',arForm);b.disabled=true;try{const f=arForm.elements;const toIso=v=>{if(!v)return '';const d=new Date(v);return isNaN(d)?'invalid':d.toISOString();};
const r=await api('prefs','PATCH',{autoReply:{enabled:f.enabled.checked,noLimit:f.period.value==='none',start:toIso(f.start.value),end:toIso(f.end.value),subject:f.subject.value,message:f.message.value}});S.prefs=r.prefs;S.autoReplyActive=!!r.autoReplyActive;f.enabled.checked=r.prefs.autoReply.enabled;arNote();notify('prefSaved');renderSidebar();}catch(err){fail(err);}finally{b.disabled=false;}};
}
// ------------------------------------------------------------------ 관리자 페이지: 서버 상태
// 발송 서비스 웹훅·동기화 기록·저장 통계·POP 전달 기록. 0.8.4 부터 웹 메일 설정 화면이 아니라 /admin 에서만 봅니다.
// 0.9.0: 메일 현황(일·주·월 발신/수신/수발신, 메일함별·전체 사용량, 시스템 제한 대비 사용 비율)을 더했습니다.
/** "8개 사용 중 / 최대 10개 (80%)" 또는 제한이 없으면 "8개 사용 중 · 제한 없음". unit: 값을 문구로 바꾸는 함수(개수·바이트). */
function usageLine(u,unit){if(!u)return '';return u.limit>0?fill('usageOfMax',{used:unit(u.used),max:unit(u.limit),percent:u.percent??0}):fill('usageNoLimit',{used:unit(u.used)});}
const countUnit=n=>fill('countUnit',{n});
async function statusPage(){
viewVersion++;showPanes(true);let s;try{s=await api('admin/status');}catch(e){fail(e);return;}const kv=rows=>`<dl class="kv">${rows.map(([k,v])=>`<dt>${k}</dt><dd>${v}</dd>`).join('')}</dl>`;
const runs=(s.sync?.runs||[]).map(r=>`<tr><td>${esc(fullDate(r.started_at))}</td><td>${esc(r.trigger)}</td><td>${text('status_'+r.status)}</td><td>${r.imported}</td><td class="meta">${esc(r.detail||'')}</td></tr>`).join('');
const events=(s.webhook?.events||[]).map(e=>`<tr><td>${text('status_'+e.status)}</td><td>${e.n}</td><td>${esc(fullDate(e.last))}</td></tr>`).join('');
const st=s.stats||{periods:{},mailboxes:[],usage:{}};const per=k=>st.periods[k]||{sent:0,received:0,total:0};
const periodRows=[['day','statDay'],['week','statWeek'],['month','statMonth']].map(([k,l])=>{const p=per(k);return `<tr><td>${text(l)}<div class="meta">${esc(fullDate(p.start))} ~ ${esc(fullDate(p.end))}</div></td><td>${p.sent}</td><td>${p.received}</td><td><strong>${p.total}</strong></td></tr>`;}).join('');
const pctTag=p=>p===null||p===undefined?'':` <span class="tag ${p>=90?'warn':'ok'}">${p}%</span>`;
const boxes=(st.mailboxes||[]).map(m=>`<tr><td>${esc(m.mailbox)}${m.login?`<div class="meta">${esc(m.login)}${m.name?' · '+esc(m.name):''}</div>`:''}</td><td>${m.messages}</td><td>${kb(m.bytes)}</td><td>${m.limit>0?kb(m.limit)+pctTag(m.percent):text('quotaUnlimited')}</td></tr>`).join('');
const svc=s.services||{pop:true,imap:true};const onoff=v=>`<span class="tag ${v?'ok':'warn'}">${text(v?'serviceOn':'serviceOff')}</span>`;
$('#page-pane').innerHTML=`<h1 class="page-title">${text('nav_status')}</h1><p class="muted">${esc(fill('statusIntro',{host:s.host,version:s.version||'-'}))}</p>
<section class="card"><h2>${text('statsSection')}</h2><p class="muted">${esc(fill('statsIntro',{tz:st.timezone||''}))}</p><table class="simple"><thead><tr><th>${text('statPeriod')}</th><th>${text('statSent')}</th><th>${text('statReceived')}</th><th>${text('statTotal')}</th></tr></thead><tbody>${periodRows}</tbody></table>
<h3>${text('statLimits')}</h3>${kv([[text('statAccounts'),esc(usageLine(st.usage.accounts,countUnit))],[text('statTotalUsage'),esc(usageLine(st.usage.total,kb))],[text('statMonthly'),esc(usageLine(st.usage.monthly,countUnit))],[text('largeFileHeading'),esc(fill('largeUsage',{count:(s.largeFiles||{}).count||0,size:kb((s.largeFiles||{}).bytes||0)}))]])}<p class="meta">${text('mimeNote')}</p>
<h3>${text('statMailboxes')}</h3>${boxes?`<table class="simple"><thead><tr><th>${text('userMailbox')}</th><th>${text('statMessages')}</th><th>${text('usageLabel')}</th><th>${text('quotaLimit')}</th></tr></thead><tbody>${boxes}</tbody></table>`:`<p class="muted">${text('empty')}</p>`}</section>
${s.mail?`<section class="card"><h2>${text('statusMailSection')}</h2><p class="muted">${text('mailConfigIntro')}</p>${kv([[text('statusInbound'),text('mode_'+s.mail.inbound)],[text('statusOutbound'),text('mode_'+s.mail.outbound)],[text('statusOutboundState'),`<span class="tag ${s.mail.status&&s.mail.status.provider==='ok'?'ok':'warn'}" title="${esc((s.mail.status&&s.mail.status.detail)||'')}">${s.mail.status&&s.mail.status.provider==='ok'?text('serviceOn'):esc(tr((s.mail.status||{}).provider||'mail_provider_unavailable'))}</span>${s.mail.status&&s.mail.status.relay?` <code>${esc(s.mail.status.relay.host)}:${esc(s.mail.status.relay.port)}</code>`:''}${s.mail.status&&s.mail.status.provider!=='ok'&&s.mail.status.detail?`<div class="meta" style="margin-top:4px;white-space:pre-wrap">${esc(s.mail.status.detail)}</div>`:''}`],[text('statusHaraka'),(()=>{const h=s.mail.haraka;if(!h)return `<span class="tag">${text('harakaOff')}</span>`;const st=h.status||{};const on=h.installed&&h.status&&!h.stale&&st.running;return `<span class="tag ${on?'ok':'warn'}">${text(!h.installed?'harakaNotInstalled':!h.status||h.stale?'harakaStopped':st.running?'harakaRunning':'harakaWaiting')}</span>${on?` <code>${esc((st.listen||[]).join(', '))}</code>`:''}`;})()],[text('statusConfigFile'),`<code>${esc(s.mail.configFile||'')}</code>`]])}<div class="actions"><a class="chip" href="${BASE}setup">🛠 ${text('mailConfigOpen')}</a></div></section>`:''}
<section class="card"><h2>${text('statusServices')}</h2>${kv([[text('statusTls'),`<span class="tag ${s.tls?'ok':'warn'}">${text(s.tls?'imapTlsActive':'statusTlsMissing')}</span>`],['IMAP',`<code>${s.ports.imaps}</code> / <code>${s.ports.imap}</code> ${onoff(svc.imap!==false)}`],['POP3',`<code>${s.ports.pop3s}</code> / <code>${s.ports.pop3}</code> ${onoff(svc.pop!==false)}`],[text('statusPlainLogin'),`<span class="tag ${s.plainLogin?'warn':'ok'}">${text(s.plainLogin?'popPlainAllowed':'popPlainRequired')}</span>`],['SMTP',`<code>${s.ports.smtps}</code> / <code>${s.ports.submission}</code>`],[text('popDeliveries'),esc(fill('popDeliveriesValue',{count:s.pop.deliveries.n||0,accounts:s.pop.deliveries.accounts||0,last:fullDate(s.pop.deliveries.last)||'-'}))]])}</section>
<section class="card"><h2>${text('receiveSettings')}</h2><p>${text('receiveIntro')}</p>${kv([[text('webhookUrl'),`<code>${esc(s.webhook.url)}</code>`],[text('webhookEvent'),'<code>email.received</code>'],[text('webhookSecret'),`<span class="tag ${s.webhook.secretConfigured?'ok':'warn'}">${text(s.webhook.secretConfigured?'webhookSecretOk':'webhookSecretMissing')}</span>`],[text('syncInterval'),esc(fill('syncIntervalValue',{minutes:s.sync.intervalMinutes}))],[text('retention'),text('retentionValue')],[text('storage'),esc(fill('storageValue',{total:s.storage.total||0,raw:s.storage.raw_stored||0,built:s.storage.raw_built||0,webhook:s.storage.via_webhook||0,sync:s.storage.via_sync||0}))]])}<div class="actions"><button id="sync-now" class="primary">${text('syncNow')}</button></div><h3>${text('webhookEvents')}</h3>${events?`<table class="simple"><tbody>${events}</tbody></table>`:`<p class="muted">${text('empty')}</p>`}<h3>${text('syncRuns')}</h3>${runs?`<table class="simple"><tbody>${runs}</tbody></table>`:`<p class="muted">${text('empty')}</p>`}</section>`;
$('#sync-now').onclick=async()=>{try{const r=await api('admin/sync','POST',{});notify(r.started?'syncStarted':'syncing');setTimeout(()=>{if(S.view==='status')statusPage();},6000);}catch(e){fail(e);}};
}
// ------------------------------------------------------------------ 계정 관리 (관리자)
/**
* 관리자 페이지 "계정 관리". 계정 표(상태에 '승인 대기' 포함, 사용량/최대 크기 열) + 비밀번호 정책 카드 + 0.9.0 설정 카드(회원 가입·메일 제한·서비스).
* 계정 목록(admin/users)과 설정(admin/config)을 함께 읽어 한 화면에 그립니다.
*/
async function adminPage(){
viewVersion++;showPanes(true);let d,cfg;try{[d,cfg]=await Promise.all([api('admin/users'),api('admin/config')]);}catch(e){fail(e);return;}
// 서비스 열: 계정 설정에서 POP·IMAP 접속을 켰는지. 행을 누르면 계정 수정 창에서 바꿀 수 있습니다.
const svc=x=>`<td>${x.pop!==false?'<span class="tag ok">POP</span>':'<span class="tag">POP ✕</span>'} ${x.imap!==false?'<span class="tag ok">IMAP</span>':'<span class="tag">IMAP ✕</span>'}</td>`;
const usage=u=>`<td class="meta">${kb(u.usageBytes||0)}${u.limit>0?` / ${kb(u.limit)}`:''}</td>`;
const status=u=>u.status==='pending'?`<span class="tag warn">${text('status_pending')}</span>`:text('status_'+u.status);
const accounts=d.accounts||cfg.accounts||{used:d.users.length,limit:0};
// 웹메이커 계정(1.0.8): d.external 의 행은 읽기 전용(이름·비밀번호·역할은 웹메이커가 관리)이며 POP/IMAP 허용·메일함 크기만 바꿉니다. 파일을 못 읽으면 그 사유를 안내합니다.
const ext=d.external||[],extSrc=d.externalSource||null;
const extRows=ext.map((u,i)=>`<tr data-row="ext:${i}" tabindex="0" class="external"><td><code>${esc(u.login)}</code></td><td>${esc(u.name)}</td><td>${esc(u.mailbox)}</td><td>${text('role_'+u.role)}</td><td><span class="tag">${text('webmakerAccount')}</span>${u.status!=='active'?' '+text('status_suspended'):''}</td>${svc(u.services||{})}${usage(u)}<td class="meta">${esc(tr('kind_'+u.kind))}</td></tr>`).join('');
const extNote=extSrc?(extSrc.ok?esc(fill('adminUsersIntroWebmaker',{n:ext.length,site:extSrc.site?extSrc.site.title||extSrc.site.id:tr('webmakerNoSite')})):`<span class="tag warn">${text('webmakerReadFailed')}</span> ${esc(extSrc.error||'')}`):'';
$('#page-pane').innerHTML=`<h1 class="page-title">${text('adminUsers')}</h1><p class="muted">${esc(fill('adminUsersIntro',{domain:d.domain}))}${d.me?' '+esc(text('adminUsersIntroWww')):''}${extNote?' '+extNote:''}</p>${S.initialPassword?`<div class="notice" style="margin:0 0 12px"><strong>⚠ ${text('initialPasswordWarn')}</strong></div>`:''}<section class="card"><div class="actions" style="margin:0 0 12px;align-items:center"><button class="primary" id="add-user">+ ${text('addUser')}</button><span class="meta">${esc(usageLine(accounts,countUnit))}</span></div><table class="simple"><thead><tr><th>${text('userLogin')}</th><th>${text('userName')}</th><th>${text('userMailbox')}</th><th>${text('userRole')}</th><th>${text('userStatus')}</th><th>${text('userServices')}</th><th>${text('usageLabel')}</th><th>${text('userLastLogin')}</th></tr></thead><tbody>${d.me?`<tr data-row="me" tabindex="0"><td><code>admin</code></td><td>${esc(d.me.name||d.me.login)}</td><td>${esc(d.defaultMailbox)}</td><td>${text('role_admin')}</td><td>${text('wwwAdminNote')}</td>${svc(d.me.services||{})}${usage(d.me)}<td></td></tr>`:''}${extRows}${d.users.map((u,i)=>`<tr data-row="${i}" tabindex="0" class="${u.status==='pending'?'pending':''}"><td><code>${esc(u.login)}</code></td><td>${esc(u.name)}</td><td>${esc(u.mailbox)}</td><td>${text('role_'+u.role)}</td><td>${status(u)}</td>${svc(u.services||{})}${usage(u)}<td class="meta">${esc(fullDate(u.last_login_at))}</td></tr>`).join('')}</tbody></table><p class="meta" style="margin-top:12px">${esc(fill('userHelpMailbox',{domain:d.domain}))}</p></section>${configCards(cfg)}${policyCard(d.policy||{})}`;
const open=u=>userDialog(u,d.domain,d.policy,d.system||cfg.system||{});
$('#add-user').onclick=()=>open(null);$$('[data-row]',$('#page-pane')).forEach(r=>{r.onclick=()=>open(r.dataset.row==='me'?{me:true,...d.me}:r.dataset.row.startsWith('ext:')?{external:true,...ext[Number(r.dataset.row.slice(4))]}:d.users[Number(r.dataset.row)]);r.onkeydown=e=>{if(e.key==='Enter')r.click();};});
// 비밀번호 정책 저장(0.8.24): 최소 길이(6~64)와 대·소문자/숫자/특수문자 요구를 PATCH 하고, 결과로 화면의 정책 안내를 갱신합니다.
const pf=$('#policy-form');pf.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',pf);b.disabled=true;try{const r=await api('admin/policy','PATCH',{minLength:Number(pf.minLength.value),mixedCase:pf.mixedCase.checked,digit:pf.digit.checked,special:pf.special.checked});d.policy=r.policy;$('#policy-now').textContent=passwordRule(r.policy);notify('policySaved');}catch(err){fail(err);}finally{b.disabled=false;}};
// 설정 카드(0.9.0): 카드마다 자기 부분만 PATCH admin/config 로 보냅니다. 제한 값은 MB 입력을 바이트로 바꿔 보내고, 시스템 제한을 넘으면 서버가 limit_exceeds_system 으로 거부합니다.
const bindConfig=(sel,build)=>{const f=$(sel);if(!f)return;f.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',f);b.disabled=true;try{cfg=await api('admin/config','PATCH',build(f.elements));notify('configSaved');}catch(err){fail(err);}finally{b.disabled=false;}};};
bindConfig('#signup-form-admin',f=>({signup:{enabled:f.enabled.checked,approval:f.approval.value==='approve'}}));
bindConfig('#limits-form',f=>({limits:{mailboxBytes:toBytes(f.mailboxMb.value),attachmentBytes:toBytes(f.attachmentMb.value),recipients:Math.max(0,Math.round(Number(f.recipients.value)||0))}}));
bindConfig('#services-form',f=>({services:{pop:f.pop.checked,imap:f.imap.checked,plainLogin:f.plainLogin.checked}}));
bindConfig('#large-form',f=>({largeFiles:{enabled:f.enabled.checked,maxBytes:toBytes(f.maxMb.value),maxDays:Math.round(Number(f.maxDays.value)||0),maxDownloads:Math.round(Number(f.maxDownloads.value)||0)}}));
}
/** MB 입력을 바이트로(빈 값·0 = 제한 없음). 소수 입력(1.5)도 받습니다. */
const toBytes=v=>Math.max(0,Math.round((Number(v)||0)*1048576));
const fromBytes=b=>b>0?String(Math.round(b/1048576*100)/100):'';
/** 시스템 제한 안내 한 줄: "시스템 제한: 500 MB 이내" 또는 "시스템 제한 없음". 관리자가 넣을 수 있는 최댓값을 알려 줍니다. */
const sysNote=(v,unit)=>v>0?fill('systemCap',{max:unit(v)}):tr('systemNoCap');
/**
* 관리자 설정 카드(0.9.0).
* - 회원 가입: 로그인 화면에 가입 단추를 보일지, 가입 계정을 즉시 사용 / 관리자 승인 후 사용 중 무엇으로 할지.
* - 메일 제한: 계정별 기본 메일함 크기, 첨부 한 개 최대 크기(MIME 기준), 한 메일의 받는 사람 수. 각 값은 시스템 제한(설치 시 설정) 이내로만 저장됩니다. 0 = 시스템 값 그대로.
* - 서비스: POP3 / IMAP 서비스 자체를 켜고 끕니다. 끄면 모든 계정의 메일 프로그램 접속이 거부됩니다.
*/
/** 대용량 첨부파일 카드(0.9.3): 사용 여부와 최대 크기(MB)·보관 기간(일)·다운로드 횟수. 모두 1 이상이어야 하고(무제한 불가) 시스템 상한 이내이며, 기본값은 시스템 값의 50% 입니다. */
function largeCard(c){const lf=c.largeFiles||{},sys=c.systemLargeFiles||{},u=c.largeFileUsage||{count:0,bytes:0};
return `<section class="card"><h2>${text('largeSection')}</h2><p class="muted">${text('largeAdminIntro')}</p><form id="large-form" class="auto-form"><label class="switch"><input type="checkbox" name="enabled" ${lf.enabled?'checked':''}><span>${text('largeEnable')}</span></label><div class="grid2"><label><span>${text('largeMaxMb')}</span><input name="maxMb" type="number" min="0.1" step="0.1" required value="${fromBytes(lf.maxBytes||0)}"></label><p class="meta" style="align-self:end">${esc(fill('systemCapStrict',{max:kb(sys.bytes||0)}))}</p><label><span>${text('largeMaxDays')}</span><input name="maxDays" type="number" min="1" step="1" required value="${lf.maxDays||1}"></label><p class="meta" style="align-self:end">${esc(fill('systemCapStrict',{max:fill('daysUnit',{n:sys.days||0})}))}</p><label><span>${text('largeMaxDownloads')}</span><input name="maxDownloads" type="number" min="1" step="1" required value="${lf.maxDownloads||1}"></label><p class="meta" style="align-self:end">${esc(fill('systemCapStrict',{max:fill('timesUnit',{n:sys.downloads||0})}))}</p></div><p class="meta">${text('largeAdminHelp')} ${esc(fill('largeUsage',{count:u.count,size:kb(u.bytes)}))}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>`;}
function configCards(c){const sys=c.system||{},lim=c.limits||{},sg=c.signup||{},sv=c.services||{};
return `<section class="card"><h2>${text('signupSection')}</h2><p class="muted">${text('signupAdminIntro')}</p><form id="signup-form-admin" class="auto-form"><label class="switch"><input type="checkbox" name="enabled" ${sg.enabled?'checked':''}><span>${text('signupEnable')}</span></label><fieldset class="services"><legend>${text('signupApprovalLegend')}</legend><label class="switch"><input type="radio" name="approval" value="immediate" ${sg.approval===false?'checked':''}><span>${text('signupImmediate')}</span></label><label class="switch"><input type="radio" name="approval" value="approve" ${sg.approval!==false?'checked':''}><span>${text('signupApprove')}</span></label></fieldset><p class="meta">${text('signupAdminHelp')}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>
<section class="card"><h2>${text('limitsSection')}</h2><p class="muted">${text('limitsIntro')}</p><form id="limits-form" class="auto-form"><div class="grid2"><label><span>${text('limitMailboxMb')}</span><input name="mailboxMb" type="number" min="0" step="0.1" value="${fromBytes(lim.mailboxBytes)}" placeholder="0"></label><p class="meta" style="align-self:end">${esc(sysNote(sys.mailboxBytes,kb))}</p><label><span>${text('limitAttachmentMb')}</span><input name="attachmentMb" type="number" min="0" step="0.1" value="${fromBytes(lim.attachmentBytes)}" placeholder="0"></label><p class="meta" style="align-self:end">${esc(sysNote(sys.attachmentBytes,kb))}</p><label><span>${text('limitRecipientsN')}</span><input name="recipients" type="number" min="0" step="1" value="${lim.recipients>0?lim.recipients:''}" placeholder="0"></label><p class="meta" style="align-self:end">${esc(sysNote(sys.recipients,countUnit))}</p></div><p class="meta">${text('limitsHelp')} ${text('mimeNote')}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form>
<h3>${text('systemSection')}</h3><p class="meta">${text('systemIntro')}</p><dl class="kv">${[['limit_accounts',sys.accounts>0?countUnit(sys.accounts):tr('noLimit')],['limit_totalBytes',sys.totalBytes>0?kb(sys.totalBytes):tr('noLimit')],['limit_mailboxBytes',sys.mailboxBytes>0?kb(sys.mailboxBytes):tr('noLimit')],['limit_attachmentBytes',sys.attachmentBytes>0?kb(sys.attachmentBytes):tr('noLimit')],['limit_recipients',sys.recipients>0?countUnit(sys.recipients):tr('noLimit')],['limit_monthlyMessages',sys.monthlyMessages>0?countUnit(sys.monthlyMessages):tr('noLimit')]].map(([k,v])=>`<dt>${text(k)}</dt><dd>${esc(v)}</dd>`).join('')}</dl></section>
${largeCard(c)}<section class="card"><h2>${text('servicesAdminSection')}</h2><p class="muted">${text('servicesAdminIntro')}</p><form id="services-form" class="auto-form"><label class="switch"><input type="checkbox" name="pop" ${sv.pop!==false?'checked':''}><span>${text('servicePop')}</span></label><label class="switch"><input type="checkbox" name="imap" ${sv.imap!==false?'checked':''}><span>${text('serviceImap')}</span></label><label class="switch"><input type="checkbox" name="plainLogin" ${sv.plainLogin!==false?'checked':''}><span>${text('servicePlainLogin')}</span></label><p class="meta">${text('servicePlainLoginHelp')}</p><p class="meta">${text('servicesAdminHelp')}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>`;}
/**
* 비밀번호 정책(0.8.24~).
* - passwordRule(p): 정책을 한 문장으로("비밀번호는 6자 이상, 숫자 포함이어야 합니다."). 계정 창의 안내문과 정책 카드, weak_password 오류 안내에 같이 씁니다.
* - passwordError(err): 서버가 weak_password 와 함께 보낸 problems(어긋난 항목)로 어느 규칙에 걸렸는지 적습니다.
* - policyCard(p): 관리자 페이지 아래의 정책 편집 카드. 최소 길이는 6 미만으로 내릴 수 없습니다(요구사항: 최소 6자 이상).
*/
function ruleParts(p,only){const parts=[];const want=k=>!only||only.includes(k);if(want('length'))parts.push(fill('passwordRuleLength',{n:p.minLength||6}));if(p.mixedCase&&want('mixedCase'))parts.push(tr('passwordRuleMixedCase'));if(p.digit&&want('digit'))parts.push(tr('passwordRuleDigit'));if(p.special&&want('special'))parts.push(tr('passwordRuleSpecial'));return parts;}
function passwordRule(p){return fill('passwordRule',{rules:ruleParts(p||{}).join(', ')});}
function passwordError(err){if(err.message!=='weak_password'||!err.policy)return errText(err);const parts=ruleParts(err.policy,err.problems||[]);return fill('weakPasswordDetail',{rules:parts.join(', ')||fill('passwordRuleLength',{n:err.policy.minLength})});}
function policyCard(p){return `<section class="card"><h2>${text('policySection')}</h2><p class="muted">${text('policyIntro')}</p><form id="policy-form" class="auto-form"><div class="grid2"><label><span>${text('policyMinLength')}</span><input name="minLength" type="number" min="6" max="64" step="1" required value="${Number(p.minLength)||6}"></label><p class="meta" style="align-self:end">${text('policyMinLengthHelp')}</p></div><label class="switch"><input type="checkbox" name="mixedCase" ${p.mixedCase?'checked':''}><span>${text('policyMixedCase')}</span></label><label class="switch"><input type="checkbox" name="digit" ${p.digit?'checked':''}><span>${text('policyDigit')}</span></label><label class="switch"><input type="checkbox" name="special" ${p.special?'checked':''}><span>${text('policySpecial')}</span></label><p class="meta" id="policy-now">${esc(passwordRule(p))}</p><div class="actions"><button class="primary" type="submit">${text('save')}</button></div></form></section>`;}
/**
* 계정 추가·수정 창.
* - 메일함 주소는 입력받지 않고 "로그인 ID@도메인" 으로 자동 지정됩니다(0.8.4~). 새 계정은 ID 를 입력하는 대로 미리 보여 줍니다.
* - 비밀번호(0.9.0): 다른 계정은 한 번만 입력하고 눈 단추로 확인합니다(예전의 두 번 입력·현재 비밀번호 확인은 설정 화면의 "내 비밀번호 변경" 으로 옮김).
* 자기 계정을 열면 비밀번호 칸 대신 "도구 → 설정에서 바꾸세요" 안내만 보입니다.
* - 메일함 최대 크기(0.9.0): MB 로 입력, 0/빈 값이면 관리자 기본값·시스템 값을 따릅니다. 시스템 제한 이내로만 저장됩니다.
* - 승인 대기 계정(0.9.0): "승인"(상태를 사용으로) / "거절"(계정 삭제) 단추가 보입니다.
* - POP / IMAP 접속 허용을 계정마다 켜고 끌 수 있습니다(서버에는 계정 설정 prefs 로 저장).
* - u.me 는 관리자 자신(www 계정) 행: 이름·비밀번호는 www 사이트에서 관리하므로 접속 허용만 바꿉니다.
*/
function userDialog(u,domain,policy,system){policy=policy||{minLength:6};system=system||{};const edit=!!u,me=!!(u&&u.me),external=!!(u&&u.external);u=u||{role:'user',status:'active',services:{pop:true,imap:true},quotaBytes:0};const sv=u.services||{pop:true,imap:true};
const self=edit&&!me&&u.id===S.me.id,pending=u.status==='pending';
$('#dialog-title').textContent=tr(me?'editMyServices':external?'editExternalUser':edit?'editUser':'addUser');$('#dialog-feedback').textContent='';
const services=`<fieldset class="services"><legend>${text('userServices')}</legend><label class="switch"><input type="checkbox" name="pop" ${sv.pop!==false?'checked':''}><span>${text('prefPop')}</span></label><label class="switch"><input type="checkbox" name="imap" ${sv.imap!==false?'checked':''}><span>${text('prefImap')}</span></label><p class="meta">${text('servicesHelp')}</p></fieldset>`;
const quota=`<label><span>${text('userQuotaMb')}</span><input name="quotaMb" type="number" min="0" step="0.1" value="${fromBytes(u.quotaBytes||0)}" placeholder="0"></label><p class="meta" style="align-self:end">${esc(sysNote(system.mailboxBytes,kb))}${u.limit>0?' · '+esc(fill('quotaNow',{limit:kb(u.limit)})):''}</p>`;
$('#dialog-body').innerHTML=me?`<form id="user-form"><p class="muted">${text('wwwAdminServicesHelp')}</p>${services}<div class="actions"><button class="primary" type="submit">${text('save')}</button><button type="button" class="ghost" id="cancel-user">${text('close')}</button></div></form>`
:external?`<form id="user-form"><p class="muted">${esc(fill('webmakerAccountHelp',{login:u.login,mailbox:u.mailbox}))}</p><div class="grid2">${quota}</div>${services}<div class="actions"><button class="primary" type="submit">${text('save')}</button><button type="button" class="ghost" id="cancel-user">${text('close')}</button></div></form>`
:`<form id="user-form">${pending?`<div class="notice" style="margin:0 0 12px"><strong>${text('pendingNotice')}</strong></div>`:''}<div class="grid2"><label><span>${text('userLogin')}</span><input name="login" value="${esc(u.login||'')}" ${edit?'readonly':'required'} autocomplete="off" pattern="[a-z0-9][a-z0-9._\\-]{1,63}"></label><label><span>${text('userName')}</span><input name="name" value="${esc(u.name||'')}"></label><label><span>${text('userMailbox')}</span><input id="user-mailbox" value="${esc(u.mailbox||'@'+domain)}" readonly tabindex="-1" title="${esc(tr('userMailboxAuto'))}"></label>${self?`<p class="meta" style="align-self:end">${text('selfPasswordNote')} ${text('selfRoleNote')}</p>`:pwField('password',text(edit?'userPasswordEdit':'userPassword'),`autocomplete="new-password" ${edit?'':'required'} minlength="${Number(policy.minLength)||6}" title="${esc(passwordRule(policy))}"`)}<label><span>${text('userRole')}</span><select name="role" ${self?'disabled':''}><option value="user" ${u.role==='user'?'selected':''}>${text('role_user')}</option><option value="admin" ${u.role==='admin'?'selected':''}>${text('role_admin')}</option></select></label><label><span>${text('userStatus')}</span><select name="status" ${self?'disabled':''}><option value="active" ${u.status==='active'?'selected':''}>${text('status_active')}</option><option value="suspended" ${u.status==='suspended'||pending?'selected':''}>${pending?text('status_pending'):text('status_suspended')}</option></select></label>${quota}</div><p class="meta">${text('userMailboxAuto')} ${self?'':esc(passwordRule(policy))}</p>${services}<div class="actions">${pending?`<button type="button" class="primary" id="approve-user">✔ ${text('approveUser')}</button><button type="button" class="danger" id="reject-user">${text('rejectUser')}</button>`:''}<button class="${pending?'':'primary'}" type="submit">${text('save')}</button>${edit&&!pending?`<button type="button" class="danger" id="delete-user">${text('deleteUser')}</button>`:''}<button type="button" class="ghost" id="cancel-user">${text('close')}</button></div></form>`;
const dlg=$('#detail');const form=$('#user-form');$('#cancel-user').onclick=()=>dlg.close();
// 새 계정: 로그인 ID 를 입력하는 대로 메일함 주소를 "id@도메인" 으로 미리 보여 줍니다(서버도 같은 규칙으로 정함).
if(!edit)form.elements.login.oninput=()=>{$('#user-mailbox').value=form.elements.login.value.trim().toLowerCase()+'@'+domain;};
bindEyes(form);
const submit=async(extra={})=>{const body=Object.fromEntries(new FormData(form));const payload={services:{pop:form.elements.pop.checked,imap:form.elements.imap.checked},quotaBytes:toBytes(body.quotaMb)};delete body.pop;delete body.imap;delete body.quotaMb;
if(me)await api('admin/users/me','PATCH',payload);else if(external)await api('admin/users/'+encodeURIComponent(u.id),'PATCH',payload);else if(edit){if(!body.password)delete body.password;delete body.login;await api('admin/users/'+u.id,'PATCH',{...body,...payload,...extra});}else await api('admin/users','POST',{...body,...payload});};
form.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',form);b.disabled=true;$('#dialog-feedback').textContent='';try{await submit();dlg.close();notify('userSaved');adminPage();}catch(err){$('#dialog-feedback').textContent=passwordError(err);}finally{b.disabled=false;}};
// 승인: 상태를 '사용' 으로 저장(서버가 승인 대기 표시를 지움). 거절: 계정 삭제(메일함의 메일은 남음).
if($('#approve-user'))$('#approve-user').onclick=async()=>{try{form.elements.status.value='active';await submit({status:'active'});dlg.close();notify('userApproved');adminPage();}catch(err){$('#dialog-feedback').textContent=passwordError(err);}};
if($('#reject-user'))$('#reject-user').onclick=async()=>{if(!confirm(tr('confirmRejectUser')))return;try{await api('admin/users/'+u.id,'DELETE');dlg.close();notify('userRejected');adminPage();}catch(err){$('#dialog-feedback').textContent=errText(err);}};
if($('#delete-user'))$('#delete-user').onclick=async()=>{if(!confirm(tr('confirmDeleteUser')))return;try{await api('admin/users/'+u.id,'DELETE');dlg.close();adminPage();}catch(err){$('#dialog-feedback').textContent=errText(err);}};
if(!dlg.open)dlg.showModal();
}
// 패널 너비 조절: 손잡이를 끌어 왼쪽 메일함·목록 창 너비를 바꾸고 브라우저에 기억합니다.
(function resizers(){const root=document.documentElement;const load=(k,def,min,max)=>{try{const v=Number(localStorage.getItem(k));return v>=min&&v<=max?v:def;}catch{return def;}};
const state={sidebar:load('mg:sidebar-w',230,160,420),list:load('mg:list-w',400,260,760)};root.style.setProperty('--sidebar-w',state.sidebar+'px');root.style.setProperty('--list-w',state.list+'px');
const bind=(el,key,min,max,getBase)=>{if(!el)return;let startX=0,base=0;const move=e=>{const x=e.touches?e.touches[0].clientX:e.clientX;const v=Math.min(max,Math.max(min,base+(x-startX)));state[key]=v;root.style.setProperty(key==='sidebar'?'--sidebar-w':'--list-w',v+'px');};const up=()=>{document.body.classList.remove('resizing');el.classList.remove('active');document.removeEventListener('mousemove',move);document.removeEventListener('mouseup',up);document.removeEventListener('touchmove',move);document.removeEventListener('touchend',up);try{localStorage.setItem(key==='sidebar'?'mg:sidebar-w':'mg:list-w',String(state[key]));}catch{}};
const down=e=>{startX=e.touches?e.touches[0].clientX:e.clientX;base=getBase();document.body.classList.add('resizing');el.classList.add('active');document.addEventListener('mousemove',move);document.addEventListener('mouseup',up);document.addEventListener('touchmove',move,{passive:true});document.addEventListener('touchend',up);e.preventDefault();};
el.addEventListener('mousedown',down);el.addEventListener('touchstart',down,{passive:false});el.addEventListener('dblclick',()=>{state[key]=key==='sidebar'?230:400;root.style.setProperty(key==='sidebar'?'--sidebar-w':'--list-w',state[key]+'px');try{localStorage.removeItem(key==='sidebar'?'mg:sidebar-w':'mg:list-w');}catch{}});};
bind($('#rz-sidebar'),'sidebar',160,420,()=>$('#sidebar').getBoundingClientRect().width);bind($('#rz-list'),'list',260,760,()=>$('#list-pane').getBoundingClientRect().width);})();
setInterval(()=>{if(S.me&&S.view==='mail'&&!$('#compose').open&&!$('#detail').open)renderMail(true);},60000);
start();
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#2b64c8"/><path d="M14 20h36v24H14z" fill="#fff"/><path d="M14 20l18 14 18-14" fill="none" stroke="#2b64c8" stroke-width="4" stroke-linejoin="round"/></svg>

After

Width:  |  Height:  |  Size: 260 B

+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html><html lang="ko" data-host="__HOST__" data-domain="__DOMAIN__" data-standalone="__STANDALONE__" data-auth="__AUTH__" data-base="__BASE__" data-version="__VERSION__"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><meta name="color-scheme" content="light"><title>__TITLE__</title><link rel="icon" href="__BASE__favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="__BASE__style.css?v=__VERSION__"></head><body>
<form id="login" hidden autocomplete="on"><button type="button" id="login-help" class="help-btn" title="접속 안내" aria-label="접속 안내">?</button><div class="login-card"><div class="login-brand"><span class="logo">✉</span><div class="brand-text"><h1 data-a="appName"></h1><small class="app-version">v__VERSION__</small></div></div><p id="login-mode" class="login-mode" hidden></p><label><span data-a="loginId"></span><input name="login" autocomplete="username" required autofocus></label><label><span data-a="password"></span><input name="password" type="password" autocomplete="current-password" required></label><button class="primary" data-a="signIn"></button><button type="button" id="signup-btn" class="link-btn" hidden data-a="signupLink"></button><p id="login-feedback" role="alert"></p></div></form>
<div id="workspace" hidden>
<header class="topbar"><button id="menu-toggle" class="icon-btn" aria-label="메뉴" title="메뉴">☰</button><div class="brand"><span class="logo">✉</span><span class="brand-text"><span><span data-a="appName"></span><span id="brand-mode" class="brand-mode" hidden></span></span><small class="app-version">v__VERSION__</small></span></div><div class="search"><input id="global-search" type="search" autocomplete="off"><button id="global-search-go" class="icon-btn" title="검색">🔍</button></div><div class="topbar-right"><span id="whoami" class="who"></span><button id="logout" class="ghost" data-a="logout"></button></div></header>
<div class="shell">
<aside id="sidebar" class="sidebar"><button id="compose-btn" class="compose-btn">✎ <span data-a="compose"></span></button><nav id="folder-nav" class="folders"></nav><div class="side-title" data-a="filters"></div><nav id="filter-nav" class="folders small"></nav><div class="side-title" data-a="toolsSection"></div><nav id="tool-nav" class="folders small"></nav><div id="usage" class="usage"></div></aside>
<div class="resizer" id="rz-sidebar" role="separator" aria-orientation="vertical" title="너비 조절"></div><main id="main" class="main"><div id="feedback" role="status"></div><section id="list-pane" class="list-pane"></section><div class="resizer" id="rz-list" role="separator" aria-orientation="vertical" title="너비 조절"></div><section id="read-pane" class="read-pane"></section><section id="page-pane" class="page-pane" hidden></section></main>
</div>
</div>
<dialog id="compose"><div id="compose-body"></div></dialog>
<dialog id="detail"><button class="close" type="button" data-a="close"></button><h2 id="dialog-title"></h2><div id="dialog-feedback" role="status"></div><div id="dialog-body"></div></dialog>
<script id="strings" type="application/json">__STRINGS__</script>
<script src="__BASE__app.js?v=__VERSION__"></script>
</body></html>
+30
View File
@@ -0,0 +1,30 @@
/* AriMail 메일 서비스 설정 화면(1.0.0). 웹 메일(style.css)과 같은 색·글꼴을 쓰되 이 화면 하나만을 위해 작게 따로 둡니다(전용 포트에서 style.css 를 받지 않음). */
:root{--brand:#2b64c8;--brand-dark:#1f4c9b;--bg:#f3f5f8;--card:#fff;--text:#1d2733;--muted:#6b7a8c;--line:#dfe5ee;--ok:#1d6b33;--ok-bg:#e3f3e6;--warn:#8d2323;--warn-bg:#fde8e6;--note-bg:#fff8e6;--note-line:#ffe2a8}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo",sans-serif}
a{color:var(--brand)}code{background:#eef2f7;padding:1px 6px;border-radius:6px;font-size:13px;overflow-wrap:anywhere}
.top{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 20px;background:var(--brand-dark);color:#fff}.top .brand{display:flex;align-items:center;gap:12px}.top .logo{font-size:26px}.top .muted{color:#c9d6ee}.top .meta{font-size:12px;color:#c9d6ee}
.top-links a{color:#fff;margin-left:14px;font-size:13px;text-decoration:none;opacity:.9}.top-links a:hover{text-decoration:underline}
main{max-width:980px;margin:18px auto 40px;padding:0 16px;display:flex;flex-direction:column;gap:14px}
.card{background:var(--card);border-radius:14px;padding:18px 20px;box-shadow:0 6px 24px rgba(20,35,60,.08)}.card h2{margin:0 0 6px;font-size:17px}.card h3{margin:16px 0 6px;font-size:14px;color:var(--muted)}.card .intro{margin:0 0 12px;color:var(--muted);font-size:14px}
.summary{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:10px}.summary .item{background:var(--bg);border-radius:10px;padding:10px 12px}.summary .item .k{font-size:12px;color:var(--muted)}.summary .item .v{font-weight:600;overflow-wrap:anywhere}
.tag{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12px;background:#e8edf3;color:#3a4a60;vertical-align:middle}.tag.ok{background:var(--ok-bg);color:var(--ok)}.tag.warn{background:var(--warn-bg);color:var(--warn)}.tag.off{background:#eceff3;color:#7a879a}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 16px}@media(max-width:640px){.grid{grid-template-columns:1fr}}
label.field{display:flex;flex-direction:column;gap:4px;font-size:13px;color:var(--muted)}label.field input,label.field select{font:inherit;font-size:14px;color:var(--text);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;min-height:38px}label.field input:focus,label.field select:focus{outline:2px solid #b9cdf3;border-color:var(--brand)}
label.field .help{font-size:12px;color:var(--muted)}label.field .src{font-size:11px;color:#8a97a8}.secret-row{display:flex;gap:6px;align-items:center}.secret-row input{flex:1}.secret-row button{min-height:38px}
.choices{display:flex;flex-direction:column;gap:8px}.choice{display:flex;gap:10px;align-items:flex-start;padding:10px 12px;border:1px solid var(--line);border-radius:10px;cursor:pointer}.choice input{margin-top:4px}.choice.selected{border-color:var(--brand);background:#f3f7fe}.choice .t{font-weight:600}.choice .d{font-size:13px;color:var(--muted)}
button{font:inherit;font-size:14px;border:1px solid var(--line);background:#fff;color:var(--text);border-radius:8px;padding:7px 14px;min-height:36px;cursor:pointer}button:hover{background:#f5f8fc}button.primary{background:var(--brand);border-color:var(--brand);color:#fff}button.primary:hover{background:var(--brand-dark)}button.ghost{border-color:transparent;color:var(--muted)}button:disabled{opacity:.5;cursor:default}button.small{min-height:28px;padding:2px 9px;font-size:12px}
.actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:14px}.actions .status{font-size:13px;color:var(--muted)}
.note{background:var(--note-bg);border:1px solid var(--note-line);border-radius:10px;padding:10px 12px;font-size:13px;margin:10px 0}.note.warn{background:var(--warn-bg);border-color:#f3c3bf}.note.ok{background:var(--ok-bg);border-color:#bfe3c7}
table.simple{width:100%;border-collapse:collapse;font-size:13px}table.simple th,table.simple td{text-align:left;padding:7px 8px;border-bottom:1px solid var(--line);vertical-align:top;overflow-wrap:anywhere}table.simple th{color:var(--muted);font-weight:600;font-size:12px}table.simple td.mono{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:12px}
.copy{display:inline-flex;gap:6px;align-items:center}pre.log{background:#1d2733;color:#d6e0ee;border-radius:10px;padding:10px 12px;font-size:12px;max-height:220px;overflow:auto;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0 0}
.checklist{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:6px}.checklist li{display:flex;gap:10px;align-items:flex-start;font-size:14px}.checklist .mark{width:22px;flex:none;text-align:center}
.foot{max-width:980px;margin:0 auto 30px;padding:0 16px;font-size:12px}.muted{color:var(--muted)}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);background:#1d2733;color:#fff;padding:10px 16px;border-radius:10px;font-size:14px;box-shadow:0 8px 30px rgba(0,0,0,.25);z-index:10;max-width:90vw}.toast.error{background:#8d2323}
.steps{counter-reset:s}.card.step h2::before{counter-increment:s;content:counter(s) ". ";color:var(--brand)}
.hint-token{max-width:560px;margin:40px auto}
.checklist.plan li{align-items:flex-start;padding:8px 0;border-bottom:1px solid var(--line)}.checklist.plan li:last-child{border-bottom:0}.checklist.plan .body{flex:1;min-width:0}.checklist.plan .t{font-weight:600}.checklist.plan .d{font-size:13px;color:var(--muted);margin-top:2px;overflow-wrap:anywhere}.plan-input{margin-top:8px;padding:10px 12px;background:var(--bg);border-radius:10px}
table.simple tr.missing td{background:#fff8f7}table.simple tr.ok td{background:#f6fbf7}label.field textarea{font:inherit;font-size:12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:var(--text);padding:8px 10px;border:1px solid var(--line);border-radius:8px;background:#fff;resize:vertical;min-height:90px;flex:1}
/* 1.0.7 도메인 카드: 목록 표와 추가·수정 폼 */
table.domains td{vertical-align:top}table.domains tr.current td{background:#eef4ff}table.domains tr.off td{color:#8a97a8}table.domains .actions-cell{white-space:nowrap}table.domains .actions-cell button{margin:0 4px 4px 0}
.domain-form{margin-top:14px;padding-top:12px;border-top:1px solid var(--line)}.domain-form.editing{background:#fffdf3;border:1px solid var(--note-line);border-radius:10px;padding:12px}.domain-form h3{margin:0 0 8px}
.meta.warn{color:var(--warn)}
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html><html lang="ko" data-mode="__MODE__" data-host="__HOST__" data-base="__BASE__" data-version="__VERSION__"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex,nofollow"><meta name="referrer" content="no-referrer"><meta name="color-scheme" content="light"><title>__TITLE__</title><link rel="icon" href="__BASE__favicon.svg" type="image/svg+xml"><link rel="stylesheet" href="__BASE__setup.css?v=__VERSION__"></head><body>
<!--
AriMail 메일 서비스 설정 화면(1.0.0). 두 곳에서 같은 파일을 씁니다.
- 전용 포트(기본 8791): data-mode="token" — 배포 출력의 주소(?token=…)로 열고 토큰을 세션 저장소에 둔 뒤 X-Setup-Token 으로 API 를 부릅니다.
- /setup(웹 메일 서버): data-mode="admin" — 관리자 페이지(/admin)에서 로그인한 세션으로 X-Mail-Scope: admin 을 붙여 부릅니다.
화면 구성과 동작은 setup.js, 모양은 setup.css 에 있습니다. 서버가 __MODE__/__HOST__/__VERSION__/__TITLE__/__BASE__(1.0.8: 기준 경로) 자리를 채웁니다(setup.mjs setupPage).
-->
<header class="top"><div class="brand"><span class="logo">✉</span><div><strong id="app-name">AriMail</strong> <span class="muted">메일 서비스 설정</span><div class="meta" id="top-meta">__HOST__ · v__VERSION__</div></div></div><nav class="top-links" id="top-links"></nav></header>
<main id="main"><section class="card" id="loading"><p class="muted">설정을 읽는 중…</p></section></main>
<footer class="foot"><span class="muted">AriMail v__VERSION__ · 저장한 값은 재시작 없이 바로 적용됩니다. 설정 파일은 서버의 데이터 디렉터리(config.json)에 있습니다.</span></footer>
<div id="toast" class="toast" hidden></div>
<script src="__BASE__setup.js?v=__VERSION__"></script>
</body></html>
+312
View File
@@ -0,0 +1,312 @@
/**
* 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 `<section class="card step" id="card-plan"><h2>자동 설정</h2><p class="intro">고른 수·발신 방식과 도메인에 맞게 Resend 도메인·웹훅, DNS 레코드, 내장 Haraka 준비 상태를 진단하고 할 수 있는 일은 자동으로 합니다.</p><div class="actions"><button type="button" class="primary" id="plan-load">상태 진단</button><span class="status muted">Resend·DNS 조회를 하므로 몇 초 걸립니다.</span></div></section>`;
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 ? `<form class="plan-input" data-step="${esc(s.id)}"><div class="grid">${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('')}</div><div class="actions"><button class="primary" type="submit">저장하고 계속</button></div></form>` : '';
const btns = (s.actions || []).map((a) => `<button type="button" class="small" data-plan-action="${esc(a.id)}" data-step="${esc(s.id)}">${esc(a.label)}</button>`).join('') + (s.auto && s.state === 'todo' ? `<button type="button" class="small primary" data-plan-run="${esc(s.id)}">지금 실행</button>` : '');
return `<li class="plan-step state-${esc(s.state)}"><span class="mark">${icon}</span><div class="body"><div class="t">${esc(s.title)} <span class="tag ${cls}">${esc(word)}</span></div>${s.detail ? `<div class="d">${esc(s.detail)}</div>` : ''}${btns ? `<div class="actions" style="margin-top:6px">${btns}</div>` : ''}${inputsHtml}</div></li>`;
}).join('');
const recs = (p.records || []).map((r) => `<tr class="${r.check && r.check.ok ? 'ok' : r.optional ? '' : 'missing'}"><td>${r.check && r.check.ok ? '✅' : r.optional ? '◦' : '⚠️'}</td><td>${esc(r.type)}</td><td class="mono">${esc(r.name)}</td><td class="mono">${esc((r.priority !== undefined ? r.priority + ' ' : '') + r.values.join(' / '))}</td><td class="muted">${esc(r.purpose)}${r.check && !r.check.ok && r.check.found && r.check.found.length ? `<div>지금 값: ${esc(r.check.found.join(' / '))}</div>` : ''}</td><td><button type="button" class="small" data-copy="${esc(r.values.join(' '))}">복사</button></td></tr>`).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 `<section class="card step" id="card-plan"><h2>자동 설정</h2><p class="intro">수신 <strong>${esc(MODES.inbound[p.inbound][0])}</strong> · 발신 <strong>${esc(MODES.outbound[p.outbound][0])}</strong> · 도메인 <code>${esc(p.domain)}</code> · 메일 서버 이름 <code>${esc(p.host)}</code></p>
<div class="note ${p.ready ? 'ok' : p.steps.some((s) => s.state === 'error') ? 'warn' : ''}"><strong>${esc(p.readyMessage)}</strong><div class="meta muted">진단 ${esc(new Date(p.checkedAt).toLocaleString('ko-KR'))}</div></div>
<ul class="checklist plan">${rows}</ul>
<div class="actions"><button type="button" class="primary" id="plan-run" ${planBusy ? 'disabled' : ''}>자동 설정 실행</button><button type="button" id="plan-reload" ${planBusy ? 'disabled' : ''}>다시 진단</button><span class="status muted">${planBusy ? '실행 중…' : '자동 실행 가능한 단계(▶)를 순서대로 처리하고 다시 진단합니다.'}</span></div>
<h3>필요한 DNS 레코드 (${esc(p.domain)})</h3><p class="muted" style="font-size:13px;margin:0 0 8px">${esc(dnsLine)} 공개 DNS(8.8.8.8 / 1.1.1.1) 기준으로 반영 여부를 봅니다.</p>
${recs ? `<table class="simple"><thead><tr><th></th><th>종류</th><th>이름</th><th>값</th><th>용도</th><th></th></tr></thead><tbody>${recs}</tbody></table>` : '<p class="muted">이 구성에는 추가할 DNS 레코드가 없습니다.</p>'}
${PLAN.log && PLAN.log.length ? `<h3>마지막 실행 기록</h3><pre class="log">${esc(PLAN.log.map((l) => `${l.id}: ${l.result}${l.detail ? ' — ' + l.detail : ''}`).join('\n'))}</pre>` : ''}</section>`;
}
/**
* 도메인 카드(1.0.7, 요구사항 1·2): 서버의 모든 도메인 목록과 추가·수정 폼. 전용 포트(token)에서만 보입니다.
* 행의 "선택" 이 아래 카드들의 대상 도메인(DOMAIN)을 바꾸고, "중지/사용" 은 그 도메인의 모든 접속(웹·메일 프로그램·수신)을 막거나 엽니다.
*/
function domainsCard() {
if (MODE !== 'token' || !DOMAINS) return '';
const rows = DOMAINS.domains.map((d) => {
const cur = d.domain === DOMAIN;
const btns = [`<button type="button" class="small ${cur ? '' : 'primary'}" data-domain-select="${esc(d.domain)}" ${cur ? 'disabled' : ''}>${cur ? '보는 중' : '선택'}</button>`, `<button type="button" class="small" data-domain-edit="${esc(d.domain)}">수정</button>`];
if (!d.primary) btns.push(`<button type="button" class="small" data-domain-toggle="${esc(d.domain)}" data-enabled="${d.enabled ? '1' : '0'}">${d.enabled ? '중지' : '사용'}</button>`, `<button type="button" class="small ghost" data-domain-remove="${esc(d.domain)}">삭제</button>`);
return `<tr class="${cur ? 'current' : ''} ${d.enabled ? '' : 'off'}"><td><code>${esc(d.domain)}</code>${d.primary ? ' <span class="tag">기본</span>' : ''}${d.note ? `<div class="meta muted">${esc(d.note)}</div>` : ''}</td><td class="mono">${d.hosts.map(esc).join('<br>')}</td><td>${d.enabled ? tag(true, '사용', '') : tag(false, '', '중지')}</td><td>${esc(AUTH_LABEL[d.auth.mode] || d.auth.mode)}${d.auth.mode === 'www' ? `<div class="meta muted">${esc(d.auth.wwwDbFile)}</div>` : ''}${d.auth.mode === 'webmaker' ? `<div class="meta muted">${esc(d.auth.webmakerDir)}${d.auth.webmakerUser ? ` (sudo -u ${esc(d.auth.webmakerUser)})` : ''}</div>${d.external && d.external.error ? `<div class="meta warn">읽기 실패: ${esc(d.external.error)}</div>` : ''}` : ''}</td><td>${esc(MODES.inbound[d.inbound] ? MODES.inbound[d.inbound][0] : d.inbound)} / ${esc(MODES.outbound[d.outbound] ? MODES.outbound[d.outbound][0] : d.outbound)}</td><td>${d.accounts}명${d.external && !d.external.error ? ` + 웹메이커 ${d.external.count}명` : ''} · ${d.messages}통${d.initialPassword ? `<div class="meta warn">초기 관리자 ${esc(d.mailbox.split('@')[0])} / ${esc(d.initialPassword)}</div>` : ''}</td><td class="actions-cell">${btns.join('')}</td></tr>`;
}).join('');
const e = EDIT, editing = !!e, isPrimaryEdit = editing && e.primary;
const form = `<form id="f-domain" class="domain-form ${editing ? 'editing' : ''}"><h3>${editing ? `도메인 수정 — ${esc(e.domain)}` : '도메인 추가'}</h3><div class="grid">
${editing ? '' : `<label class="field"><span>도메인</span><input name="domain" required placeholder="example.com" autocomplete="off" pattern="[a-z0-9.\\-]+"><span class="help">메일 주소의 @ 뒤. 이 도메인으로 받은 메일을 다루고 계정의 메일함은 로그인 ID@도메인이 됩니다.</span></label>`}
<label class="field"><span>웹 메일 호스트 이름</span><input name="hosts" value="${esc(editing ? e.hosts.join(', ') : '')}" placeholder="mail.example.com (쉼표로 여러 개)" autocomplete="off" ${isPrimaryEdit ? '' : ''}><span class="help">이 이름으로 들어온 요청이 이 도메인의 웹 메일입니다. 비우면 mail.&lt;도메인&gt;. nginx 에 server_name 과 인증서가 있어야 합니다(deploy/domain-nginx.sh). 메일용 호스트를 따로 만들기 어려우면 "site.example.com/mail" 처럼 경로를 붙여 그 사이트의 /mail 아래로 둘 수 있습니다(deploy/site-mail-path.sh).</span></label>
${isPrimaryEdit ? '' : `<label class="field"><span>기본 메일함(로컬 부분)</span><input name="mailbox" value="${esc(editing ? e.mailbox.split('@')[0] : 'admin')}" placeholder="admin" autocomplete="off" ${editing ? 'readonly' : ''}><span class="help">첫 관리자의 로그인 ID 이자 기본 메일함(admin@도메인). 만든 뒤에는 바꿀 수 없습니다.</span></label>
<label class="field"><span>로그인 방식</span><select name="authMode"><option value="local" ${!editing || e.auth.mode === 'local' ? 'selected' : ''}>자체 계정(admin 관리자 자동 생성)</option><option value="www" ${editing && e.auth.mode === 'www' ? 'selected' : ''}>연결된 웹 서비스의 계정과 연동</option><option value="webmaker" ${editing && e.auth.mode === 'webmaker' ? 'selected' : ''}>웹메이커(webmaker) 계정과 연동</option></select><span class="help">자체 계정: 이 도메인만의 관리자(admin)와 계정을 관리자 페이지에서 만듭니다. 웹 서비스 연동: www 사이트 DB 의 관리자 계정으로 로그인합니다. 웹메이커 연동: 웹메이커의 사용자 파일을 읽기 전용으로 참조해 그 계정(관리자 admin, 사이트 소유자, 사이트의 사용자)으로 웹 메일·IMAP·POP·SMTP 에 같은 비밀번호로 로그인합니다.</span></label>
<label class="field www-only"><span>웹 서비스 DB 파일</span><input name="wwwDbFile" value="${esc(editing ? e.auth.wwwDbFile : '')}" placeholder="/srv/example/data/example.sqlite" autocomplete="off"><span class="help">users 표(login_id·email·role·status·password_hash)를 가진 SQLite 파일. 서비스 계정이 읽을 수 있어야 합니다(systemd ReadWritePaths 참고).</span></label>
<label class="field www-only"><span>기본 메일함 소유 관리자(선택)</span><input name="ownerLogin" value="${esc(editing ? e.auth.ownerLogin : '')}" placeholder="로그인 ID 또는 이메일" autocomplete="off"></label>
<label class="field webmaker-only"><span>웹메이커 데이터 디렉터리</span><input name="webmakerDir" value="${esc(editing ? e.auth.webmakerDir : '')}" placeholder="/data/webmaker" autocomplete="off"><span class="help">웹메이커 서비스의 DATA_ROOT(users.json·sites/·modules/·domains.json 이 있는 곳). 서비스 계정이 못 읽는 디렉터리(보통 0700)면 아래 계정을 적고 서버에서 <code>sudo bash deploy/webmaker-access.sh &lt;디렉터리&gt;</code> 로 읽기 도우미를 설치합니다.</span></label>
<label class="field webmaker-only"><span>파일을 읽을 계정(선택)</span><input name="webmakerUser" value="${esc(editing ? e.auth.webmakerUser : '')}" placeholder="master" autocomplete="off" pattern="[a-z_][a-z0-9_\\-]{0,31}"><span class="help">웹메이커 데이터의 소유 계정. 적으면 sudo -u 이 계정으로 읽기 도우미(/usr/local/lib/arimail/webmaker-snapshot)를 부릅니다. 비우면 서비스 계정이 직접 읽습니다.</span></label>
<label class="field webmaker-only"><span>웹메이커 사이트 ID(선택)</span><input name="siteId" value="${esc(editing ? e.auth.siteId : '')}" placeholder="32자리 16진수" autocomplete="off" pattern="[a-f0-9]{32}"><span class="help">비우면 도메인 이름으로 사이트를 찾습니다(서브 도메인 등록 → 사이트의 도메인 목록 → 사이트의 메일 도메인). 사이트가 없으면 웹메이커의 모든 회원이 아이디@도메인으로 로그인합니다.</span></label>
${editing ? '' : `<label class="field local-only"><span>초기 관리자 비밀번호</span><input name="adminPassword" value="adminpasswd" autocomplete="off" minlength="6"><span class="help">admin 계정의 첫 비밀번호(6자 이상). 로그인 뒤 바꾸라는 경고가 보입니다.</span></label>
<label class="field"><span>기본 도메인 설정 복사</span><select name="copyFromPrimary"><option value="1">API 키·DNS 인증 정보·중계 서버 설정을 복사</option><option value="0">빈 설정으로 시작</option></select><span class="help">같은 Resend 계정·DNS 계정을 쓰면 복사가 편합니다. 수·발신 방식, Resend 도메인, DKIM 키는 복사하지 않습니다.</span></label>`}`}
<label class="field"><span>메모(선택)</span><input name="note" value="${esc(editing ? e.note : '')}" maxlength="200" autocomplete="off"></label>
</div><div class="actions"><button class="primary" type="submit">${editing ? '저장' : '도메인 추가'}</button>${editing ? '<button type="button" id="domain-cancel">취소</button>' : ''}<span class="status"></span></div></form>`;
return `<section class="card" id="card-domains"><h2>도메인 <span class="tag">${DOMAINS.domains.length}개</span></h2><p class="intro">이 서버가 메일을 다루는 도메인입니다. 도메인마다 계정·메일·설정이 따로 있고, 웹 메일은 각 호스트 이름으로, 메일 프로그램은 <code>${esc(DOMAINS.serverHost || '')}</code> 에 <code>아이디@도메인</code> 으로 접속합니다. 아래 카드는 <strong>선택한 도메인</strong>의 설정입니다.</p>
<table class="simple domains"><thead><tr><th>도메인</th><th>웹 메일 호스트</th><th>상태</th><th>로그인</th><th>수신 / 발신</th><th>계정 · 메일</th><th></th></tr></thead><tbody>${rows}</tbody></table>${form}</section>`;
}
/**
* 사용량 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 `<section class="card step" id="card-usage"><h2>사용량 API <span class="tag ${u.enabled ? 'ok' : ''}">${u.enabled ? '사용 중' : '꺼짐'}</span></h2>
<p class="intro">웹메이커 등 외부 관리 시스템이 도메인·계정별 <strong>디스크 사용량·메일 수·발송량(일/주/월·일별 추이)</strong>을 읽는 읽기 전용 API 입니다. 비밀 키(Bearer)와 접속 IP 제한으로 보호되며 메일 내용·비밀번호·API 키는 나가지 않습니다. 사용 방법은 저장소의 <code>docs/USAGE-API.md</code>.</p>
<div class="summary"><div class="item"><div class="k">주소</div><div class="v"><code>${esc(u.endpoint)}</code> <span class="muted">(도메인 하나: <code>/api/usage/&lt;도메인&gt;</code>)</span></div></div>
<div class="item"><div class="k">키</div><div class="v">${u.enabled ? `<code>${esc(u.keyMasked)}</code>${u.updatedAt ? ` <span class="muted">(${esc(new Date(u.updatedAt).toLocaleString('ko-KR'))} 생성)</span>` : ''}` : '<span class="muted">없음 — 아래 단추로 만들면 API 가 켜집니다</span>'}</div></div></div>
${shown ? `<div class="notice"><strong>새 키(지금만 보입니다 — 웹메이커 설정에 복사해 두세요):</strong><pre class="log" style="user-select:all">${esc(shown)}</pre><p class="meta">예: <code>${esc(curl.replace('<키>', shown))}</code></p></div>` : ''}
<div class="actions"><button type="button" class="primary" id="usage-generate">${u.enabled ? '키 다시 만들기(기존 키 무효)' : '키 만들기'}</button>${u.enabled ? '<button type="button" class="ghost" id="usage-revoke">키 삭제(API 끄기)</button>' : ''}</div>
<form id="f-usage"><div class="grid">${field('USAGE_API_ALLOW', { value: v.USAGE_API_ALLOW || '', source: s.USAGE_API_ALLOW, placeholder: '203.0.113.10, 10.0.0.0/8' })}</div><div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>`;
}
/** DNS 모듈 카드(요구사항 1): 서비스 선택과 인증 정보, 연결 확인. */
function dnsCard() {
const v = D.values, s = D.sources, prov = v.DNS_PROVIDER || 'none';
return `<section class="card step"><h2>DNS 모듈</h2><p class="intro">도메인의 DNS 를 Google Cloud DNS 나 AWS Route 53 으로 관리하면 인증 정보를 넣어 두세요. 자동 설정이 필요한 레코드를 직접 넣고 반영을 확인합니다. 다른 DNS 서비스는 "none" 으로 두고 위 표의 레코드를 직접 넣으세요.</p>
<form id="f-dns"><div class="grid">${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 }) : ''}</div>
<div class="actions"><button class="primary" type="submit">저장</button>${prov !== 'none' ? `<button type="button" id="dns-probe">연결 확인(존 찾기)</button>` : ''}<span class="status"></span></div></form></section>`;
}
function field(key, { type = 'text', options = null, value = '', source = 'default', secret = false, placeholder = '', multiline = false } = {}) {
const [label, help] = FIELD[key] || [key, ''];
const src = `<span class="src">${esc(SRC[source] || source)}</span>`;
if (secret && multiline) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><textarea name="${key}" rows="4" autocomplete="off" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 붙여 넣기)' : '(없음) 붙여 넣으세요'}" data-secret="1"></textarea><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
if (secret) return `<label class="field"><span>${esc(label)}</span><span class="secret-row"><input type="password" name="${key}" autocomplete="new-password" placeholder="${value ? '저장됨: ' + esc(value) + ' (바꾸려면 입력)' : '(없음) 입력하세요'}" data-secret="1"><button type="button" class="small ghost" data-clear="${key}" title="저장된 값을 지웁니다">비우기</button></span>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
if (options) return `<label class="field"><span>${esc(label)}</span><select name="${key}">${options.map((o) => `<option value="${esc(o)}" ${String(o) === String(value) ? 'selected' : ''}>${esc(o)}</option>`).join('')}</select>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
return `<label class="field"><span>${esc(label)}</span><input type="${type}" name="${key}" value="${esc(value)}" placeholder="${esc(placeholder)}" autocomplete="off" ${type === 'number' ? 'min="0" step="1"' : ''}>${help ? `<span class="help">${esc(help)}</span>` : ''}${src}</label>`;
}
const tag = (ok, onText, offText, cls = ok ? 'ok' : 'warn') => `<span class="tag ${cls}">${esc(ok ? onText : offText)}</span>`;
const statusTag = (st) => !st ? tag(false, '', '확인 중') : st.provider === 'ok' ? tag(true, '정상', '') : `<span class="tag warn" title="${esc(st.detail || '')}">${esc(ERRORS[st.provider] || st.provider)}</span>`;
/** 전체 화면 그리기. 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 = `<a href="https://${esc(m.host)}/" target="_blank" rel="noopener">웹 메일 ↗</a><a href="https://${esc(m.host)}/admin" target="_blank" rel="noopener">관리자 페이지 ↗</a>`;
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) => `<div class="choices">${Object.entries(MODES[name]).map(([k, [t, d]]) => `<label class="choice ${k === current ? 'selected' : ''}"><input type="radio" name="${name}" value="${k}" ${k === current ? 'checked' : ''}><span><span class="t">${esc(t)}</span><div class="d">${esc(d)}</div></span></label>`).join('')}</div>`;
const missing = checklist(D);
$('#main').innerHTML = `
${domainsCard()}
<section class="card"><h2>현재 상태${MODE === 'token' ? ` — <code>${esc(m.domain)}</code>${m.primary ? ' <span class="tag">기본 도메인</span>' : ''}` : ''}</h2><div class="summary">
<div class="item"><div class="k">수신</div><div class="v">${esc(MODES.inbound[m.inbound][0])}</div></div>
<div class="item"><div class="k">발신</div><div class="v">${esc(MODES.outbound[m.outbound][0])}</div></div>
<div class="item"><div class="k">발신 서비스 연결</div><div class="v">${statusTag(m.outboundStatus)} <button type="button" class="small" id="check-outbound">연결 확인</button></div>${m.outboundStatus && m.outboundStatus.detail ? `<div class="meta muted">${esc(m.outboundStatus.detail)}</div>` : ''}${m.outboundStatus && m.outboundStatus.domain ? `<div class="meta muted">Resend 도메인 ${esc(m.outboundStatus.domain.name)}: ${esc(m.outboundStatus.domain.status)}</div>` : ''}</div>
<div class="item"><div class="k">내장 Haraka</div><div class="v">${harakaTag}</div></div>
<div class="item"><div class="k">설정 파일</div><div class="v"><code>${esc(D.file)}</code></div><div class="meta muted">${D.updatedAt ? '마지막 저장 ' + esc(new Date(D.updatedAt).toLocaleString('ko-KR')) : ''}${D.importedFromEnv && D.importedFromEnv.length ? ` · 환경 파일에서 가져온 값 ${D.importedFromEnv.length}개` : ''}</div></div>
<div class="item"><div class="k">초기 설정</div><div class="v">${tag(v.SETUP_DONE === true, '완료 표시됨', '진행 중', v.SETUP_DONE === true ? 'ok' : '')}</div></div>
</div></section>
<div class="steps">
<section class="card step"><h2>메일을 받고 보내는 방식</h2><p class="intro">받는 길과 보내는 길을 따로 고를 수 있습니다. 예: 수신은 내장 Haraka, 발신은 Resend. 저장하면 바로 적용되며, Haraka 를 고르면 별도 서비스(haraka)가 설정을 보고 스스로 뜹니다.</p>
<form id="f-mail"><div class="grid"><div><h3>수신</h3>${choices('inbound', m.inbound)}</div><div><h3>발신</h3>${choices('outbound', m.outbound)}</div></div><div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>
${planCard()}
${dnsCard()}
${usageCard()}
${m.usesResend ? `<section class="card step"><h2>Resend</h2><p class="intro">Resend 를 ${m.inbound === 'resend' && m.outbound === 'resend' ? '수신과 발신에' : m.inbound === 'resend' ? '수신에' : '발신에'}씁니다. 도메인 <code>${esc(m.domain)}</code> 이 Resend 에 등록·검증되어 있어야 합니다.</p>
<form id="f-resend"><div class="grid">${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 }) : ''}</div>
${m.inbound === 'resend' ? `<div class="note">웹훅 엔드포인트: <span class="copy"><code>${esc(m.webhookUrl)}</code><button type="button" class="small" data-copy="${esc(m.webhookUrl)}">복사</button></span> · 이벤트 <code>email.received</code></div>` : ''}
<div class="actions"><button class="primary" type="submit">저장</button><button type="button" id="check-resend">연결 확인</button><span class="status"></span></div></form></section>` : ''}
${m.outbound === 'smtp' ? `<section class="card step"><h2>SMTP 중계 서버</h2><p class="intro">보내는 메일을 넘길 외부 SMTP 서버입니다. 보낸 사람 주소(<code>${esc(m.domain)}</code>)를 그 서버가 허용해야 합니다.</p>
<form id="f-smtp"><div class="grid">${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 })}</div>
<div class="actions"><button class="primary" type="submit">저장</button><button type="button" id="check-smtp">연결 확인</button><span class="status"></span></div></form></section>` : ''}
${m.usesHaraka ? `<section class="card step"><h2>내장 Haraka</h2><p class="intro">Haraka 는 이 서버에서 함께 도는 메일 서버(MTA)입니다. 게이트웨이와는 별도 systemd 서비스로 돌며, 여기 설정을 보고 자기 설정 파일을 만들어 스스로 켜고 끕니다.</p>
${!h.installed ? `<div class="note warn"><strong>Haraka 가 설치되어 있지 않습니다.</strong> 서버에서 한 번 실행하세요(root): <code>sudo bash ${esc((h.dir || '').replace(/\/haraka$/, ''))}/repo/deploy/haraka-apply.sh</code> — 서비스 등록과 npm 설치(Haraka ${esc(hs.version || '3.x')})를 합니다. 설치 뒤 이 화면을 새로 고치세요.</div>` : !h.status || h.stale ? `<div class="note warn"><strong>Haraka 관리 서비스가 돌고 있지 않습니다.</strong> <code>sudo systemctl start &lt;서비스 이름&gt;-haraka</code> 또는 <code>deploy/haraka-apply.sh</code> 를 다시 실행하세요.</div>` : hs.running ? `<div class="note ok">실행 중 (pid ${esc(hs.pid)}, ${esc(hs.startedAt ? new Date(hs.startedAt).toLocaleString('ko-KR') : '')} 부터) · 대기 포트: ${esc((hs.listen || []).join(', '))}${hs.restarts ? ` · 재시작 ${esc(hs.restarts)}회` : ''}</div>` : `<div class="note">${hs.enabled ? '시작을 준비하고 있습니다…' : '설정이 저장되면 몇 초 안에 시작합니다.'}${hs.lastExit ? ` 마지막 종료: 코드 ${esc(hs.lastExit.code)} (${esc(hs.lastExit.at)})` : ''}</div>`}
${m.primary ? '' : `<div class="note">Haraka 는 서버에 하나이므로 수신 포트·제출 포트·메일 서버 이름은 <strong>기본 도메인</strong>의 설정을 따릅니다. 이 도메인에서는 DKIM 셀렉터만 정합니다.</div>`}
<form id="f-haraka"><div class="grid">${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 })}</div>
<div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form>
<h3>DNS 와 발신 준비</h3><p class="muted" style="font-size:13px;margin:0 0 8px">${m.inbound === 'haraka' ? 'MX 를 이 서버로 바꾸면 그때부터 메일이 이 서버로 옵니다(이전 MX 의 메일은 더 오지 않습니다). ' : ''}${m.outbound === 'haraka' ? 'SPF·DKIM 이 없으면 보낸 메일이 스팸으로 분류되기 쉽습니다. 서버 IP 의 역방향 DNS(PTR)가 메일 서버 이름을 가리키게 호스팅 업체에 요청하세요. ' : ''}필요한 레코드와 반영 상태는 위 "자동 설정" 카드에 있습니다.</p>
${m.outbound === 'haraka' ? `<div class="actions" style="margin:0 0 8px">${p.HARAKA_DKIM_PRIVATE ? `<span class="tag ok">DKIM 키 있음</span><button type="button" id="dkim-regen" class="small">키 다시 만들기</button>` : `<button type="button" id="dkim-gen" class="primary">DKIM 키 만들기</button><span class="status">자동 설정 실행이 대신 만들어 줍니다</span>`}</div>` : ''}
<h3>서비스 로그(최근)</h3>${hs.lastLog && hs.lastLog.length ? `<pre class="log">${esc(hs.lastLog.join('\n'))}</pre>` : `<p class="muted" style="font-size:13px">아직 기록이 없습니다. 서버에서 <code>journalctl -u &lt;서비스 이름&gt;-haraka -f</code> 로 볼 수 있습니다.</p>`}
<div class="actions"><button type="button" id="check-haraka">상태 새로 고침</button></div></section>` : ''}
<section class="card step"><h2>사이트 정보</h2><p class="intro">배포에는 필요 없지만 운영 중 바꿀 수 있는 값입니다.</p>
<form id="f-site"><div class="grid">${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 }) : ''}</div>
<div class="actions"><button class="primary" type="submit">저장</button><span class="status"></span></div></form></section>
<section class="card step"><h2>마무리</h2>
<ul class="checklist">${missing.map((c) => `<li><span class="mark">${c.ok ? '✅' : '⚠️'}</span><span>${esc(c.text)}</span></li>`).join('')}</ul>
<div class="actions">${v.SETUP_DONE === true ? `<button type="button" id="setup-undone">완료 표시 해제</button>` : `<button type="button" id="setup-done" class="primary" ${missing.some((c) => !c.ok && c.required) ? 'title="필수 항목이 비어 있어도 표시할 수 있습니다"' : ''}>설치 완료로 표시</button>`}${m.systemd ? `<button type="button" id="restart">서비스 재시작</button>` : ''}<span class="status muted">저장한 설정은 재시작 없이 적용됩니다. 재시작은 포트(SETUP_PORT 등 환경 파일) 변경 뒤에만 필요합니다.</span></div>
<p class="muted" style="font-size:13px;margin-top:12px">${MODE === 'token' ? '이 화면은 전용 포트에서 토큰으로 열었습니다. 운영 중에는 관리자 페이지(/admin) → "메일 서비스 설정" 으로도 같은 화면을 열 수 있습니다.' : '이 화면은 관리자 세션으로 열었습니다. 설치 직후에는 배포 출력에 나온 전용 포트 주소(…:' + esc(m.setupPort) + '/?token=…)로도 열 수 있습니다.'}</p></section>
</div>`;
bind();
}
/** 점검표: 고른 방식에 꼭 필요한 값이 있는지. */
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.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 = `<section class="card hint-token"><h2>접속 토큰</h2><p class="muted">${esc(message)}</p><form id="f-token"><label class="field"><span>토큰</span><input name="token" autocomplete="off" placeholder="배포 출력의 ?token= 뒤 값"></label><div class="actions"><button class="primary" type="submit">열기</button></div></form><p class="muted" style="font-size:13px">토큰은 서버의 데이터 디렉터리 <code>setup-token</code> 파일에 있습니다(배포 계정만 읽을 수 있음). 배포 스크립트가 끝에 전체 주소를 출력합니다.</p></section>`;
$('#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 = `<section class="card hint-token"><h2>관리자 로그인 필요</h2><p>이 화면은 관리자 페이지에서 로그인한 뒤 열 수 있습니다.</p><div class="actions"><a href="${BASE}admin"><button type="button" class="primary">관리자 페이지로</button></a></div></section>`; return; }
// CSP(script-src 'self')가 인라인 onclick 을 막으므로 단추는 코드로 묶습니다.
$('#main').innerHTML = `<section class="card"><h2>설정을 읽지 못했습니다</h2><p class="muted">${esc(errText(e))}</p><div class="actions"><button type="button" id="retry">다시 시도</button></div></section>`;
$('#retry').onclick = () => location.reload();
}
}
load();
})();
+105
View File
@@ -0,0 +1,105 @@
:root{--sidebar-w:230px;--list-w:400px;--bg:#f3f5f8;--panel:#fff;--line:#e3e7ee;--text:#1d2733;--muted:#6b7a8c;--brand:#2b64c8;--brand-dark:#1f4c9b;--accent:#ff9f1c;--danger:#d64545;--hover:#eef3fb;--selected:#dfe9fb;--unread:#111;--shadow:0 8px 30px rgba(20,35,60,.12)}
*{box-sizing:border-box}html,body{height:100%}body{margin:0;background:var(--bg);color:var(--text);font:14px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI","Apple SD Gothic Neo","Noto Sans KR",system-ui,sans-serif}
button,input,select,textarea{font:inherit;color:inherit}button{cursor:pointer;border:1px solid var(--line);border-radius:8px;background:#fff;padding:7px 12px;min-height:36px}button:disabled{opacity:.5;cursor:wait}
button.primary{background:var(--brand);color:#fff;border-color:var(--brand)}button.primary:hover{background:var(--brand-dark)}button.danger{background:var(--danger);color:#fff;border-color:var(--danger)}button.ghost{background:transparent;border-color:transparent}button.ghost:hover{background:var(--hover)}
.icon-btn{border:0;background:transparent;font-size:18px;padding:6px 10px;border-radius:8px}.icon-btn:hover{background:var(--hover)}
input,select,textarea{width:100%;padding:8px 10px;border:1px solid #cbd3de;border-radius:8px;background:#fff;min-height:36px}input:focus,select:focus,textarea:focus,[contenteditable]:focus{outline:2px solid #9dbcf3;outline-offset:1px}
label{display:block;margin-bottom:12px}label>span{display:block;font-size:12px;color:var(--muted);margin-bottom:4px}.muted{color:var(--muted)}.meta{font-size:12px;color:var(--muted)}
[hidden]{display:none!important}
/* 로그인 */
#login{min-height:100%;display:flex;align-items:center;justify-content:center;padding:24px;background:linear-gradient(135deg,#1f4c9b,#2b64c8 60%,#4c8ae6)}
.login-card{background:#fff;border-radius:16px;padding:32px 28px;width:min(400px,100%);box-shadow:var(--shadow)}.login-brand{display:flex;align-items:center;gap:10px;margin-bottom:6px}.login-brand h1{font-size:20px;margin:0}
/* 버전 표시(0.8.1~): "아리학당 메일" 바로 아래 작은 회색 글씨 */
.brand-text{display:flex;flex-direction:column;line-height:1.15}.app-version{font-size:11px;font-weight:400;color:var(--muted);letter-spacing:.02em}.logo{display:inline-flex;width:34px;height:34px;border-radius:9px;background:var(--brand);color:#fff;align-items:center;justify-content:center;font-size:18px}
#login button{width:100%;min-height:42px;margin-top:6px}#login-feedback:not(:empty){margin:12px 0 0;padding:10px;border-radius:8px;background:#fdecec;color:#8d2323}
/* 상단 */
#workspace{height:100vh;display:flex;flex-direction:column}
.topbar{display:flex;align-items:center;gap:14px;padding:0 16px;height:54px;background:#fff;border-bottom:1px solid var(--line);flex-shrink:0}.brand{display:flex;align-items:center;gap:8px;font-weight:700;white-space:nowrap}.search{flex:1;max-width:620px;display:flex;align-items:center;background:var(--bg);border-radius:10px;padding:0 4px 0 12px}.search input{border:0;background:transparent;min-height:38px}.search input:focus{outline:none}.topbar-right{margin-left:auto;display:flex;align-items:center;gap:10px}.who{font-size:13px;color:var(--muted);white-space:nowrap}#mailbox-select{width:auto;min-height:32px;padding:4px 8px;font-size:13px}
#menu-toggle{display:none}
/* 3단 */
.shell{flex:1;display:flex;min-height:0}
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:#fff;border-right:1px solid var(--line);padding:14px 12px;display:flex;flex-direction:column;gap:6px;overflow:auto}
.compose-btn{background:var(--brand);color:#fff;border:0;border-radius:10px;min-height:42px;font-weight:600;margin-bottom:10px;box-shadow:0 4px 12px rgba(43,100,200,.35)}.compose-btn:hover{background:var(--brand-dark)}
.folders{display:flex;flex-direction:column;gap:2px}.folders button{display:flex;align-items:center;gap:10px;border:0;background:transparent;text-align:left;border-radius:8px;padding:8px 10px;min-height:38px}.folders button:hover{background:var(--hover)}.folders button[aria-current=true]{background:var(--selected);font-weight:600;color:var(--brand-dark)}.folders .badge{margin-left:auto;background:var(--brand);color:#fff;border-radius:999px;font-size:11px;padding:1px 8px;font-weight:600}.folders button[aria-current=true] .badge{background:var(--brand-dark)}.folders.small button{min-height:34px;font-size:13px}.folders .ic{width:20px;text-align:center;opacity:.8}
.side-title{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin:14px 6px 4px}.usage{margin-top:auto;padding:12px 8px 4px;font-size:12px;color:var(--muted);border-top:1px solid var(--line)}.usage .bar{height:6px;background:var(--bg);border-radius:4px;overflow:hidden;margin:6px 0}.usage .bar i{display:block;height:100%;background:var(--brand)}
.main{flex:1;display:flex;min-width:0;position:relative}
#feedback{position:absolute;top:10px;left:50%;transform:translateX(-50%);z-index:20;background:#1d2733;color:#fff;padding:10px 16px;border-radius:10px;box-shadow:var(--shadow);max-width:90%;font-size:13px}#feedback:empty{display:none}
.list-pane{width:var(--list-w);flex-shrink:0;border-right:1px solid var(--line);background:#fff;display:flex;flex-direction:column;min-height:0}
.read-pane{flex:1;min-width:0;background:#fff;display:flex;flex-direction:column;min-height:0}
.page-pane{flex:1;overflow:auto;padding:24px;background:var(--bg)}
.list-toolbar{display:flex;align-items:center;gap:6px;padding:8px 10px;border-bottom:1px solid var(--line);flex-wrap:wrap}.list-toolbar .spacer{flex:1}.list-toolbar button{min-height:32px;padding:4px 9px;font-size:13px}.list-toolbar select{width:auto;min-height:32px;padding:3px 6px;font-size:13px}
.list-head{display:flex;align-items:center;gap:8px;padding:6px 12px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--line);min-height:34px}.list-head .spacer{flex:1}.list-head .folder-name{font-weight:600;color:var(--text);font-size:13px}.list-head .select-all{margin:0;display:inline-flex;align-items:center}.list-head .select-all input{width:15px;height:15px;min-height:0;margin:0;cursor:pointer}.list-head .tag{margin-left:0;white-space:nowrap}
.mail-list{flex:1;overflow:auto}
.mail-row{display:grid;grid-template-columns:auto auto minmax(0,1fr) auto;gap:6px 10px;padding:10px 12px;border-bottom:1px solid #eef1f5;cursor:pointer;align-items:start;position:relative}.mail-row:hover{background:var(--hover)}.mail-row[aria-selected=true]{background:var(--selected)}.mail-row.unread .subject,.mail-row.unread .who{font-weight:700;color:var(--unread)}
.mail-row input[type=checkbox]{width:16px;height:16px;margin-top:3px}.mail-row .star{background:transparent;border:0;padding:0;min-height:0;font-size:16px;color:#c2c9d3;line-height:1}.mail-row .star.on{color:var(--accent)}
.mail-row .who{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.mail-row .subject{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;grid-column:3;font-size:14px}.mail-row .snippet{grid-column:3;color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mail-row time{font-size:12px;color:var(--muted);white-space:nowrap}.mail-row .icons{grid-column:4;font-size:12px;color:var(--muted);text-align:right}
.mail-row .avatar{grid-row:1/4;grid-column:2;width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:700;color:#fff;font-size:14px}
.pager{display:flex;align-items:center;justify-content:center;gap:10px;padding:8px;border-top:1px solid var(--line);font-size:12px;color:var(--muted)}.pager button{min-height:30px;padding:3px 10px}
.empty{padding:40px 20px;text-align:center;color:var(--muted)}.empty .big{font-size:40px;opacity:.4}
/* 읽기 */
.read-head{padding:16px 22px 10px;border-bottom:1px solid var(--line)}.read-head h2{margin:0 0 10px;font-size:20px;font-weight:600;overflow-wrap:anywhere}.read-actions{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}.read-actions button{min-height:32px;padding:4px 10px;font-size:13px}
.sender-line{display:flex;gap:12px;align-items:center}.sender-line .avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;flex-shrink:0}.sender-line .lines{min-width:0}.sender-line .name{font-weight:600}.sender-line .addr{color:var(--muted);font-size:12px;overflow-wrap:anywhere}.sender-line time{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap}
.detail-box{margin-top:10px;font-size:12px;color:var(--muted);display:grid;grid-template-columns:70px 1fr;gap:2px 8px}
.read-body{flex:1;overflow:auto;padding:10px 22px 22px;position:relative}.corner-btn{position:absolute;top:8px;right:14px;z-index:2;font-size:11px;min-height:24px;padding:2px 8px;border-radius:999px;background:#fff;opacity:.9;box-shadow:0 1px 4px rgba(0,0,0,.12)}.corner-btn:hover{opacity:1}.switch{display:flex;align-items:center;gap:10px;margin:8px 0}.switch input{width:18px;height:18px;min-height:0}.mail-frame{width:100%;border:0;min-height:300px;background:#fff}
.attach-list{display:flex;flex-wrap:wrap;gap:8px;padding:10px 22px;border-top:1px solid var(--line)}.chip{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line);border-radius:8px;padding:6px 10px;font-size:13px;background:#fafbfd;text-decoration:none;color:inherit;max-width:100%}.chip:hover{background:var(--hover)}.chip .sz{color:var(--muted);font-size:11px}
.tag{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;background:#e8edf3;color:#3a4a60;margin-left:4px}.list-head .search-chip{background:#e3ecfb;color:var(--brand-dark);max-width:40%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.list-head .search-chip button{border:0;background:transparent;padding:0 0 0 6px;min-height:0;font-size:11px;color:inherit;cursor:pointer;vertical-align:baseline}.tag.ok{background:#e3f3e6;color:#1d6b33}.tag.warn{background:#fde8e6;color:#9b2c1f}.tag.accent{background:#fff1dc;color:#9a5b00}
.notice{padding:8px 12px;background:#fff8e6;border:1px solid #ffe2a8;border-radius:8px;font-size:12px;margin:8px 22px 0;display:flex;gap:10px;align-items:center}
/* 작성 */
#compose{width:min(980px,96vw);max-height:94vh;border:0;border-radius:14px;padding:0;box-shadow:var(--shadow)}#compose::backdrop{background:#14233566}
.compose-head{display:flex;align-items:center;gap:10px;padding:12px 18px;background:#1f4c9b;color:#fff;border-radius:14px 14px 0 0}.compose-head h2{margin:0;font-size:16px;font-weight:600;flex:1}.compose-head button{color:#fff;border:0;background:transparent;font-size:18px;padding:2px 8px;min-height:0}
.compose-form{padding:14px 18px 16px;display:flex;flex-direction:column;gap:8px;max-height:calc(94vh - 52px);overflow:auto;position:relative}.drop-overlay{position:absolute;inset:8px;background:rgba(43,100,200,.10);border:2px dashed var(--brand);border-radius:12px;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:16px;color:var(--brand-dark);z-index:6;pointer-events:none}.choice{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:10px 12px;background:#fff8e6;border:1px solid #ffe2a8;border-radius:10px;font-size:13px}.choice strong{flex:1}.choice button{min-height:30px;padding:3px 10px;font-size:13px}
.addr-row{display:grid;grid-template-columns:90px 1fr auto;align-items:center;gap:8px;border-bottom:1px solid var(--line);padding:4px 0}.addr-row span{font-size:13px;color:var(--muted)}.addr-row input{border:0;min-height:32px;padding:4px 6px}.addr-row input:focus{outline:none}.addr-row .link{color:var(--brand);font-size:12px;cursor:pointer;background:transparent;border:0;min-height:0;padding:2px 6px}
.editor-tools{display:flex;gap:2px;flex-wrap:wrap;padding:6px;background:var(--bg);border-radius:8px;align-items:center}.editor-tools button{border:0;background:transparent;min-height:30px;padding:3px 7px;border-radius:6px;font-size:14px}.editor-tools button:hover{background:#fff}.editor-tools button[aria-pressed=true]{background:#fff;box-shadow:inset 0 0 0 1px var(--line)}.editor-tools select{width:auto;min-height:30px;padding:2px 4px;font-size:12px;border-color:transparent;background:transparent}.editor-tools .sep{width:1px;height:20px;background:#d5dbe4;margin:0 4px}.editor-tools input[type=color]{width:26px;height:26px;padding:0;border:0;background:transparent;min-height:0}.editor-tools label{margin:0;display:inline-flex;align-items:center;gap:3px;font-size:12px;color:var(--muted)}
/* 편집기는 .compose-form(세로 flex, max-height 로 높이 제한) 의 flex 항목입니다. flex 항목은 기본값 flex-shrink:1 이고, min-height 를 명시하면
"내용 크기만큼은 줄어들지 않는다"는 자동 최소 크기(min-height:auto)가 사라져 창이 꽉 차면 320px 까지 눌립니다. 그 상태에서 overflow 는 visible
이라 여러 줄을 붙여넣으면 글이 편집기 테두리를 넘어 아래의 첨부·보내기 단추 위에 겹쳐 보였습니다(0.9.9 수정). flex-shrink:0 으로 편집기는 내용
높이대로 자라고 스크롤은 .compose-form 이 맡습니다(이미지 크기 도구 place() 도 form.scrollTop 기준). overflow:auto 는 가로로 긴 표·white-space:pre
본문이 테두리 밖으로 번지지 않게 편집기 안에서 가로 스크롤하게 합니다(세로는 내용 높이대로 자라므로 스크롤이 생기지 않음). */
.html-editor{border:1px solid var(--line);border-radius:8px;background:#fff;min-height:320px;flex-shrink:0;overflow:auto;padding:16px;overflow-wrap:anywhere;line-height:1.6}.html-editor img{max-width:100%;height:auto;cursor:pointer}.html-editor img.selected{outline:2px solid var(--brand);outline-offset:1px}.img-sizer{position:absolute;z-index:7;background:#fff;border:1px solid var(--line);border-radius:8px;box-shadow:var(--shadow);padding:4px 6px;display:flex;gap:4px;align-items:center;font-size:12px;flex-wrap:wrap}.img-sizer button{min-height:26px;padding:2px 7px;font-size:12px}.img-sizer input{width:72px;min-height:26px;padding:2px 6px;font-size:12px}.img-sizer .lbl{color:var(--muted);margin-right:2px}.img-handle{position:absolute;width:14px;height:14px;background:var(--brand);border:2px solid #fff;border-radius:3px;cursor:nwse-resize;z-index:7;box-shadow:0 0 0 1px var(--brand)}.html-editor blockquote{border-left:3px solid #c8d2e0;margin:8px 0;padding-left:12px;color:#4a5566}.html-source{min-height:320px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:13px}
.compose-attachments{display:flex;flex-wrap:wrap;gap:6px}.compose-attachments .chip button{border:0;background:transparent;padding:0 2px;min-height:0;color:var(--muted)}
.compose-foot{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding-top:6px;border-top:1px solid var(--line)}.compose-foot .spacer{flex:1}.compose-foot .status{font-size:12px;color:var(--muted)}
/* 페이지(설정·계정) */
.card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:20px;margin-bottom:16px;max-width:1100px}h1.page-title{font-size:22px;margin:0 0 6px}h2{font-size:17px;margin:0 0 10px}h3{font-size:15px;margin:16px 0 8px}
dl.kv{display:grid;grid-template-columns:minmax(160px,260px) 1fr;gap:6px 16px;margin:0}dl.kv dt{font-weight:600;color:#3a4a60}dl.kv dd{margin:0;overflow-wrap:anywhere}code{background:#eef1f4;padding:2px 6px;border-radius:4px;font-size:13px}ol.steps li{margin-bottom:6px}
table.simple{border-collapse:collapse;width:100%}table.simple th,table.simple td{text-align:left;padding:8px 10px;border-bottom:1px solid #e8eaef;font-size:13px}table.simple th{color:var(--muted);font-weight:600}table.simple tr[data-row]{cursor:pointer}table.simple tr[data-row]:hover{background:var(--hover)}
.actions{display:flex;gap:8px;margin-top:16px;flex-wrap:wrap}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
#detail{width:min(640px,94vw);border:0;border-radius:14px;padding:22px;box-shadow:var(--shadow)}#detail::backdrop{background:#14233566}#detail .close{float:right}#dialog-feedback:not(:empty){background:#fdecec;color:#8d2323;padding:10px;border-radius:8px;margin-bottom:10px}
.avatar.c0{background:#2b64c8}.avatar.c1{background:#1d9a6c}.avatar.c2{background:#d64545}.avatar.c3{background:#9a5b00}.avatar.c4{background:#6b4bc8}.avatar.c5{background:#0e8ba8}
.resizer{width:6px;flex-shrink:0;cursor:col-resize;background:transparent;position:relative;z-index:5;margin:0 -3px}.resizer:hover,.resizer.active{background:#c8d6ee}body.resizing{cursor:col-resize;user-select:none}body.resizing iframe{pointer-events:none}
@media(max-width:1100px){.list-pane{width:min(var(--list-w),340px)}}
@media(max-width:900px){.resizer{display:none}#menu-toggle{display:inline-block}.sidebar{position:absolute;left:0;top:54px;bottom:0;z-index:30;transform:translateX(-100%);transition:transform .2s;box-shadow:var(--shadow)}.sidebar.open{transform:none}.shell{position:relative}.list-pane{width:100%;border-right:0}.read-pane{position:absolute;inset:0;z-index:10;display:none}.read-pane.open{display:flex}.search{max-width:none}.who{display:none}}
@media(max-width:600px){.topbar{padding:0 8px;gap:8px}.brand span:last-child{display:none}.mail-row .avatar{display:none}.read-head{padding:12px 14px 8px}.read-body{padding:8px 14px 14px}.attach-list{padding:8px 14px}.page-pane{padding:12px}.grid2{grid-template-columns:1fr}.addr-row{grid-template-columns:70px 1fr auto}#compose{width:100vw;max-height:100vh;border-radius:0}.compose-head{border-radius:0}}
/* 0.8.4: 로그인 안내 (?) 버튼 — 로그인 화면 오른쪽 위 */
.help-btn{position:fixed;top:14px;right:16px;width:38px;height:38px;min-height:0;padding:0;border-radius:50%;border:2px solid #ffffffaa;background:#ffffff22;color:#fff;font-weight:700;font-size:18px;line-height:1;z-index:5}.help-btn:hover{background:#ffffff44}
.login-mode{margin:0 0 14px;font-size:13px;color:var(--brand-dark);font-weight:600}
/* 0.8.4: 관리자 페이지(/admin) — 메일함 요소 숨김 */
[data-mode=admin] .search,[data-mode=admin] #compose-btn,[data-mode=admin] #folder-nav,[data-mode=admin] #filter-nav,[data-mode=admin] .side-title[data-a=filters],[data-mode=admin] #usage,[data-mode=admin] #rz-list{display:none!important}
.brand-mode{font-weight:400;color:var(--muted)}
.folders a.nav-link{display:flex;align-items:center;gap:10px;border-radius:8px;padding:8px 10px;min-height:38px;color:inherit;text-decoration:none}.folders a.nav-link:hover{background:var(--hover)}
fieldset.services{border:1px solid var(--line);border-radius:10px;padding:8px 14px 4px;margin:4px 0 8px}fieldset.services legend{font-size:12px;color:var(--muted);padding:0 6px}
.simple .tag{font-size:11px}
/* 0.8.4: 편집기 — SVG 아이콘, 색상 팔레트 */
.editor-tools svg{width:16px;height:16px;vertical-align:-3px;display:inline-block}
.color-tool{position:relative;display:inline-flex}.color-tool button{display:inline-flex;flex-direction:column;align-items:center;gap:1px;padding:2px 7px;line-height:1}.color-tool .glyph{font-size:14px}.color-tool .swatch{display:block;width:16px;height:4px;border-radius:2px}
.color-pop{position:absolute;top:100%;left:0;z-index:30;margin-top:4px;background:#fff;border:1px solid var(--line);border-radius:10px;padding:8px;box-shadow:var(--shadow);width:176px}
.color-pop .swatches{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}.color-pop .swatches button{width:34px;height:28px;min-height:0;padding:0;border:1px solid #d5dbe4;border-radius:6px}.color-pop .swatches button:hover{outline:2px solid var(--brand);outline-offset:1px}
.color-pop .color-more{display:flex;flex-direction:column;gap:4px;margin-top:8px}.color-pop .color-more button{min-height:30px;padding:3px 8px;font-size:12px;border:1px solid var(--line);background:#fff}.color-pop .color-more button:hover{background:var(--hover)}.color-pop input[type=color]{position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;bottom:0;left:0}
/* 0.8.16 설정: 메일 전달·자동 응답 양식. 사이드바 아래 자동 처리 상태(.auto-note)는 켜 둔 전달·자동 응답을 잊지 않게 합니다. */
.auto-form label:not(.switch){display:block;margin:8px 0}.auto-form textarea{min-height:120px;resize:vertical}.auto-form .period{margin:4px 0 8px}.auto-form .actions{align-items:center;margin-top:10px}
.auto-note{margin-top:8px;color:var(--brand);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.auto-note.warn{color:#b45309}
/* 비밀번호 입력 칸 + 보기 단추(0.8.25) */
.pw-wrap{display:flex;gap:6px;align-items:center}.pw-wrap input{flex:1;min-width:0}.pw-wrap .eye{flex:none;min-height:36px;padding:0 10px;border:1px solid #cbd3de;border-radius:8px;background:#fff;cursor:pointer}
.switch-row{display:flex;align-items:center;gap:14px;flex-wrap:wrap}.switch-row .switch{margin:8px 0}.auto-on{color:var(--brand);font-weight:700}
/* 0.9.0: 회원 가입 링크(로그인 화면), 안내성 피드백, 사용량 막대 경고, 승인 대기 행 */
.link-btn{width:100%;min-height:34px;margin-top:6px;border:0;background:transparent;color:var(--brand);font-size:13px;text-decoration:underline}.link-btn:hover{color:var(--brand-dark)}
#login-feedback.info:not(:empty){background:#e6f1ff;color:#1f4c9b}
.usage .bar.full i{background:var(--danger)}
table.simple tr.pending td{background:#fff8e6}
/* 0.9.3: 대용량 첨부파일 — 칩(☁), 발송 조건 줄, 읽기 창 목록 */
.chip.large{background:#eef5ff;border-color:#c9dbf5}.chip.large.off{opacity:.6}.chip.large button{border:0;background:transparent;padding:0 2px;min-height:0;color:var(--muted)}
.terms-row{display:flex;align-items:center;gap:10px;flex-wrap:wrap;padding:8px 10px;background:#eef5ff;border:1px solid #c9dbf5;border-radius:8px;font-size:13px}.terms-row label{margin:0;display:inline-flex;align-items:center;gap:4px;font-size:13px;color:var(--text)}.terms-row input{width:72px;min-height:30px;padding:2px 6px}.terms-row .meta{flex-basis:100%}
.large-list .meta{flex-basis:100%}
/* 0.9.5: 업로드 진행 칩, 참조·숨은 참조 단추 묶음 */
.chip.large.uploading{flex-wrap:wrap;gap:4px 8px}.chip.large.uploading progress{width:160px;height:8px;accent-color:var(--brand)}.chip.large.uploading .pct{font-size:11px;color:var(--muted)}
.addr-links{display:inline-flex;gap:2px}