Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
535 lines
118 KiB
JavaScript
535 lines
118 KiB
JavaScript
'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=`<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"><${esc(addr(m.sender))}> → ${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'))}"></></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();
|