'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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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=`
`;
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='')=>``;
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=>`
`;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=`🛠${text('nav_mailConfig')}✉${text('openMailbox')}`;$$('[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 ``;}).join('');
$('#filter-nav').innerHTML=[['all','filterAll'],['unread','filterUnread'],['attach','filterAttachments']].map(([k,l])=>``).join('');
// 관리자에게는 관리자 페이지(/admin) 링크만 둡니다. 계정 관리는 거기서 따로 로그인한 뒤 씁니다(0.8.4~).
$('#tool-nav').innerHTML=`${S.me.role==='admin'?`👥${text('nav_adminPage')} ↗`:''}`;
// 폴더를 누르면 검색어도 지웁니다(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?`
↪ ${text('sidebarForward')}
`:''}${S.autoReplyActive?`
⏰ ${text('sidebarAutoReply')}
`:''}${S.initialPassword?`
⚠ ${text('initialPasswordShort')}
`:''}`;
// 사용량(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=`
${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'?`${text('mailServiceOk')}`:`${text('mailServiceError')}: ${text(status.provider)}`:'';
$('#list-pane').innerHTML=`
`;
$('#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.donebulk({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 `
`;}
// ------------------------------------------------------------------ 읽기
// 자동 처리 기록 한 줄(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}))+' '+esc(fullDate(a.created_at))+'';}
function emptyRead(){$('#read-pane').innerHTML=`
✉
${text('noSelection')}
`;$('#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=''+t.innerHTML+'';return {html:``,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||'
`:''}`;
$$('[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',' ');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=`
`;
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:`
${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)=>``;
// 0.8.12: 링크·링크 제거·인용·이미지·서식 지우기도 일반 편집기(Word·Google Docs·Gmail)에서 쓰는 모양으로 통일합니다(24 격자).
// link: 사슬 두 고리 / unlink: 같은 사슬에 빗금(끊어진 링크) / quote: 큰따옴표 두 개(인용) / image: 그림 틀 + 해·산 / clear: T 에 빗금(서식 지우기 "Tx")
const ICON24={
link:svg('',24),
unlink:svg('',24),
quote:'',
image:svg('',24),
clear:'',
};
const ICON={alignLeft:svg(''),alignCenter:svg(''),alignRight:svg(''),
indent:svg(''),outdent:svg('')};
// 기본 색상 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)=>``;return `
`;}
/**
* 편집기 바인딩. 핵심은 "선택 영역 기억/복원"입니다.
* contenteditable 의 선택 영역은 도구 모음의 다른 요소(색상 선택창, 글꼴 목록)가 포커스를 가져가면 브라우저에 따라 사라집니다.
* 예전에는 색상 자체의 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=`
${PALETTE[cmd].map(([c,k])=>``).join('')}
${cmd==='hiliteColor'?``:''}
`;
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,`${esc(label)}`);}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,` `);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,` `);}}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=`${text('imgSize')}${[25,50,75,100].map(p=>``).join('')}`;
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,` `);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//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=`
${esc(title)}
`;
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=>`📎 ${esc(a.filename)} ${kb(a.size)}${locked?'':``}`).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=>`☁ ${esc(f.filename)} ${kb(f.size)}${locked?'':``}`).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)} ${kb(file.size)}0%`;$('#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=>`
${rows.map(([k,v])=>`
${k}
${v}
`).join('')}
`;
// 메일 전달·자동 응답(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'),`${esc(S.me.source==='www'?'admin':S.me.login)} / ${esc(S.mailbox)}`],[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=`
`;
// 비밀번호 변경(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=>`
`}`;
$('#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=>`
${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 `
${text('largeSection')}
${text('largeAdminIntro')}
`;}
function configCards(c){const sys=c.system||{},lim=c.limits||{},sg=c.signup||{},sv=c.services||{};
return `
`;}
/**
* 비밀번호 정책(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 `
${text('policySection')}
${text('policyIntro')}
`;}
/**
* 계정 추가·수정 창.
* - 메일함 주소는 입력받지 않고 "로그인 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=``;
const quota=`