Files
AriWeb-Suite/modules/arimail/web/app.js
T

535 lines
118 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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();