'use strict'; const A=JSON.parse(document.getElementById('strings').textContent); // BASE(1.0.8): 이 화면의 기준 경로. 서버가 data-base 로 심어 줍니다 — 보통 "/", nginx 가 /mail/ 아래를 게이트웨이로 넘기는 사이트(site.ariweb.net/mail)면 "/mail/". // 정적 파일·API·화면 사이 링크가 모두 이 값을 앞에 붙이므로 같은 코드가 어느 경로 아래에서도 돌아갑니다. REL 은 BASE 를 뗀 현재 경로(모드 판단용). const BASE=(document.documentElement.dataset.base||'/').replace(/\/?$/,'/'); const REL=location.pathname.startsWith(BASE)?'/'+location.pathname.slice(BASE.length):location.pathname; // MODE: 이 화면이 웹 메일(/)인지 관리자 페이지(/admin)인지. 주소로 정하며, 관리자 페이지는 별도 로그인(관리자 세션)을 거칩니다. // 모든 API 호출에 X-Mail-Scope 헤더로 알려 서버가 알맞은 세션 쿠키를 보게 합니다. const MODE=/^\/admin\/?$/.test(REL)?'admin':'mail';document.documentElement.dataset.mode=MODE; // standalone(0.8.18~): www 사이트와 연동하지 않는 독립 설치. 서버가 data-standalone="1" 로 심어 주며, www 관리자 관련 안내를 숨기는 데 씁니다. // auth(1.0.8): 로그인 방식 local | www | webmaker — 접속 안내의 문구를 고르는 데 씁니다. const SITE={host:document.documentElement.dataset.host||location.host,domain:document.documentElement.dataset.domain||'',standalone:document.documentElement.dataset.standalone==='1',auth:document.documentElement.dataset.auth||'local',version:document.documentElement.dataset.version||''}; /** * 배포 뒤 자동 새로고침(0.8.25~). 서버는 모든 응답에 X-App-Version 을 실어 보냅니다. 열어 둔 화면의 버전(SITE.version, 서버가 index.html 에 심음)과 다르면 * 옛 app.js 가 새 API 를 부르다 "처리하지 못했습니다" 로 끝나는 일이 생기므로 화면을 새로 고칩니다. 같은 버전으로 두 번 새로 고치지 않도록 sessionStorage 에 표시해 되풀이를 막습니다. */ function checkVersion(v){if(!v||!SITE.version||v===SITE.version)return;try{if(sessionStorage.getItem('mg:reloaded')===v)return;sessionStorage.setItem('mg:reloaded',v);}catch{}location.reload();} /** 서버 오류 코드를 사람이 읽을 문구로. 아는 코드는 그 문구, 모르는 코드는 일반 문구에 코드(와 detail)를 덧붙여 원인을 추적할 수 있게 합니다(0.8.25). */ function errText(e){const code=e&&e.message||'error'; // 제한 오류(0.9.0): 서버가 limit(적용된 상한)을 붙여 보내면 "<코드>Limit" 문구로 구체적인 수치를 보여 줍니다(예: 받는 사람은 최대 20명). if(e&&e.limit!==undefined&&A[code+'Limit'])return fill(code+'Limit',{limit:e.limit,mb:mbOf(e.limit),max:/Bytes$/.test(e.field||'')?mbOf(e.limit):String(e.limit),field:e.field&&A['limit_'+e.field]?tr('limit_'+e.field):''}); if(A[code])return tr(code);return fill('errorCode',{code:code+(e&&e.detail?': '+e.detail:'')});} /** 바이트를 MB 문자열로(소수 1자리, 1MB 미만은 KB). 제한 값·사용량 표시에 씁니다. */ const mbOf=n=>n>=1048576?(n/1048576).toFixed(n%1048576?1:0)+' MB':Math.max(1,Math.round((n||0)/1024))+' KB'; const scopeHeaders=()=>MODE==='admin'?{'X-Mail-Scope':'admin'}:{}; const $=(s,r=document)=>r.querySelector(s),$$=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const tr=k=>A[k]||k,text=k=>esc(tr(k)),fill=(k,vars)=>Object.entries(vars).reduce((s,[a,b])=>s.replaceAll('{'+a+'}',String(b)),tr(k)); document.querySelectorAll('[data-a]').forEach(e=>e.textContent=tr(e.dataset.a)); const S={me:null,mailboxes:[],mailbox:'',view:'mail',folder:'inbox',filter:'all',page:0,query:'',selected:new Set(),current:null,counts:[],rows:[],total:0,prefs:{remoteImages:false},status:null,syncBusy:false,quota:0,signupEnabled:false}; let viewVersion=0; const addr=v=>{const m=String(v||'').match(/<([^<>]+)>\s*$/);return (m?m[1]:String(v||'')).trim();}; const nameOf=v=>{const s=String(v||'').trim();const m=s.match(/^"?(.*?)"?\s*<[^<>]+>$/);return m&&m[1]?m[1].trim():s.split('@')[0];}; const initial=v=>{const n=nameOf(v);return (n[0]||'?').toUpperCase();}; const colorOf=v=>'c'+([...String(v||'')].reduce((a,c)=>a+c.charCodeAt(0),0)%6); function when(iso){if(!iso)return '';const d=new Date(iso),n=new Date();const p=x=>String(x).padStart(2,'0');if(d.toDateString()===n.toDateString())return p(d.getHours())+':'+p(d.getMinutes());if(d.getFullYear()===n.getFullYear())return (d.getMonth()+1)+'월 '+d.getDate()+'일';return d.getFullYear()+'-'+p(d.getMonth()+1)+'-'+p(d.getDate());} const fullDate=iso=>iso?new Date(iso).toLocaleString('ko-KR',{dateStyle:'medium',timeStyle:'short'}):''; const kb=n=>n>1048576?(n/1048576).toFixed(1)+' MB':Math.max(1,Math.round((n||0)/1024))+' KB'; function notify(key,detail=''){const el=$('#feedback');el.textContent=tr(key)+(detail?' ('+detail+')':'');clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},4000);} async function api(path,method='GET',body){const version=viewVersion;const r=await fetch(BASE+'api/'+path,{method,headers:{...scopeHeaders(),...(body instanceof FormData||body===undefined?{}:{'Content-Type':'application/json'})},body:body instanceof FormData?body:body===undefined?undefined:JSON.stringify(body)});checkVersion(r.headers.get('x-app-version'));const d=await r.json().catch(()=>({error:'error'}));if(method==='GET'&&version!==viewVersion)return new Promise(()=>{});if(!r.ok){if(r.status===401&&path!=='login'){showLogin();return new Promise(()=>{});}const err=new Error(d.error||'error');if(d.detail)err.detail=String(d.detail).slice(0,300);if(d.problems){err.problems=d.problems;err.policy=d.policy;}if(d.limit!==undefined)err.limit=d.limit;if(d.field)err.field=d.field;throw err;}return d;} const fail=e=>{const el=$('#feedback');el.textContent=errText(e)+(A[e&&e.message]&&e.detail?' ('+e.detail+')':'');clearTimeout(notify.t);notify.t=setTimeout(()=>{el.textContent='';},6000);}; // 메일 상태에 맞는 이동 대상 폴더(서버 api.mjs 의 movableFolders 와 같은 규칙). 받은 메일 → 받은 메일함·휴지통, // 발송한 메일 → 보낸 메일함·휴지통, 초안·발송 실패 → 임시 보관함·휴지통. const movable=m=>m.direction==='inbound'?['inbox','trash']:m.state==='sent'?['sent','trash']:['drafts','trash']; // 0.9.0: 관리자의 다른 메일함 열람(?mailbox=)을 없앴습니다. 모든 메일 API 는 로그인한 계정의 메일함만 다룹니다. // ------------------------------------------------------------------ 세션 function showLogin(){viewVersion++;S.me=null;$('#compose').open&&$('#compose').close();$('#detail').open&&$('#detail').close();$('#workspace').hidden=true;$('#login').hidden=false;const mode=$('#login-mode');mode.hidden=MODE!=='admin';if(MODE==='admin')mode.textContent=tr('adminLoginTitle'); // 회원 가입(0.9.0): 관리자가 켜 두었을 때만 로그인 단추 아래에 "회원 가입" 을 보여 줍니다(관리자 페이지 로그인에는 없음). $('#signup-btn').hidden=!(S.signupEnabled&&MODE==='mail');$('#login input').focus();} /** * 회원 가입 창(0.9.0). 로그인 ID·이름·비밀번호(2회, 눈 단추)를 받아 POST /api/signup 으로 보냅니다. * 결과의 pending 이 true 면 "관리자 승인 후 사용" 안내, 아니면 바로 로그인하라고 안내합니다. 오류(ID 중복·정책 위반·계정 수 제한)는 창 안에 보여 줍니다. */ function signupDialog(){$('#dialog-title').textContent=tr('signupTitle');$('#dialog-feedback').textContent=''; $('#dialog-body').innerHTML=`

${esc(fill('signupIntro',{domain:SITE.domain}))}

${pwField('password',text('userPassword'),'autocomplete="new-password" required')}${pwField('passwordConfirm',text('userPasswordConfirm'),'autocomplete="new-password" required')}

${text('signupHelp')}

`; const dlg=$('#detail'),form=$('#signup-form');bindEyes(form);$('#signup-cancel').onclick=()=>dlg.close(); form.elements.login.oninput=()=>{$('#signup-mailbox').value=form.elements.login.value.trim().toLowerCase()+'@'+SITE.domain;}; form.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',form);b.disabled=true;$('#dialog-feedback').textContent='';try{const f=form.elements;if(f.password.value!==f.passwordConfirm.value){$('#dialog-feedback').textContent=tr('password_mismatch');return;} const r=await api('signup','POST',{login:f.login.value,name:f.name.value,password:f.password.value,passwordConfirm:f.passwordConfirm.value});dlg.close();$('#login-feedback').textContent=tr(r.pending?'signupPending':'signupDone');$('#login-feedback').classList.add('info');$('#login input').value=f.login.value.trim().toLowerCase();} catch(err){$('#dialog-feedback').textContent=passwordError(err);}finally{b.disabled=false;}}; if(!dlg.open)dlg.showModal();} $('#signup-btn').onclick=signupDialog; /** 비밀번호 입력 칸 + 눈 단추(0.8.25~, 0.9.0 에서 공용 도우미로). bindEyes(form) 가 단추를 묶어 글자를 보이거나 숨깁니다. */ const pwField=(name,label,attrs='')=>``; function bindEyes(form){$$('[data-eye]',form).forEach(btn=>btn.onclick=()=>{const i=form.elements[btn.dataset.eye];const show=i.type==='password';i.type=show?'text':'password';btn.textContent=show?'🙈':'👁';btn.title=btn.ariaLabel=tr(show?'hidePassword':'showPassword');i.focus();});} // 로그인 화면 오른쪽 위 (?) — 이 도메인의 메일 서비스라는 점과, 부여받은 계정 또는 도메인을 포함한 이메일 주소로 로그인하며 // 웹 접속 계정과 같은 아이디·비밀번호를 쓴다는 안내를 팝업으로 보여 줍니다. 문구의 {host}/{domain}/{www} 는 서버가 심어 둔 값입니다. function loginHelp(){const v={host:SITE.host,domain:SITE.domain,www:'www.'+SITE.domain,example:'info'}; // 3번 항목: www 연동이면 www 관리자 안내, 웹메이커 연동(1.0.8)이면 웹메이커 계정 안내, 독립 실행이면 없음. const items=[1,2,3,4,5].filter(n=>!(n===3&&SITE.standalone&&SITE.auth!=='webmaker')).map(n=>`
  • ${esc(fill(n===3&&SITE.auth==='webmaker'?'loginHelpWebmaker':'loginHelp'+n,v))}
  • `).join(''); $('#dialog-title').textContent=fill('loginHelpTitle',v);$('#dialog-feedback').textContent='';$('#dialog-body').innerHTML=`

    ${esc(fill('loginHelpIntro',v))}

      ${items}
    ${MODE==='admin'?`

    ${esc(fill('adminLoginHelp',v))}

    `:''}

    ${esc(fill('loginHelpFooter',v))}

    `;const dlg=$('#detail');if(!dlg.open)dlg.showModal();} $('#login-help').onclick=loginHelp; async function start(){try{const res=await fetch(BASE+'api/session',{cache:'no-store',headers:scopeHeaders()});checkVersion(res.headers.get('x-app-version'));const d=await res.json();S.signupEnabled=!!d.signup;if(!d.user){showLogin();return;}S.me=d.user;S.mailboxes=[d.mailbox];S.mailbox=d.mailbox;S.prefs={remoteImages:false,pop:true,imap:true,...(d.prefs||{})};S.autoReplyActive=!!d.autoReplyActive;S.initialPassword=!!d.initialPassword;S.largeFiles=d.largeFiles||{enabled:false};S.attachmentBytes=d.attachmentBytes||0;$('#login').hidden=true;$('#workspace').hidden=false; // 관리자 페이지: 메일함 대신 계정 관리·서버 상태만 보여 줍니다. 검색·메일 쓰기·폴더는 CSS([data-mode=admin])로 숨깁니다. if(MODE==='admin'){$('#brand-mode').hidden=false;$('#brand-mode').textContent=' · '+tr('adminTitle');$('#whoami').textContent=(S.me.name||S.me.login)+' · '+tr('adminTitle');S.view='admin';renderSidebar();await adminPage();return;} // 0.9.0: 메일함 선택(관리자의 다른 메일함 열람)을 없앴습니다. 관리자도 자기 메일함만 봅니다. $('#whoami').textContent=(S.me.name||S.me.login)+' · '+S.me.mailbox;renderSidebar();await renderMail(true);}catch(e){showLogin();}} $('#login').onsubmit=async e=>{e.preventDefault();const f=e.target,b=$('button[type=submit],button:not([type])',f);b.disabled=true;$('#login-feedback').textContent='';$('#login-feedback').classList.remove('info');try{await api('login','POST',Object.fromEntries(new FormData(f)));f.reset();await start();}catch(err){$('#login-feedback').textContent=errText(err);f.elements.password.value='';}finally{b.disabled=false;}}; $('#logout').onclick=async()=>{await fetch(BASE+'api/session',{method:'DELETE',headers:scopeHeaders()});location.replace(MODE==='admin'?BASE+'admin':BASE);}; $('#menu-toggle').onclick=()=>$('#sidebar').classList.toggle('open'); document.addEventListener('click',e=>{if(window.innerWidth<=900&&!e.target.closest('#sidebar')&&!e.target.closest('#menu-toggle'))$('#sidebar').classList.remove('open');}); $('#global-search').placeholder=tr('searchPlaceholder'); const doSearch=()=>{S.query=$('#global-search').value.trim();S.page=0;S.view='mail';renderMail();}; $('#global-search-go').onclick=doSearch;$('#global-search').onkeydown=e=>{if(e.key==='Enter')doSearch();}; $('#compose-btn').onclick=()=>openCompose({}); $('.close',$('#detail')).onclick=()=>$('#detail').close(); // ------------------------------------------------------------------ 사이드바 const FOLDERS=[['inbox','📥','folder_inbox'],['starred','⭐','folder_starred'],['sent','📤','folder_sent'],['drafts','📝','folder_drafts'],['trash','🗑','folder_trash']]; function renderSidebar(){ // 관리자 페이지 모드: 왼쪽에는 계정 관리·서버 상태·메일함 열기만 둡니다. if(MODE==='admin'){$('#tool-nav').innerHTML=`🛠${text('nav_mailConfig')}✉${text('openMailbox')}`;$$('[data-view]').forEach(b=>b.onclick=()=>{S.view=b.dataset.view;renderSidebar();S.view==='status'?statusPage():adminPage();});return;} const count=f=>S.counts.find(c=>c.folder===f); $('#folder-nav').innerHTML=FOLDERS.map(([f,ic,label])=>{const c=f==='inbox'?count('inbox')?.unread:f==='drafts'?count('drafts')?.n:0;return ``;}).join(''); $('#filter-nav').innerHTML=[['all','filterAll'],['unread','filterUnread'],['attach','filterAttachments']].map(([k,l])=>``).join(''); // 관리자에게는 관리자 페이지(/admin) 링크만 둡니다. 계정 관리는 거기서 따로 로그인한 뒤 씁니다(0.8.4~). $('#tool-nav').innerHTML=`${S.me.role==='admin'?`👥${text('nav_adminPage')} ↗`:''}`; // 폴더를 누르면 검색어도 지웁니다(0.9.9). 예전에는 검색 뒤 다른 폴더를 눌러도 검색어가 남아 "0–0 / 0" 으로 비어 보였습니다(검색 칸의 글자가 유일한 단서). $$('[data-folder]').forEach(b=>b.onclick=()=>{S.view='mail';S.folder=b.dataset.folder;S.page=0;S.query='';$('#global-search').value='';S.selected.clear();S.current=null;$('#read-pane').classList.remove('open');renderMail();}); $$('[data-filter]').forEach(b=>b.onclick=()=>{S.filter=b.dataset.filter;S.page=0;S.view='mail';renderMail();}); $$('[data-view]').forEach(b=>b.onclick=()=>{S.view=b.dataset.view;renderSidebar();settingsPage();}); // 자동 처리 상태(0.8.16): 전달·자동 응답이 켜져 있으면 사이드바 아래에 알려 줍니다(켜 둔 것을 잊고 메일이 계속 나가는 일을 막기 위해). const auto=`${S.prefs.forward?.enabled?`
    ↪ ${text('sidebarForward')}
    `:''}${S.autoReplyActive?`
    ⏰ ${text('sidebarAutoReply')}
    `:''}${S.initialPassword?`
    ⚠ ${text('initialPasswordShort')}
    `:''}`; // 사용량(0.9.0): MIME 기준 사용량과 적용 중인 메일함 최대 크기(quota, 0 = 제한 없음). 제한이 있으면 막대와 비율을, 90% 이상이면 경고색을 보여 줍니다. if(S.usage!==undefined){const q=S.quota||0,pct=q?Math.min(100,(S.usage||0)/q*100):0;$('#usage').innerHTML=`
    ${text('usageLabel')} · ${kb(S.usage)}${q?' / '+kb(q):''}
    ${q?`
    ${esc(fill('quotaPercent',{percent:Math.round(pct)}))}
    `:`
    ${text('quotaUnlimited')}
    `}
    ${text('retentionShort')}
    ${auto}`;} } // ------------------------------------------------------------------ 목록 function showPanes(page){$('#page-pane').hidden=!page;$('#list-pane').hidden=!!page;$('#read-pane').hidden=!!page;$('#rz-list').hidden=!!page;} async function renderMail(sync=false){ viewVersion++;const version=viewVersion;showPanes(false);renderSidebar(); if(sync&&!S.syncBusy){S.syncBusy=true;api('mail/sync','POST',{quick:true}).then(r=>{if(r.started)setTimeout(()=>{if(S.view==='mail'&&S.me)renderMail();},8000);}).catch(()=>{}).finally(()=>{S.syncBusy=false;});} const folder=S.folder==='starred'?'inbox':S.folder;const q=`mail?folder=${folder}&page=${S.page}&q=${encodeURIComponent(S.query)}${S.filter==='unread'?'&unread=1':''}${S.filter==='attach'?'&attach=1':''}${S.folder==='starred'?'&flagged=1':''}`; const [d,status]=await Promise.all([api(q),S.status?Promise.resolve(S.status):api('mail/status').catch(()=>null)]);if(version!==viewVersion)return; // 마지막 페이지의 메일을 모두 지우거나 옮기면 범위를 벗어난 페이지("51–50 / 50")에 남습니다. 마지막 유효 페이지로 돌아가 다시 그립니다. if(S.page>0&&S.page*d.size>=d.total){S.page=Math.max(0,Math.ceil(d.total/d.size)-1);return renderMail();} // 첨부 필터는 서버(attach=1)가 걸러 주므로 받은 목록을 그대로 씁니다(예전에는 한 페이지 안에서만 걸러 합계와 어긋났음). S.status=status;S.counts=d.counts;S.total=d.total;S.usage=d.usageBytes;S.quota=d.quotaBytes||0;S.rows=d.messages;renderSidebar(); // 연결 오류 표시: 문구는 오류 코드별(ko.json), 서버가 만든 원인 문장(detail — 1.0.11: 검증되지 않은 도메인이면 어느 DNS 레코드가 다른지)은 마우스를 올리면 보입니다. 전체 문장은 관리자 › 서버 상태. const statusTag=status?status.provider==='ok'?`${text('mailServiceOk')}`:`${text('mailServiceError')}: ${text(status.provider)}`:''; $('#list-pane').innerHTML=`
    ${text('folder_'+S.folder)}${S.query?`${esc(fill('searchChip',{q:S.query}))}`:''}${d.syncing?`${text('syncing')}`:''}${statusTag}
    ${S.folder==='trash'?``:``}
    ${S.rows.map(row).join('')||`
    📭
    ${text('emptyFolder')}
    `}
    ${S.page*d.size+Math.min(1,d.total)}–${Math.min((S.page+1)*d.size,d.total)} / ${d.total}
    `; $('#act-refresh').onclick=()=>renderMail(true);$('#pg-prev').onclick=()=>{S.page--;renderMail();};$('#pg-next').onclick=()=>{S.page++;renderMail();}; $('#select-all').onchange=e=>{S.selected=new Set(e.target.checked?S.rows.map(m=>m.id):[]);$$('.mail-row input[type=checkbox]').forEach(c=>c.checked=e.target.checked);selCount();}; // 검색 칩(0.9.9): 검색 중이면 목록 머리에 "검색: …" 과 ✕ 가 보이고, ✕ 를 누르면 검색을 풀고 폴더 전체를 다시 보여 줍니다. if($('#search-clear'))$('#search-clear').onclick=()=>{S.query='';$('#global-search').value='';S.page=0;renderMail();}; $$('.mail-row').forEach(r=>{r.onclick=e=>{if(e.target.closest('input,button'))return;openMessage(r.dataset.id);};$('input[type=checkbox]',r).onchange=e=>{e.target.checked?S.selected.add(r.dataset.id):S.selected.delete(r.dataset.id);selCount();};// 별표 전환. e.currentTarget 은 await 뒤에 null 이 되므로(이벤트 처리가 끝나면 비워짐) 버튼을 btn 에 먼저 잡아 둡니다. $('.star',r).onclick=async e=>{e.stopPropagation();const btn=e.currentTarget,on=!btn.classList.contains('on');try{await api('mail/'+r.dataset.id,'PATCH',{flagged:on});if(S.current===r.dataset.id)openMessage(r.dataset.id);btn.classList.toggle('on',on);btn.textContent=on?'★':'☆';}catch(err){fail(err);}};}); const bulk=async(body,confirmKey)=>{if(!S.selected.size)return;if(confirmKey&&!confirm(tr(confirmKey)))return;const ids=[...S.selected];try{const r=await api('mail/bulk','POST',{ids,...body});S.selected.clear();if(body.action==='move'&&r.donebulk({action:'trash'});if($('#act-restore'))$('#act-restore').onclick=()=>bulk({action:'restore'});if($('#act-purge'))$('#act-purge').onclick=()=>bulk({action:'delete'},'confirmDeleteForever'); $('#act-read').onclick=()=>bulk({action:'read'});$('#act-unread').onclick=()=>bulk({action:'unread'});$('#act-move').onchange=e=>{if(e.target.value)bulk({action:'move',target:e.target.value});}; selCount();if(S.current&&d.messages.find(m=>m.id===S.current))$(`.mail-row[data-id="${S.current}"]`)?.setAttribute('aria-selected','true');else if(S.current&&S.filter==='all'&&!S.query&&d.total<=d.size){S.current=null;emptyRead();}else if(!S.current)emptyRead(); } async function refreshCounts(){try{const d=await api('mail/counts');S.counts=d.counts;S.usage=d.usageBytes;S.quota=d.quotaBytes||0;renderSidebar();}catch{}} function selCount(){$('#sel-count').textContent=S.selected.size?fill('selected',{count:S.selected.size}):'';} function row(m){const who=m.direction==='inbound'?m.sender:JSON.parse(m.recipients_json||'[]').join(', ');const unread=!m.read_at&&m.direction==='inbound';return `
    ${esc(initial(who))}
    ${esc(m.direction==='inbound'?nameOf(who):(tr('toMe')+': '+who))}${esc(m.subject||tr('noSubject'))}${m.attachment_count?'📎':''}${m.state==='failed'?' ⚠':''}${m.pop_delivered?' POP':''}${esc(m.preview||'')}
    `;} // ------------------------------------------------------------------ 읽기 // 자동 처리 기록 한 줄(0.8.16): "전달됨 → 주소 (시각)", "자동 응답 보냄 → 주소", 건너뛴 경우는 이유(되풀이 방지·오래된 메일·자동 발송 메일 …)를 풀어 씁니다. function autoLine(a){const reason=a.detail&&A['reason_'+a.detail]?tr('reason_'+a.detail):a.detail||'';const key='auto_'+a.kind+'_'+a.status;return esc(fill(A[key]?key:'auto_pending',{target:a.target||'',detail:reason}))+' '+esc(fullDate(a.created_at))+'';} function emptyRead(){$('#read-pane').innerHTML=`
    ✉
    ${text('noSelection')}
    `;$('#read-pane').classList.remove('open');} function frame(html,allowRemote){const t=document.createElement('template');t.innerHTML=html;let remote=0;t.content.querySelectorAll('img').forEach(img=>{try{if(new URL(img.getAttribute('src'),location.href).origin!==location.origin){remote++;if(!allowRemote)img.removeAttribute('src');}}catch{img.removeAttribute('src');}});const doc=''+t.innerHTML+'';return {html:``,remote};} // 파일 첨부인지(본문에 끼운 그림이 아닌지). Content-ID 가 없는 inline 은 본문에서 참조할 수 없으므로 일반 첨부로 봅니다(0.9.9). // Apple Mail 등은 PDF 를 "inline; filename=…" 으로 보내는데, 예전에는 읽기 창에는 칩이 보이면서 전달 창에서는 빠졌습니다. const isFile=a=>a.disposition!=='inline'||!a.content_id; async function openMessage(id){ try{S.current=id;$$('.mail-row').forEach(r=>r.setAttribute('aria-selected',String(r.dataset.id===id))); const m=(await api('mail/'+id)).message; // 경쟁 방지(0.9.9): 느린 응답 사이에 다른 줄을 눌렀으면(S.current 가 바뀜) 이 응답은 버립니다. 예전에는 먼저 누른 메일의 늦은 응답이 나중에 그려져, // 목록은 두 번째 메일이 선택돼 있는데 읽기 창은 첫 메일을 보여 주고 답장·삭제 단추도 첫 메일에 동작했습니다. if(S.current!==id)return; // 초안 상태(또는 발송 실패)인 메일만 작성 창으로 엽니다. 임시 보관함에 들어온 받은 메일(옛 IMAP MOVE 등)은 보통 읽기 창으로 보여 줍니다(0.8.24). // 휴지통에 있는 메일은 발송 실패한 메일이라도 읽기 창(복원·영구 삭제)으로 엽니다. 예전에는 잠긴 작성 창이 열려 할 수 있는 일이 없었습니다(0.8.25). if(m.folder!=='trash'&&((m.folder==='drafts'&&m.state==='draft')||m.state==='failed')){openCompose(m);return;} const rowEl=$(`.mail-row[data-id="${id}"]`);const wasUnread=rowEl&&rowEl.classList.contains('unread');if(rowEl)rowEl.classList.remove('unread');const rowData=S.rows.find(x=>x.id===id);if(rowData&&!rowData.read_at)rowData.read_at=new Date().toISOString(); if(wasUnread){const c=S.counts.find(x=>x.folder===m.folder);if(c&&c.unread>0)c.unread--;renderSidebar();refreshCounts();} const to=JSON.parse(m.recipients_json||'[]'),cc=JSON.parse(m.cc_json||'[]'),replyTo=JSON.parse(m.reply_to_json||'[]');let details=false,remote=!!S.prefs.remoteImages; const paint=()=>{if(S.current!==id)return;const body=frame(m.previewHtml||m.html||'

    '+esc(m.text)+'

    ',remote); $('#read-pane').innerHTML=`
    ${m.folder==='trash'?``:``}${m.direction==='inbound'?``:''}⬇ ${text('original')}

    ${esc(m.subject||tr('noSubject'))}${m.flagged?` ${text('flagged')}`:''}${m.answered?` ${text('answered')}`:''}

    ${esc(initial(m.sender))}
    ${esc(nameOf(m.sender))}
    <${esc(addr(m.sender))}> → ${esc(to.join(', '))}${cc.length?' · cc '+esc(cc.join(', ')):''}
    ${details?`
    ${text('from')}${esc(m.sender)}${text('to')}${esc(to.join(', '))}${cc.length?`${text('cc')}${esc(cc.join(', '))}`:''}${replyTo.length?`Reply-To${esc(replyTo.join(', '))}`:''}${text('receivedAt')}${esc(fullDate(m.received_at))} · ${text('source_'+m.source)} · ${text(m.raw_status==='stored'?'originalStored':m.raw_status==='built'?'originalBuilt':'originalNone')}POP${m.popDeliveries.length?m.popDeliveries.map(p=>esc(fullDate(p.delivered_at))+' ('+esc(p.via)+')').join(', ')+` `:text('popNotDelivered')}${m.message_id?`Message-ID${esc(m.message_id)}`:''}${(m.autoActions||[]).length?`${text('autoActions')}${m.autoActions.map(autoLine).join('
    ')}
    `:''}
    `:''}
    ${body.remote?``:''}${body.html}
    ${m.attachments.length?`
    ${m.attachments.map(a=>a.available?`📎 ${esc(a.filename)} ${kb(a.size)}`:`📎 ${esc(a.filename)} ${text('attachmentUnavailable')}`).join('')}
    `:''}${(m.largeFiles||[]).length?`
    ☁ ${text('largeFileHeading')}${m.largeFiles.map(f=>`☁ ${esc(f.filename)} ${kb(f.size)} ${f.expired?text('largeExpired'):esc(fill('largeStatus',{remaining:f.remaining,max:f.max_downloads,until:fullDate(f.expires_at)}))}${f.expired?'':``}`).join('')}
    `:''}`; $$('[data-revoke]').forEach(b=>b.onclick=async()=>{if(!confirm(tr('confirmRevoke')))return;try{await api('mail/'+id+'/files/'+b.dataset.revoke,'DELETE');openMessage(id);}catch(e){fail(e);}}); $('#read-pane').classList.add('open');if(window.innerWidth<=900){$('#rd-back').style.display='';$('#rd-back').onclick=()=>{$('#read-pane').classList.remove('open');};} const fr=$('.mail-frame');fr.onload=()=>{try{fr.style.height=Math.max(300,fr.contentDocument.body.scrollHeight+30)+'px';}catch{}}; $('#rd-details').onclick=()=>{details=!details;paint();};if($('#rd-remote'))$('#rd-remote').onclick=()=>{remote=!remote;paint();}; const act=async(body,confirmKey,after)=>{if(confirmKey&&!confirm(tr(confirmKey)))return;try{await api('mail/'+id,body.action==='delete'?'DELETE':'PATCH',body.action==='delete'?undefined:body);if(after==='close'){S.current=null;emptyRead();}await renderMail();if(after==='reopen')openMessage(id);}catch(e){fail(e);}}; if($('#rd-trash'))$('#rd-trash').onclick=()=>act({folder:'trash'},null,'close');if($('#rd-restore'))$('#rd-restore').onclick=()=>act({restore:true},null,'close');if($('#rd-purge'))$('#rd-purge').onclick=()=>act({action:'delete'},'confirmDeleteForever','close'); $('#rd-star').onclick=()=>act({flagged:!m.flagged},null,'reopen');if($('#rd-unread'))$('#rd-unread').onclick=()=>act({unread:true},null,'close');$('#rd-move').onchange=e=>{if(e.target.value)act({action:'move',target:e.target.value},null,'close');}; if($('#rd-pop-reset'))$('#rd-pop-reset').onclick=()=>act({popReset:true},'popResetConfirm','reopen'); // 답장·전달 인용문. 읽기 창에서 외부 이미지를 막아 둔 상태(remote=false)면 인용문에서도 외부 이미지 주소를 지웁니다. // 편집기는 iframe 이 아니라 이 페이지 안에 있으므로, 그대로 넣으면 답장 버튼을 누르는 순간 추적 이미지가 불러와졌습니다. const quoteBody=()=>{const html=m.previewHtml||m.html||esc(m.text).replaceAll('\n','
    ');if(remote)return html;const t=document.createElement('template');t.innerHTML=html;t.content.querySelectorAll('img').forEach(img=>{try{if(new URL(img.getAttribute('src'),location.href).origin!==location.origin)img.removeAttribute('src');}catch{img.removeAttribute('src');}});return t.innerHTML;}; const quoted=`



    ${esc(fill('quoteHeader',{date:fullDate(m.received_at),from:m.sender}))}
    ${quoteBody()}
    `; 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:`


    ${text('forwardedHeader')}
    ${text('from')}: ${esc(m.sender)}
    ${text('receivedAt')}: ${esc(fullDate(m.received_at))}
    ${text('mailSubject')}: ${esc(m.subject)}
    ${text('to')}: ${esc(to.join(', '))}

    ${quoteBody()}`,title:'forwardTitle'}); };paint(); }catch(e){fail(e);} } // ------------------------------------------------------------------ 편집기 const FONTS=['기본','Arial','Georgia','Verdana','Tahoma','Courier New','Malgun Gothic','Apple SD Gothic Neo','Nanum Gothic']; // 도구 모음 아이콘(워드 프로세서와 같은 모양). 정렬: 긴 줄·짧은 줄을 번갈아 왼쪽/가운데/오른쪽에 맞춘 선. 들여쓰기/내어쓰기: 줄과 화살표. const svg=(inner,box=16)=>``; // 0.8.12: 링크·링크 제거·인용·이미지·서식 지우기도 일반 편집기(Word·Google Docs·Gmail)에서 쓰는 모양으로 통일합니다(24 격자). // link: 사슬 두 고리 / unlink: 같은 사슬에 빗금(끊어진 링크) / quote: 큰따옴표 두 개(인용) / image: 그림 틀 + 해·산 / clear: T 에 빗금(서식 지우기 "Tx") const ICON24={ link:svg('',24), unlink:svg('',24), quote:'', image:svg('',24), clear:'', }; const ICON={alignLeft:svg(''),alignCenter:svg(''),alignRight:svg(''), indent:svg(''),outdent:svg('')}; // 기본 색상 8색. 글자색은 진한 색, 강조색은 연한 바탕색입니다. 더 많은 색은 브라우저 색상 선택창(RGB 입력·스포이드)으로 이어집니다. const PALETTE={foreColor:[['#000000','color_black'],['#e53935','color_red'],['#fb8c00','color_orange'],['#fdd835','color_yellow'],['#43a047','color_green'],['#1e88e5','color_blue'],['#8e24aa','color_purple'],['#9e9e9e','color_gray']], hiliteColor:[['#fff59d','color_yellow'],['#ffe0b2','color_orange'],['#ffcdd2','color_red'],['#c8e6c9','color_green'],['#bbdefb','color_blue'],['#e1bee7','color_purple'],['#e0e0e0','color_gray'],['#ffffff','color_white']]}; function toolbar(){const b=(cmd,label,icon)=>``;return ``;} /** * 편집기 바인딩. 핵심은 "선택 영역 기억/복원"입니다. * contenteditable 의 선택 영역은 도구 모음의 다른 요소(색상 선택창, 글꼴 목록)가 포커스를 가져가면 브라우저에 따라 사라집니다. * 예전에는 색상 자체의 mousedown 에서만 선택을 기억해서, 글자 "A"(label)를 눌러 창을 열거나 브라우저의 * 색상 선택창이 포커스를 가져간 뒤에는 기억한 범위가 없거나 낡아, 복원 때 커서가 맨 앞으로 가고 색이 적용되지 않았습니다. * 이제 (1) 도구 모음 어디를 누르든 포커스가 옮겨 가기 전(capture 단계 mousedown)에 기억하고, (2) 편집기 안에서 선택이 바뀔 때마다 * (selectionchange) 기억하며, (3) 색상 팔레트 버튼은 mousedown 을 막아 포커스를 아예 가져가지 않습니다. */ function bindEditor(form,onImage){const editor=$('[data-editor]',form);const tools=$('.editor-tools',form);let range=null; const remember=()=>{const s=window.getSelection();if(s.rangeCount&&editor.contains(s.anchorNode)&&editor.contains(s.focusNode))range=s.getRangeAt(0).cloneRange();}; const restore=()=>{editor.focus({preventScroll:true});if(range){const s=window.getSelection();s.removeAllRanges();s.addRange(range);}}; const command=(cmd,value)=>{restore();document.execCommand('styleWithCSS',false,true);document.execCommand(cmd,false,value);remember();refreshState();}; const refreshState=()=>{$$('[data-cmd]',form).forEach(b=>{if(['bold','italic','underline','strikeThrough','insertOrderedList','insertUnorderedList','justifyLeft','justifyCenter','justifyRight'].includes(b.dataset.cmd))b.setAttribute('aria-pressed',String(document.queryCommandState(b.dataset.cmd)));});}; editor.addEventListener('keyup',()=>{remember();refreshState();});editor.addEventListener('mouseup',()=>{remember();refreshState();});editor.addEventListener('focus',remember); document.addEventListener('selectionchange',()=>{if(document.activeElement===editor)remember();}); tools.addEventListener('mousedown',remember,true); $$('[data-cmd]',form).forEach(b=>{b.onmousedown=e=>e.preventDefault();b.onclick=()=>command(b.dataset.cmd);}); // 글꼴 "기본" 은 글꼴만 상위 값으로 되돌립니다. 예전에는 removeFormat 을 불러 글꼴을 되돌리려다 굵게·색 등 다른 서식까지 지워졌습니다. $$('[data-format]',form).forEach(el=>{el.onchange=()=>{if(el.dataset.format==='fontName'&&!el.value)command('fontName','inherit');else command(el.dataset.format,el.value);};}); // ---- 서식 지우기(0.8.12 에서 다시 구현) // 브라우저의 removeFormat 은 굵게·기울임·글꼴·글자색 같은 글자 서식만 지우고, 강조색(배경색, Chrome), 정렬(text-align), 들여쓰기(여백), // 인용(blockquote), 제목(h1~h4), 링크는 그대로 두며 커서만 있을 때(선택 없음)는 아무 일도 하지 않습니다. 그래서 "서식 지우기가 안 된다"고 느껴졌습니다. // 동작: 선택이 없으면 커서가 있는 단락 전체를 대상으로 하고, (1) removeFormat·unlink 로 글자 서식과 링크를 지운 뒤 // (2) 경계에 걸친 인라인 요소는 선택 부분만 떼어 내고 (3) 범위 안의 요소에서 서식 속성(style·class·align·color·face·size·bgcolor)을 지우며 // 제목·pre 는 단락(p)으로, 인용·인라인 래퍼(span·font·b·i·u·s·code·a …)는 껍데기를 벗깁니다. 목록·표·이미지·구분선은 내용 구조라 그대로 둡니다. const BLOCKS=new Set(['P','DIV','H1','H2','H3','H4','H5','H6','PRE','BLOCKQUOTE','LI','TD','TH','UL','OL','TABLE','TR','TBODY','THEAD']); const INLINE_WRAPPERS=new Set(['SPAN','FONT','B','STRONG','I','EM','U','S','STRIKE','DEL','INS','CODE','SUB','SUP','MARK','SMALL','BIG','TT','A']); const FORMAT_ATTRS=['style','class','align','color','face','size','bgcolor']; const blockOf=n=>{let e=n&&n.nodeType===1?n:n&&n.parentNode;while(e&&e!==editor&&!BLOCKS.has(e.tagName))e=e.parentNode;return e&&e!==editor?e:null;}; const unwrap=el=>{const p=el.parentNode;if(!p)return;while(el.firstChild)p.insertBefore(el.firstChild,el);p.removeChild(el);}; const containsNode=(r,node)=>{const nr=document.createRange();nr.selectNode(node);return r.compareBoundaryPoints(Range.START_TO_START,nr)<=0&&r.compareBoundaryPoints(Range.END_TO_END,nr)>=0;}; // 경계(container/offset)를 감싸는 인라인 래퍼 사슬을 그 지점에서 둘로 나눕니다. 선택 밖 부분은 복제한 껍데기에 담아 옆에 둬 서식을 유지합니다. const splitEdge=(r,toStart)=>{const container=toStart?r.startContainer:r.endContainer,offset=toStart?r.startOffset:r.endOffset;let el=container.nodeType===1?container:container.parentNode;let top=null;while(el&&el!==editor&&INLINE_WRAPPERS.has(el.tagName)){top=el;el=el.parentNode;} if(!top)return;const cut=document.createRange();if(toStart){cut.setStart(top,0);cut.setEnd(container,offset);}else{cut.setStart(container,offset);cut.setEnd(top,top.childNodes.length);} if(cut.collapsed)return;const frag=cut.extractContents();if(!frag.textContent&&!frag.querySelector('img,br'))return;const clone=top.cloneNode(false);clone.appendChild(frag);top.parentNode.insertBefore(clone,toStart?top:top.nextSibling);}; const clearFormatting=()=>{restore();const sel=window.getSelection();if(!sel.rangeCount)return;let r=sel.getRangeAt(0); if(r.collapsed){const b=blockOf(r.startContainer);if(!b)return;r=document.createRange();r.selectNodeContents(b);sel.removeAllRanges();sel.addRange(r);} document.execCommand('styleWithCSS',false,true);document.execCommand('removeFormat');document.execCommand('unlink'); const work=sel.getRangeAt(0).cloneRange(); // Range 는 DOM 이 바뀌면 경계를 따라 움직이므로 아래 수정 중에도 선택 범위를 가리킵니다. splitEdge(work,true);splitEdge(work,false); const walker=document.createTreeWalker(editor,NodeFilter.SHOW_ELEMENT);const nodes=[];let n;while((n=walker.nextNode()))if(work.intersectsNode(n))nodes.push(n); for(const el of nodes){if(!el.isConnected||['IMG','BR','HR'].includes(el.tagName))continue;const inline=INLINE_WRAPPERS.has(el.tagName);if(inline&&!containsNode(work,el))continue; for(const a of FORMAT_ATTRS)el.removeAttribute(a); if(/^H[1-6]$/.test(el.tagName)||el.tagName==='PRE'){const p=document.createElement('p');while(el.firstChild)p.appendChild(el.firstChild);el.replaceWith(p);} else if(el.tagName==='BLOCKQUOTE'||inline)unwrap(el);} sel.removeAllRanges();sel.addRange(work);remember();refreshState(); $$('[data-format]',form).forEach(el=>{el.selectedIndex=el.dataset.format==='fontSize'?1:0;}); editor.dispatchEvent(new Event('input',{bubbles:true}));}; // 자동 저장이 변경을 알아채게 합니다(직접 DOM 을 고쳐 input 이벤트가 나지 않음). $('[data-clear]',form).onmousedown=e=>e.preventDefault();$('[data-clear]',form).onclick=clearFormatting; // 색상 팔레트: 기본 8색을 먼저 보여 주고, "더 많은 색상…" 을 누르면 브라우저의 색상 선택창(RGB 입력·스포이드)으로 이어집니다. // 팝업 안의 버튼은 mousedown 을 막아 편집기의 선택 영역이 유지되게 합니다. 선택창은 포커스를 가져가므로 기억한 범위로 복원해 적용합니다. let pop=null;const closePalette=()=>{if(pop){pop.remove();pop=null;}}; $$('[data-color]',form).forEach(btn=>{const cmd=btn.dataset.color;btn.onmousedown=e=>e.preventDefault();btn.onclick=()=>{if(pop&&pop.dataset.cmd===cmd){closePalette();return;}closePalette();remember(); pop=document.createElement('div');pop.className='color-pop';pop.dataset.cmd=cmd;pop.setAttribute('role','dialog'); pop.innerHTML=`
    ${PALETTE[cmd].map(([c,k])=>``).join('')}
    ${cmd==='hiliteColor'?``:''}
    `; btn.parentElement.appendChild(pop); const apply=c=>{command(cmd,c);btn.querySelector('.swatch').style.background=c==='transparent'?'#fff':c;}; $$('[data-pick]',pop).forEach(p=>{p.onmousedown=e=>e.preventDefault();p.onclick=()=>{apply(p.dataset.pick);closePalette();};}); const input=$('input[type=color]',pop);$('[data-more]',pop).onmousedown=e=>e.preventDefault();$('[data-more]',pop).onclick=()=>{remember();input.click();}; input.oninput=()=>apply(input.value);input.onchange=()=>{apply(input.value);closePalette();};};}); document.addEventListener('mousedown',e=>{if(pop&&!e.target.closest('.color-tool'))closePalette();}); $('[data-link]',form).onmousedown=e=>e.preventDefault();$('[data-link]',form).onclick=()=>{const url=prompt(tr('linkUrl'),'https://');if(!url)return;try{if(!['https:','http:','mailto:'].includes(new URL(url).protocol))throw Error();restore();if(window.getSelection().isCollapsed){const label=prompt(tr('linkText'),url)||url;document.execCommand('insertHTML',false,`${esc(label)}`);}else command('createLink',url);}catch{notify('invalidUrl');}}; $('[data-quote]',form).onmousedown=e=>e.preventDefault();$('[data-quote]',form).onclick=()=>command('formatBlock','blockquote'); $('[data-image]',form).onmousedown=e=>e.preventDefault();$('[data-image]',form).onclick=()=>{const input=document.createElement('input');input.type='file';input.accept='image/png,image/jpeg,image/gif,image/webp';input.multiple=true;input.onchange=async()=>{for(const f of input.files){try{const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`
    `);remember();}}catch(e){fail(e);}}};input.click();}; $('[data-html]',form).onclick=()=>{const src=$('.html-source',form);if(src.hidden){src.value=editor.innerHTML;src.hidden=false;editor.hidden=true;$('[data-html]',form).setAttribute('aria-pressed','true');notify('htmlMode');}else{editor.innerHTML=src.value;src.hidden=true;editor.hidden=false;$('[data-html]',form).setAttribute('aria-pressed','false');}}; editor.onpaste=async e=>{e.preventDefault();remember();try{const files=[...e.clipboardData.files].filter(f=>/^image\//.test(f.type));if(files.length){for(const f of files){const url=await onImage(f);if(url){restore();document.execCommand('insertHTML',false,`
    `);}}return;}const html=e.clipboardData.getData('text/html');if(html){const clean=await api('mail/sanitize','POST',{html});restore();document.execCommand('insertHTML',false,clean.html);}else{restore();document.execCommand('insertText',false,e.clipboardData.getData('text/plain'));}}catch{notify('error');}}; // ---- 이미지 크기: 삽입 시 편집기 폭에 맞추고, 클릭하면 크기 도구와 모서리 손잡이를 보여 줍니다. const editorWidth=()=>Math.max(120,editor.clientWidth-34); // silent: 창을 열 때 하는 첫 맞춤에서는 input 이벤트를 내지 않습니다. 예전에는 이 맞춤이 "편집" 으로 잡혀, 인용문에 이미지가 있는 답장·전달 창을 // 열기만 해도 4초 뒤 자동 저장이 초안을 만들었습니다(아무것도 쓰지 않았는데 임시 보관함에 "Re: …" 가 남음). const setWidth=(img,px,silent=false)=>{px=Math.round(Math.min(Math.max(40,px),editorWidth()));img.style.width=px+'px';img.style.height='auto';img.style.maxWidth='100%';img.setAttribute('width',String(px));img.removeAttribute('height');if(!silent)editor.dispatchEvent(new Event('input',{bubbles:true}));place();}; // 기본 크기: 글쓰기 창의 가로/세로 중 작은 쪽의 80% 상자 안에 가로·세로가 모두 들어가게 줄입니다(확대는 하지 않음). const fitBox=()=>{const visible=Math.max(200,form.clientHeight-160);return Math.round(Math.min(editorWidth(),visible)*0.8);}; const fit=(img,silent=false)=>{const box=fitBox(),nw=img.naturalWidth||0,nh=img.naturalHeight||0;if(!nw||!nh){img.style.maxWidth='100%';return;}const scale=Math.min(1,box/nw,box/nh); // 줄일 필요가 없는(상자보다 작은) 그림은 크기를 건드리지 않습니다(0.9.9). 예전에는 setWidth 의 최소 40px 때문에 인용문의 1×1 추적 그림·간격용 그림이 40px 로 커진 채 저장·발송됐습니다. if(scale>=1){img.style.maxWidth='100%';return;}setWidth(img,Math.max(40,Math.round(nw*scale)),silent);}; const whenLoaded=(img,fn)=>{const run=()=>{if(img.naturalWidth)fn(img);};if(img.complete&&img.naturalWidth){run();return;}(img.decode?img.decode():Promise.reject()).then(run,()=>{img.addEventListener('load',run,{once:true});if(img.complete)run();});}; const sizer=document.createElement('div');sizer.className='img-sizer';sizer.hidden=true;sizer.innerHTML=`${text('imgSize')}${[25,50,75,100].map(p=>``).join('')}`; const handle=document.createElement('div');handle.className='img-handle';handle.hidden=true;form.append(sizer,handle);let selected=null; const place=()=>{if(!selected||!selected.isConnected){hideSizer();return;}const r=selected.getBoundingClientRect(),f=form.getBoundingClientRect();sizer.style.left=Math.max(0,r.left-f.left)+'px';sizer.style.top=(r.bottom-f.top+form.scrollTop+6)+'px';handle.style.left=(r.right-f.left-7)+'px';handle.style.top=(r.bottom-f.top+form.scrollTop-7)+'px';$('[data-px]',sizer).value=Math.round(r.width);}; const hideSizer=()=>{if(selected)selected.classList.remove('selected');selected=null;sizer.hidden=true;handle.hidden=true;}; const selectImage=img=>{hideSizer();selected=img;img.classList.add('selected');sizer.hidden=false;handle.hidden=false;place();}; editor.addEventListener('click',e=>{if(e.target.tagName==='IMG')selectImage(e.target);else hideSizer();}); form.addEventListener('scroll',()=>{if(selected)place();});window.addEventListener('resize',()=>{if(selected)place();}); $$('[data-pct]',sizer).forEach(b=>b.onclick=()=>{if(selected)setWidth(selected,editorWidth()*Number(b.dataset.pct)/100);}); $('[data-fit]',sizer).onclick=()=>{if(selected)setWidth(selected,editorWidth());};$('[data-orig]',sizer).onclick=()=>{if(selected)setWidth(selected,selected.naturalWidth||editorWidth());}; $('[data-px]',sizer).onchange=e=>{if(selected)setWidth(selected,Number(e.target.value));}; handle.addEventListener('mousedown',e=>{if(!selected)return;e.preventDefault();const startX=e.clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.clientX-startX));const up=()=>{document.removeEventListener('mousemove',mv);document.removeEventListener('mouseup',up);};document.addEventListener('mousemove',mv);document.addEventListener('mouseup',up);}); handle.addEventListener('touchstart',e=>{if(!selected)return;const startX=e.touches[0].clientX,startW=selected.getBoundingClientRect().width;const mv=ev=>setWidth(selected,startW+(ev.touches[0].clientX-startX));const up=()=>{document.removeEventListener('touchmove',mv);document.removeEventListener('touchend',up);};document.addEventListener('touchmove',mv,{passive:true});document.addEventListener('touchend',up);},{passive:true}); // 다시 연 초안: 사용자가 정한 크기(width)가 없는 이미지만 맞춥니다. editor.querySelectorAll('img').forEach(img=>{if(!img.getAttribute('width')&&!img.style.width)whenLoaded(img,i=>fit(i,true));else img.style.maxWidth='100%';}); const insertImage=url=>{restore();document.execCommand('insertHTML',false,`
    `);remember();const img=[...editor.querySelectorAll('img')].reverse().find(i=>i.getAttribute('src')===url||i.src===new URL(url,location.href).href);if(img)whenLoaded(img,fit);}; return {html:()=>{hideSizer();editor.querySelectorAll('img.selected').forEach(i=>i.classList.remove('selected'));return $('.html-source',form).hidden?editor.innerHTML:$('.html-source',form).value;},editor,insertImage}; } // ------------------------------------------------------------------ 작성 // 편집기에는 previewHtml(cid: 그림을 /api/mail//attachments/… 주소로 바꾼 본문)을 넣습니다. IMAP 으로 올린 초안의 그림이 예전에는 cid: 그대로라 깨져 보였습니다(0.8.16). function openCompose(m){ const locked=m.state&&m.state!=='draft';let id=m.id||null;let files=m.attachments||[];let dirty=false,saving=null,timer=null;let closed=false; // rev: 편집할 때마다 1씩 늘어나는 번호. 저장이 끝났을 때 저장을 시작한 시점의 번호와 같을 때만 dirty 를 끕니다. // 예전에는 저장 요청 중에 입력한 내용까지 "저장됨"으로 처리해 그 편집이 사라질 수 있었습니다. let rev=0; // autoCreated: 사용자가 아무것도 하지 않았는데 코드가 만든 초안(전달 창의 첨부 복사용). touched: 사용자가 첨부·대용량 파일을 올렸음. // 전달 창을 열고 그대로 닫으면 rev(편집 횟수)가 0 이고 touched 도 아니므로 자동 생성 초안을 지웁니다(0.9.9). 예전에는 "Fwd: …" 초안이 임시 보관함에 남았습니다. let autoCreated=false,touched=false; const dlg=$('#compose');const title=m.title?tr(m.title):id?tr('editDraft'):tr('composeTitle');const showCc=!!(m.cc_json&&JSON.parse(m.cc_json).length),showBcc=!!(m.bcc_json&&JSON.parse(m.bcc_json).length); $('#compose-body').innerHTML=`

    ${esc(title)}

    ${text('fromLabel')}
    ${text('to')}
    ${text('cc')}
    ${text('bcc')}
    ${text('mailSubject')}
    ${toolbar()}
    ${m.previewHtml||m.html||'


    '}
    ${locked?'':`${S.largeFiles&&S.largeFiles.enabled?``:''}`}
    `; const form=$('#compose-form'); // 첨부·본문 이미지를 올리려면 초안 id 가 필요합니다. 직접 POST 하지 않고 save 를 거쳐, 자동 저장과 동시에 초안이 두 개 생기지 않게 합니다. const ensureDraft=async()=>{if(id)return id;return save(true);}; const upload=async(file,inline)=>{touched=true;await ensureDraft();const data=new FormData();data.append('files',file);const d=await api('mail/'+id+'/attachments?'+(inline?'inline=1':''),'POST',data);files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();return inline?d.urls[0]:null;}; const ed=bindEditor(form,f=>upload(f,true)); const values=()=>({to:form.elements.to.value,cc:form.elements.cc.value,bcc:form.elements.bcc.value,subject:form.elements.subject.value,html:ed.html(),...(m.inReplyTo?{inReplyTo:m.inReplyTo}:{})}); const paintFiles=()=>{$('#cp-files').innerHTML=files.filter(isFile).map(a=>`📎 ${esc(a.filename)} ${kb(a.size)}${locked?'':``}`).join('');$$('[data-remove]',$('#cp-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/attachments/'+b.dataset.remove,'DELETE');files=files.filter(a=>a.id!==b.dataset.remove);paintFiles();}catch(e){fail(e);}});};paintFiles(); // ---- 대용량 첨부파일(0.9.3): 메일에 붙이지 않고 서버에 두고 링크를 보냅니다. 올리면 칩에 "☁" 표시와 함께 보이고, 하나라도 있으면 발송 조건(보관 일수·다운로드 횟수) 줄이 나타납니다. let large=m.largeFiles||[]; const paintLarge=()=>{const live=large.filter(f=>f.state!=='expired');$('#cp-large-files').innerHTML=live.map(f=>`☁ ${esc(f.filename)} ${kb(f.size)}${locked?'':``}`).join('');$('#cp-terms').hidden=!live.length; $$('[data-remove-large]',$('#cp-large-files')).forEach(b=>b.onclick=async()=>{try{await api('mail/'+id+'/files/'+b.dataset.removeLarge,'DELETE');large=large.filter(f=>f.id!==b.dataset.removeLarge);paintLarge();}catch(e){fail(e);}});};paintLarge(); // 업로드는 XMLHttpRequest 로(진행률 표시). 본문이 파일 바이트 그대로이고 이름은 X-File-Name 헤더로 보냅니다. 서버 상한을 넘으면 413 과 limit 이 돌아옵니다. /** * 대용량 파일 업로드(0.9.5 에서 진행 표시 추가). 올리는 동안 칩 자리에 파일 이름·진행 막대·퍼센트·보낸 양/전체·취소(✕) 단추가 보이고, 끝나면 보통 칩으로 바뀝니다. * 오류는 원인을 나눠 알립니다: 서버가 JSON 으로 거부(코드 그대로), 프록시(nginx)가 413 으로 막음(upload_too_large_proxy), 그 밖의 HTTP 상태(upload_failed + 상태 코드), 네트워크 끊김(upload_network), 사용자가 취소(조용히). */ const uploadLarge=file=>new Promise(async(resolve,reject)=>{touched=true;try{await ensureDraft();}catch(e){reject(e);return;} const x=new XMLHttpRequest();const row=document.createElement('span');row.className='chip large uploading';row.innerHTML=`☁ ${esc(file.name)} ${kb(file.size)}0%`;$('#cp-large-files').appendChild(row); const bar=row.querySelector('progress'),pct=row.querySelector('.pct');let cancelled=false,sent=false; // 취소: 전송 중이면 abort 가 onabort 를 부르지만, 아직 send 전(초안 생성 중)이면 아무 이벤트도 나지 않으므로 직접 정리합니다. row.querySelector('button').onclick=()=>{cancelled=true;x.abort();if(!sent){done();reject(new Error('upload_cancelled'));}}; x.open('POST',BASE+'api/files?draft='+encodeURIComponent(id));x.setRequestHeader('X-File-Name',encodeURIComponent(file.name));x.setRequestHeader('Content-Type',file.type||'application/octet-stream'); x.upload.onprogress=e=>{if(!e.lengthComputable)return;const p=Math.round(e.loaded/e.total*100);bar.value=p;pct.textContent=p+'% · '+kb(e.loaded)+' / '+kb(e.total);$('#cp-status').textContent='☁ '+p+'%';}; x.upload.onload=()=>{pct.textContent=tr('uploadFinishing');}; const done=()=>{row.remove();$('#cp-status').textContent='';}; x.onload=()=>{done();let d=null;try{d=JSON.parse(x.responseText);}catch{}if(x.status>=200&&x.status<300&&d&&d.file){large.push(d.file);paintLarge();resolve(d.file);return;} const err=new Error(d&&d.error?d.error:x.status===413?'upload_too_large_proxy':'upload_failed');if(d&&d.limit!==undefined)err.limit=d.limit;if(!d||!d.error)err.detail='HTTP '+x.status;reject(err);}; x.onerror=()=>{done();reject(new Error('upload_network'));};x.onabort=()=>{done();reject(new Error(cancelled?'upload_cancelled':'upload_network'));};sent=true;x.send(file);}); if($('#cp-large')){$('#cp-large').onclick=()=>$('#cp-large-input').click();$('#cp-large-input').onchange=async e=>{$('#cp-large').disabled=true;try{for(const f of e.target.files)await uploadLarge(f);notify('largeAttached');}catch(err){if(err.message!=='upload_cancelled')fail(err);}finally{$('#cp-large').disabled=false;$('#cp-status').textContent='';e.target.value='';}};} // 발송 조건: 보관 일수·다운로드 횟수(둘 다 필수, 상한 이내). 서버도 같은 검사를 합니다. const largeTerms=()=>{if(!large.some(f=>f.state!=='expired'))return undefined;const t=$('#cp-terms');return {days:Number(t.querySelector('[name=largeDays]').value),downloads:Number(t.querySelector('[name=largeDownloads]').value)};}; // 저장은 한 번에 하나씩. 진행 중인 저장이 있으면 끝나기를 기다린 뒤 지금 내용으로 한 번 더 저장합니다 // (예전에는 진행 중인 저장을 그대로 돌려줘, 발송이 직전 편집이 빠진 본문으로 나갈 수 있었습니다). const save=async(quiet)=>{if(locked)return id;if(saving){await saving.catch(()=>{});return save(quiet);}const startRev=rev;saving=(async()=>{try{if(id)await api('mail/'+id,'PATCH',values());else id=(await api('mail','POST',values())).id;if(rev===startRev)dirty=false;$('#cp-status').textContent=fill('draftAutosaved',{time:when(new Date().toISOString())});if(!quiet)notify('draftSaved');}finally{saving=null;}return id;})();return saving;}; const markDirty=()=>{dirty=true;rev++;clearTimeout(timer);timer=setTimeout(()=>{if(dirty&&!closed)save(true).catch(()=>{});},4000);}; form.oninput=markDirty;ed.editor.addEventListener('input',markDirty); // 참조·숨은 참조(0.9.5): 받는 사람 줄의 "참조" / "숨은 참조" 를 누르면 각각의 입력 줄이 열리고 단추는 사라집니다(예전에는 참조 줄만 열리고 숨은 참조는 입력할 수 없었음). const openRow=(btn,row)=>{$(btn).onclick=()=>{$(row).hidden=false;$(btn).hidden=true;$(row+' input').focus();};};openRow('#cp-cc-toggle','#cp-cc-row');openRow('#cp-bcc-toggle','#cp-bcc-row'); if($('#cp-save'))$('#cp-save').onclick=()=>save(false).catch(fail); const askImageMode=n=>new Promise(res=>{const box=$('#cp-choice');box.hidden=false;$('#cp-choice-text').textContent=fill('imageChoiceTitle',{count:n});const done=v=>{box.hidden=true;res(v);};$('#cp-choice-inline').onclick=()=>done('inline');$('#cp-choice-attach').onclick=()=>done('attach');$('#cp-choice-cancel').onclick=()=>done(null);box.scrollIntoView({block:'nearest'});}); const handleFiles=async(list,{askImages=true}={})=>{list=[...list];if(!list.length||locked)return; // 일반 첨부 상한(MIME 기준)을 넘는 파일은 대용량 첨부가 켜져 있으면 그쪽으로 올립니다(0.9.3). 꺼져 있으면 서버가 attachment_too_large 로 알립니다. const over=S.largeFiles&&S.largeFiles.enabled&&S.attachmentBytes>0?list.filter(f=>Math.ceil(f.size/3)*4>S.attachmentBytes):[];if(over.length){list=list.filter(f=>!over.includes(f));try{for(const f of over)await uploadLarge(f);notify('largeAutoRouted');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}if(!list.length)return;} const images=list.filter(f=>/^image\//.test(f.type)),others=list.filter(f=>!/^image\//.test(f.type));let mode='attach';if(images.length&&askImages){mode=await askImageMode(images.length);if(!mode)return;}$('#cp-status').textContent='⏳';try{for(const f of others)await upload(f,false);for(const f of images){if(mode==='inline'){const url=await upload(f,true);ed.insertImage(url);}else await upload(f,false);}notify(images.length&&mode==='inline'?'inlineAdded':'attached');}catch(e){fail(e);}finally{$('#cp-status').textContent='';}}; if($('#cp-attach')){$('#cp-attach').onclick=()=>$('#cp-file-input').click();$('#cp-file-input').onchange=async e=>{$('#cp-attach').disabled=true;try{await handleFiles(e.target.files,{askImages:false});}finally{$('#cp-attach').disabled=false;e.target.value='';}};} let dragDepth=0;const overlay=$('#cp-drop');const hasFiles=e=>[...(e.dataTransfer?.types||[])].includes('Files'); form.addEventListener('dragenter',e=>{if(!hasFiles(e)||locked)return;e.preventDefault();dragDepth++;overlay.hidden=false;});form.addEventListener('dragover',e=>{if(hasFiles(e)){e.preventDefault();e.dataTransfer.dropEffect='copy';}});form.addEventListener('dragleave',()=>{dragDepth=Math.max(0,dragDepth-1);if(!dragDepth)overlay.hidden=true;});form.addEventListener('drop',e=>{if(!hasFiles(e))return;e.preventDefault();dragDepth=0;overlay.hidden=true;handleFiles(e.dataTransfer.files);}); const close=()=>{closed=true;clearTimeout(timer);dlg.close();renderMail();}; // 닫기 전에 저장합니다. 저장에 실패하면 창을 닫지 않고 물어봅니다(예전에는 오류만 보이고 닫혀 작성한 내용이 사라졌습니다). $('#cp-close').onclick=async()=>{if(dirty&&!locked){try{await save(true);}catch(e){fail(e);if(!confirm(tr('confirmCloseUnsaved')))return;}} // 손대지 않은 자동 생성 초안은 휴지통을 거쳐 영구 삭제합니다(서버는 휴지통의 메일만 DELETE 를 받음). 실패해도 창은 닫습니다(초안이 남는 것뿐). if(id&&autoCreated&&rev===0&&!touched){try{await api('mail/'+id,'PATCH',{folder:'trash'});await api('mail/'+id,'DELETE');}catch{}} close();}; $('#cp-discard').onclick=async()=>{if(!confirm(tr('confirmDiscard')))return;try{if(id)await api('mail/'+id,'PATCH',{folder:'trash'});}catch(e){fail(e);}closed=true;clearTimeout(timer);dlg.close();renderMail();}; $('#cp-send').onclick=async()=>{const b=$('#cp-send');b.disabled=true;$('#cp-status').textContent=tr('sending');try{if(!locked)await save(true);await api('mail/'+id+'/send','POST',{largeFiles:largeTerms()});closed=true;clearTimeout(timer);dlg.close();S.folder='sent';S.page=0;S.current=null;await renderMail();notify('mailSent');}catch(e){fail(e);const msg=tr(A[e.message]?e.message:'error')+(e.detail?' ('+e.detail+')':'');$('#cp-status').textContent=msg; // 발송 실패로 잠긴 메일은 "다시 보내기" 창으로 다시 그립니다. 오류 문구(statusText)를 함께 넘겨 새 창의 상태 줄에도 남깁니다(0.9.9). 예전에는 다시 그리면서 비어 6초짜리 알림만 남았습니다. if(id){try{const cur=(await api('mail/'+id)).message;if(cur.state!=='draft'){openCompose({...cur,statusText:msg});return;}}catch{}}}finally{b.disabled=false;}}; dlg.oncancel=e=>{e.preventDefault();$('#cp-close').click();}; if(!dlg.open)dlg.showModal();if(!locked)(form.elements.to.value?ed.editor:form.elements.to).focus(); if(m.statusText)$('#cp-status').textContent=m.statusText; // 전달 첨부 복사: 초안을 먼저 만들고(ensureDraft) 원본의 첨부 id 를 서버에 넘겨 복사한 뒤 칩 목록을 다시 그립니다. if(m.forwardFrom&&m.forwardFrom.ids.length)(async()=>{try{if(!id)autoCreated=true;await ensureDraft();await api('mail/'+id+'/attachments','POST',{from:m.forwardFrom.id,ids:m.forwardFrom.ids});files=(await api('mail/'+id+'/attachments')).attachments;paintFiles();}catch(e){fail(e);}})(); } // ------------------------------------------------------------------ 설정 /** * 웹 메일 "도구 → 설정". 0.9.0 에서 더한 것: 비밀번호 변경 카드(게이트웨이 계정만, 현재 비밀번호 + 새 비밀번호 2회 + 눈 단추), * 메일함 사용량·적용 중인 제한(첨부 크기·받는 사람 수) 카드, 관리자가 POP/IMAP 서비스 자체를 껐을 때의 안내. */ async function settingsPage(){ viewVersion++;showPanes(true);const s=await api('settings');const kv=rows=>`
    ${rows.map(([k,v])=>`
    ${k}
    ${v}
    `).join('')}
    `; // 메일 전달·자동 응답(0.8.16): 서버에 저장된 설정(prefs.forward / prefs.autoReply)으로 양식을 채웁니다. // 시각은 서버에 ISO(UTC)로 저장돼 있어 datetime-local 입력 상자에는 브라우저 지역 시각(YYYY-MM-DDTHH:MM)으로 바꿔 보여 줍니다. const fw=S.prefs.forward||{enabled:false,to:''},ar=S.prefs.autoReply||{enabled:false,noLimit:true,start:'',end:'',subject:'',message:''}; const localInput=iso=>{if(!iso)return '';const d=new Date(iso);if(isNaN(d))return '';const p=x=>String(x).padStart(2,'0');return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;}; const acct=`${kv([[text('popUser'),`${esc(S.me.source==='www'?'admin':S.me.login)} / ${esc(S.mailbox)}`],[text('password'),text('popPasswordValue')]])}`; const svc=s.services||{pop:true,imap:true},q=s.quota||{usageBytes:0,limit:0},lim=s.limits||{},policy=s.policy||{minLength:6}; const pct=q.limit>0?Math.min(100,Math.round(q.usageBytes/q.limit*100)):null; $('#page-pane').innerHTML=`

    ${text('settings')}

    ${text('settingsIntro')}

    ${text('passwordSection')}

    ${s.canChangePassword?`

    ${text('passwordIntro')}

    ${pwField('currentPassword',text('userCurrentPassword'),'autocomplete="current-password" required')}${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')}

    ${esc(passwordRule(policy))} ${text('passwordChangeHelp')}

    `:`

    ${text(s.managedBy==='webmaker'?'passwordWebmakerManaged':'passwordWwwManaged')}

    `}

    ${text('quotaSection')}

    ${kv([[text('usageLabel'),`${kb(q.usageBytes)}${q.limit>0?` / ${kb(q.limit)} ${esc(fill('quotaPercent',{percent:pct}))}`:` · ${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}))]]:[])])}

    ${text('mimeNote')}

    ${text('prefsSection')}

    ${text('prefRemoteImagesHelp')}

    ${text('servicesSection')}

    ${text('servicesIntro')}

    ${text('servicesHelp')}${svc.pop===false||svc.imap===false?' '+text('serviceOffByAdminHelp'):''}

    ${text('forwardSection')}

    ${text('forwardIntro')}

    ${text('forwardOnNote')}

    ${text('forwardHelp')}

    ${text('autoReplySection')}

    ${text('autoReplyIntro')}

    ${text('autoReplyOnNote')}
    ${text('autoReplyPeriod')}

    ${text('autoReplyPeriodHelp')}

    ${text('autoReplyHelp')}

    ${text('imapSettings')}

    ${text('imapIntro')}

    ${kv([[text('imapHost'),`${esc(s.imap.host)}`],[text('imapPortTls'),`${s.imap.ports.imaps} ${text(s.imap.tls?'imapTlsActive':'imapTlsMissing')}`],[text('imapPortPlain'),`${s.imap.ports.starttls} ${text(s.imap.plaintext?'popPlainAllowed':'popPlainRequired')}`]])}${acct}${kv([[text('imapMailboxes'),text('imapMailboxesValue')]])}

    ${text('imapSteps')}

      ${[1,2,3,4,5].map(n=>`
    1. ${text('imapStep'+n)}
    2. `).join('')}

    ${text('imapNotes')}

      ${[1,2,3].map(n=>`
    • ${text('imapNote'+n)}
    • `).join('')}

    ${text('smtpSettings')}

    ${text('smtpIntro')}

    ${kv([[text('smtpHost'),`${esc(s.smtp.host)}`],[text('smtpPortSmtps'),`${s.smtp.ports.smtps} ${text(s.smtp.tls?'imapTlsActive':'smtpTlsMissing')}`],[text('smtpPortStarttls'),`${s.smtp.ports.starttls}`]])}${acct}${kv([[text('smtpFrom'),`${esc(s.smtp.from)}`],[text('smtpAuth'),text('smtpAuthValue')]])}

    ${text('smtpSteps')}

      ${[1,2,3,4,5].map(n=>`
    1. ${text('smtpStep'+n)}
    2. `).join('')}

    ${text('smtpNotes')}

      ${[1,2,3].map(n=>`
    • ${text('smtpNote'+n)}
    • `).join('')}

    ${text('popSettings')}

    ${text('popIntro')}

    ${kv([[text('popHost'),`${esc(s.pop.host)}`],[text('popPortTls'),`${s.pop.ports.pop3s} ${text(s.pop.tls?'popTlsActive':'popTlsMissing')}`],[text('popPortPlain'),`${s.pop.ports.starttls} ${text(s.pop.plaintext?'popPlainAllowed':'popPlainRequired')}`]])}${acct}

    ${text('popDedupe')}

    ${text('popNoteGmail')}

    `; // 비밀번호 변경(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=>`
    ${rows.map(([k,v])=>`
    ${k}
    ${v}
    `).join('')}
    `; const runs=(s.sync?.runs||[]).map(r=>`${esc(fullDate(r.started_at))}${esc(r.trigger)}${text('status_'+r.status)}${r.imported}${esc(r.detail||'')}`).join(''); const events=(s.webhook?.events||[]).map(e=>`${text('status_'+e.status)}${e.n}${esc(fullDate(e.last))}`).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 `${text(l)}
    ${esc(fullDate(p.start))} ~ ${esc(fullDate(p.end))}
    ${p.sent}${p.received}${p.total}`;}).join(''); const pctTag=p=>p===null||p===undefined?'':` ${p}%`; const boxes=(st.mailboxes||[]).map(m=>`${esc(m.mailbox)}${m.login?`
    ${esc(m.login)}${m.name?' · '+esc(m.name):''}
    `:''}${m.messages}${kb(m.bytes)}${m.limit>0?kb(m.limit)+pctTag(m.percent):text('quotaUnlimited')}`).join(''); const svc=s.services||{pop:true,imap:true};const onoff=v=>`${text(v?'serviceOn':'serviceOff')}`; $('#page-pane').innerHTML=`

    ${text('nav_status')}

    ${esc(fill('statusIntro',{host:s.host,version:s.version||'-'}))}

    ${text('statsSection')}

    ${esc(fill('statsIntro',{tz:st.timezone||''}))}

    ${periodRows}
    ${text('statPeriod')}${text('statSent')}${text('statReceived')}${text('statTotal')}

    ${text('statLimits')}

    ${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)}))]])}

    ${text('mimeNote')}

    ${text('statMailboxes')}

    ${boxes?`${boxes}
    ${text('userMailbox')}${text('statMessages')}${text('usageLabel')}${text('quotaLimit')}
    `:`

    ${text('empty')}

    `}
    ${s.mail?`

    ${text('statusMailSection')}

    ${text('mailConfigIntro')}

    ${kv([[text('statusInbound'),text('mode_'+s.mail.inbound)],[text('statusOutbound'),text('mode_'+s.mail.outbound)],[text('statusOutboundState'),`${s.mail.status&&s.mail.status.provider==='ok'?text('serviceOn'):esc(tr((s.mail.status||{}).provider||'mail_provider_unavailable'))}${s.mail.status&&s.mail.status.relay?` ${esc(s.mail.status.relay.host)}:${esc(s.mail.status.relay.port)}`:''}${s.mail.status&&s.mail.status.provider!=='ok'&&s.mail.status.detail?`
    ${esc(s.mail.status.detail)}
    `:''}`],[text('statusHaraka'),(()=>{const h=s.mail.haraka;if(!h)return `${text('harakaOff')}`;const st=h.status||{};const on=h.installed&&h.status&&!h.stale&&st.running;return `${text(!h.installed?'harakaNotInstalled':!h.status||h.stale?'harakaStopped':st.running?'harakaRunning':'harakaWaiting')}${on?` ${esc((st.listen||[]).join(', '))}`:''}`;})()],[text('statusConfigFile'),`${esc(s.mail.configFile||'')}`]])}
    `:''}

    ${text('statusServices')}

    ${kv([[text('statusTls'),`${text(s.tls?'imapTlsActive':'statusTlsMissing')}`],['IMAP',`${s.ports.imaps} / ${s.ports.imap} ${onoff(svc.imap!==false)}`],['POP3',`${s.ports.pop3s} / ${s.ports.pop3} ${onoff(svc.pop!==false)}`],[text('statusPlainLogin'),`${text(s.plainLogin?'popPlainAllowed':'popPlainRequired')}`],['SMTP',`${s.ports.smtps} / ${s.ports.submission}`],[text('popDeliveries'),esc(fill('popDeliveriesValue',{count:s.pop.deliveries.n||0,accounts:s.pop.deliveries.accounts||0,last:fullDate(s.pop.deliveries.last)||'-'}))]])}

    ${text('receiveSettings')}

    ${text('receiveIntro')}

    ${kv([[text('webhookUrl'),`${esc(s.webhook.url)}`],[text('webhookEvent'),'email.received'],[text('webhookSecret'),`${text(s.webhook.secretConfigured?'webhookSecretOk':'webhookSecretMissing')}`],[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}))]])}

    ${text('webhookEvents')}

    ${events?`${events}
    `:`

    ${text('empty')}

    `}

    ${text('syncRuns')}

    ${runs?`${runs}
    `:`

    ${text('empty')}

    `}
    `; $('#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=>`${x.pop!==false?'POP':'POP ✕'} ${x.imap!==false?'IMAP':'IMAP ✕'}`; const usage=u=>`${kb(u.usageBytes||0)}${u.limit>0?` / ${kb(u.limit)}`:''}`; const status=u=>u.status==='pending'?`${text('status_pending')}`: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)=>`${esc(u.login)}${esc(u.name)}${esc(u.mailbox)}${text('role_'+u.role)}${text('webmakerAccount')}${u.status!=='active'?' '+text('status_suspended'):''}${svc(u.services||{})}${usage(u)}${esc(tr('kind_'+u.kind))}`).join(''); const extNote=extSrc?(extSrc.ok?esc(fill('adminUsersIntroWebmaker',{n:ext.length,site:extSrc.site?extSrc.site.title||extSrc.site.id:tr('webmakerNoSite')})):`${text('webmakerReadFailed')} ${esc(extSrc.error||'')}`):''; $('#page-pane').innerHTML=`

    ${text('adminUsers')}

    ${esc(fill('adminUsersIntro',{domain:d.domain}))}${d.me?' '+esc(text('adminUsersIntroWww')):''}${extNote?' '+extNote:''}

    ${S.initialPassword?`
    ⚠ ${text('initialPasswordWarn')}
    `:''}
    ${esc(usageLine(accounts,countUnit))}
    ${d.me?`${svc(d.me.services||{})}${usage(d.me)}`:''}${extRows}${d.users.map((u,i)=>`${svc(u.services||{})}${usage(u)}`).join('')}
    ${text('userLogin')}${text('userName')}${text('userMailbox')}${text('userRole')}${text('userStatus')}${text('userServices')}${text('usageLabel')}${text('userLastLogin')}
    admin${esc(d.me.name||d.me.login)}${esc(d.defaultMailbox)}${text('role_admin')}${text('wwwAdminNote')}
    ${esc(u.login)}${esc(u.name)}${esc(u.mailbox)}${text('role_'+u.role)}${status(u)}${esc(fullDate(u.last_login_at))}

    ${esc(fill('userHelpMailbox',{domain:d.domain}))}

    ${configCards(cfg)}${policyCard(d.policy||{})}`; const open=u=>userDialog(u,d.domain,d.policy,d.system||cfg.system||{}); $('#add-user').onclick=()=>open(null);$$('[data-row]',$('#page-pane')).forEach(r=>{r.onclick=()=>open(r.dataset.row==='me'?{me:true,...d.me}:r.dataset.row.startsWith('ext:')?{external:true,...ext[Number(r.dataset.row.slice(4))]}:d.users[Number(r.dataset.row)]);r.onkeydown=e=>{if(e.key==='Enter')r.click();};}); // 비밀번호 정책 저장(0.8.24): 최소 길이(6~64)와 대·소문자/숫자/특수문자 요구를 PATCH 하고, 결과로 화면의 정책 안내를 갱신합니다. const pf=$('#policy-form');pf.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',pf);b.disabled=true;try{const r=await api('admin/policy','PATCH',{minLength:Number(pf.minLength.value),mixedCase:pf.mixedCase.checked,digit:pf.digit.checked,special:pf.special.checked});d.policy=r.policy;$('#policy-now').textContent=passwordRule(r.policy);notify('policySaved');}catch(err){fail(err);}finally{b.disabled=false;}}; // 설정 카드(0.9.0): 카드마다 자기 부분만 PATCH admin/config 로 보냅니다. 제한 값은 MB 입력을 바이트로 바꿔 보내고, 시스템 제한을 넘으면 서버가 limit_exceeds_system 으로 거부합니다. const bindConfig=(sel,build)=>{const f=$(sel);if(!f)return;f.onsubmit=async e=>{e.preventDefault();const b=$('[type=submit]',f);b.disabled=true;try{cfg=await api('admin/config','PATCH',build(f.elements));notify('configSaved');}catch(err){fail(err);}finally{b.disabled=false;}};}; bindConfig('#signup-form-admin',f=>({signup:{enabled:f.enabled.checked,approval:f.approval.value==='approve'}})); bindConfig('#limits-form',f=>({limits:{mailboxBytes:toBytes(f.mailboxMb.value),attachmentBytes:toBytes(f.attachmentMb.value),recipients:Math.max(0,Math.round(Number(f.recipients.value)||0))}})); bindConfig('#services-form',f=>({services:{pop:f.pop.checked,imap:f.imap.checked,plainLogin:f.plainLogin.checked}})); bindConfig('#large-form',f=>({largeFiles:{enabled:f.enabled.checked,maxBytes:toBytes(f.maxMb.value),maxDays:Math.round(Number(f.maxDays.value)||0),maxDownloads:Math.round(Number(f.maxDownloads.value)||0)}})); } /** MB 입력을 바이트로(빈 값·0 = 제한 없음). 소수 입력(1.5)도 받습니다. */ const toBytes=v=>Math.max(0,Math.round((Number(v)||0)*1048576)); const fromBytes=b=>b>0?String(Math.round(b/1048576*100)/100):''; /** 시스템 제한 안내 한 줄: "시스템 제한: 500 MB 이내" 또는 "시스템 제한 없음". 관리자가 넣을 수 있는 최댓값을 알려 줍니다. */ const sysNote=(v,unit)=>v>0?fill('systemCap',{max:unit(v)}):tr('systemNoCap'); /** * 관리자 설정 카드(0.9.0). * - 회원 가입: 로그인 화면에 가입 단추를 보일지, 가입 계정을 즉시 사용 / 관리자 승인 후 사용 중 무엇으로 할지. * - 메일 제한: 계정별 기본 메일함 크기, 첨부 한 개 최대 크기(MIME 기준), 한 메일의 받는 사람 수. 각 값은 시스템 제한(설치 시 설정) 이내로만 저장됩니다. 0 = 시스템 값 그대로. * - 서비스: POP3 / IMAP 서비스 자체를 켜고 끕니다. 끄면 모든 계정의 메일 프로그램 접속이 거부됩니다. */ /** 대용량 첨부파일 카드(0.9.3): 사용 여부와 최대 크기(MB)·보관 기간(일)·다운로드 횟수. 모두 1 이상이어야 하고(무제한 불가) 시스템 상한 이내이며, 기본값은 시스템 값의 50% 입니다. */ function largeCard(c){const lf=c.largeFiles||{},sys=c.systemLargeFiles||{},u=c.largeFileUsage||{count:0,bytes:0}; return `

    ${text('largeSection')}

    ${text('largeAdminIntro')}

    ${esc(fill('systemCapStrict',{max:kb(sys.bytes||0)}))}

    ${esc(fill('systemCapStrict',{max:fill('daysUnit',{n:sys.days||0})}))}

    ${esc(fill('systemCapStrict',{max:fill('timesUnit',{n:sys.downloads||0})}))}

    ${text('largeAdminHelp')} ${esc(fill('largeUsage',{count:u.count,size:kb(u.bytes)}))}

    `;} function configCards(c){const sys=c.system||{},lim=c.limits||{},sg=c.signup||{},sv=c.services||{}; return `

    ${text('signupSection')}

    ${text('signupAdminIntro')}

    ${text('signupApprovalLegend')}

    ${text('signupAdminHelp')}

    ${text('limitsSection')}

    ${text('limitsIntro')}

    ${esc(sysNote(sys.mailboxBytes,kb))}

    ${esc(sysNote(sys.attachmentBytes,kb))}

    ${esc(sysNote(sys.recipients,countUnit))}

    ${text('limitsHelp')} ${text('mimeNote')}

    ${text('systemSection')}

    ${text('systemIntro')}

    ${[['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])=>`
    ${text(k)}
    ${esc(v)}
    `).join('')}
    ${largeCard(c)}

    ${text('servicesAdminSection')}

    ${text('servicesAdminIntro')}

    ${text('servicePlainLoginHelp')}

    ${text('servicesAdminHelp')}

    `;} /** * 비밀번호 정책(0.8.24~). * - passwordRule(p): 정책을 한 문장으로("비밀번호는 6자 이상, 숫자 포함이어야 합니다."). 계정 창의 안내문과 정책 카드, weak_password 오류 안내에 같이 씁니다. * - passwordError(err): 서버가 weak_password 와 함께 보낸 problems(어긋난 항목)로 어느 규칙에 걸렸는지 적습니다. * - policyCard(p): 관리자 페이지 아래의 정책 편집 카드. 최소 길이는 6 미만으로 내릴 수 없습니다(요구사항: 최소 6자 이상). */ function ruleParts(p,only){const parts=[];const want=k=>!only||only.includes(k);if(want('length'))parts.push(fill('passwordRuleLength',{n:p.minLength||6}));if(p.mixedCase&&want('mixedCase'))parts.push(tr('passwordRuleMixedCase'));if(p.digit&&want('digit'))parts.push(tr('passwordRuleDigit'));if(p.special&&want('special'))parts.push(tr('passwordRuleSpecial'));return parts;} function passwordRule(p){return fill('passwordRule',{rules:ruleParts(p||{}).join(', ')});} function passwordError(err){if(err.message!=='weak_password'||!err.policy)return errText(err);const parts=ruleParts(err.policy,err.problems||[]);return fill('weakPasswordDetail',{rules:parts.join(', ')||fill('passwordRuleLength',{n:err.policy.minLength})});} function policyCard(p){return `

    ${text('policySection')}

    ${text('policyIntro')}

    ${text('policyMinLengthHelp')}

    ${esc(passwordRule(p))}

    `;} /** * 계정 추가·수정 창. * - 메일함 주소는 입력받지 않고 "로그인 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=`
    ${text('userServices')}

    ${text('servicesHelp')}

    `; const quota=`

    ${esc(sysNote(system.mailboxBytes,kb))}${u.limit>0?' · '+esc(fill('quotaNow',{limit:kb(u.limit)})):''}

    `; $('#dialog-body').innerHTML=me?`

    ${text('wwwAdminServicesHelp')}

    ${services}
    ` :external?`

    ${esc(fill('webmakerAccountHelp',{login:u.login,mailbox:u.mailbox}))}

    ${quota}
    ${services}
    ` :`
    ${pending?`
    ${text('pendingNotice')}
    `:''}
    ${self?`

    ${text('selfPasswordNote')} ${text('selfRoleNote')}

    `:pwField('password',text(edit?'userPasswordEdit':'userPassword'),`autocomplete="new-password" ${edit?'':'required'} minlength="${Number(policy.minLength)||6}" title="${esc(passwordRule(policy))}"`)}${quota}

    ${text('userMailboxAuto')} ${self?'':esc(passwordRule(policy))}

    ${services}
    ${pending?``:''}${edit&&!pending?``:''}
    `; 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();