Last active
August 25, 2026 17:48
-
-
Save monperrus/31b116c72bbc0deec28a674db99055d7 to your computer and use it in GitHub Desktop.
Martin's Super Webapp tab: Email
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "1": { | |
| "tab": { | |
| "id": "jmap-email", | |
| "label": "Email", | |
| "html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>JMAP Email</title>\n<style>\n html, body { margin: 0; height: 100%; background: #0d1117; color: #c9d1d9;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }\n * { box-sizing: border-box; }\n .app { display: flex; flex-direction: column; height: 100%; max-width: 600px; margin: 0 auto; }\n .header { padding: 12px 16px; border-bottom: 1px solid #30363d; background: #161b22;\n display: flex; align-items: center; gap: 8px; min-height: 52px; }\n .header h1 { margin: 0; font-size: 18px; font-weight: 600; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;\n cursor: pointer; -webkit-user-select: none; user-select: none; }\n .header h1:active { opacity: .7; }\n .header .back-btn { background: none; border: none; color: #58a6ff; font-size: 20px;\n cursor: pointer; padding: 8px; min-width: 44px; min-height: 44px; display: flex; align-items: center; justify-content: center; }\n .header .header-btn { background: none; border: 1px solid #30363d; \n padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 14px; min-height: 36px;\n display: flex; align-items: center; gap: 4px; white-space: nowrap; }\n .header .refresh-btn, .header .compose-btn { color: #58a6ff; }\n .header .logout-btn { color: #f85149; }\n .header .send-header-btn { background: #238636; color: #fff; border: none; \n padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600; min-height: 36px;\n display: flex; align-items: center; gap: 4px; white-space: nowrap; }\n .header .send-header-btn:disabled { opacity: .5; cursor: not-allowed; }\n .content { flex: 1; overflow-y: auto; padding: 16px; }\n .content::-webkit-scrollbar { width: 6px; }\n .content::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }\n .content.no-pad { padding: 0; }\n .login-form { max-width: 400px; margin: 60px auto 0; padding: 24px; background: #161b22;\n border: 1px solid #30363d; border-radius: 12px; }\n .login-form h2 { margin: 0 0 20px; font-size: 20px; text-align: center; }\n .login-form label { display: block; margin-bottom: 6px; color: #8b949e; font-size: 14px; }\n .login-form input { width: 100%; padding: 12px 14px; margin-bottom: 16px; min-height: 44px;\n background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #c9d1d9;\n font-size: 15px; outline: none; }\n .login-form input:focus { border-color: #58a6ff; }\n .login-form .btn-primary { width: 100%; padding: 12px; min-height: 44px; font-size: 15px;\n background: #238636; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }\n .login-form .btn-primary:disabled { opacity: .5; cursor: not-allowed; }\n .login-form .error { color: #f85149; font-size: 13px; padding: 8px; background: rgba(248,81,73,0.1); border-radius: 6px; display: none; }\n .mailbox-item { display: flex; align-items: center; padding: 14px 16px; min-height: 52px;\n border-bottom: 1px solid #21262d; cursor: pointer; gap: 12px; }\n .mailbox-item:hover { background: #161b22; }\n .mailbox-icon { font-size: 20px; width: 36px; text-align: center; }\n .mailbox-info { flex: 1; }\n .mailbox-name { font-size: 15px; font-weight: 500; }\n .mailbox-stats { font-size: 12px; color: #8b949e; margin-top: 2px; }\n .mailbox-unread { background: #238636; color: #fff; border-radius: 12px; padding: 2px 10px; font-size: 12px; font-weight: 600; }\n .email-list { position: relative; }\n .email-item { padding: 14px 16px; min-height: 60px; border-bottom: 1px solid #21262d; cursor: pointer;\n position: relative; background: #0d1117; transition: transform .2s ease; }\n .email-item:hover { background: #161b22; }\n .email-item.unread { border-left: 3px solid #58a6ff; }\n .email-item.swiping { transition: none; }\n .email-item .delete-bg { position: absolute; top: 0; right: 0; bottom: 0; background: #f85149;\n display: flex; align-items: center; padding: 0 20px; font-size: 14px; font-weight: 600; color: #fff;\n border-radius: 0 0 0 0; z-index: 0; }\n .email-item .email-content { position: relative; z-index: 1; background: #0d1117;\n transition: transform .2s ease; padding: 0; }\n .email-item .email-content.swiping { transition: none; }\n .email-sender { font-size: 14px; font-weight: 600; color: #e6edf3; margin-bottom: 4px; }\n .email-subject { font-size: 14px; color: #c9d1d9; margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .email-preview { font-size: 13px; color: #8b949e; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }\n .email-date { font-size: 11px; color: #8b949e; margin-top: 4px; }\n .email-item.unread .email-subject { font-weight: 600; color: #e6edf3; }\n .email-detail { position: relative; }\n .email-detail.swiping { transition: none; }\n .email-detail-header { padding: 16px; border-bottom: 1px solid #30363d; }\n .email-detail-subject { font-size: 18px; font-weight: 600; margin-bottom: 12px; }\n .email-detail-from { font-size: 14px; color: #8b949e; margin-bottom: 4px; }\n .email-detail-date { font-size: 13px; color: #8b949e; }\n .email-detail-body { padding: 16px; line-height: 1.6; font-size: 14px; word-wrap: break-word; }\n .email-detail-actions { padding: 12px 16px; border-top: 1px solid #30363d; display: flex; gap: 8px; }\n .email-detail-actions .action-btn { flex: 1; padding: 10px; min-height: 44px; border: 1px solid #30363d;\n border-radius: 8px; background: none; color: #c9d1d9; font-size: 14px; cursor: pointer; }\n .email-detail-actions .action-btn:active { background: #161b22; }\n .email-detail-actions .reply-btn { color: #58a6ff; }\n .compose-form { display: flex; flex-direction: column; height: 100%; }\n .compose-fields { padding: 16px; display: flex; flex-direction: column; height: 100%; }\n .compose-fields label { display: block; margin-bottom: 4px; color: #8b949e; font-size: 13px; font-weight: 500; }\n .compose-fields input, .compose-fields textarea { padding: 10px 12px; min-height: 44px;\n background: #0d1117; border: 1px solid #30363d; border-radius: 8px; color: #c9d1d9;\n font-size: 15px; outline: none; font-family: inherit; }\n .compose-row { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }\n .compose-row label { flex-shrink: 0; width: auto; min-width: 52px; margin-bottom: 0; }\n .compose-row input { flex: 1; min-width: 0; width: auto; }\n .compose-fields textarea { min-height: 200px; resize: vertical; }\n .compose-fields .send-btn { width: 100%; padding: 12px; min-height: 44px; font-size: 15px;\n background: #238636; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }\n .compose-fields .send-btn:disabled { opacity: .5; cursor: not-allowed; }\n .compose-fields .compose-error { color: #f85149; font-size: 13px; padding: 8px;\n background: rgba(248,81,73,0.1); border-radius: 6px; display: none; margin-bottom: 12px; }\n .compose-fields.compose-expanded { padding: 0; }\n .compose-fields.compose-expanded .compose-hdrs, .compose-fields.compose-expanded .compose-from-bar { display: none; }\n .compose-fields.compose-expanded .compose-error { margin: 8px 8px 0; }\n .compose-fields.compose-expanded #composeBody { min-height: 0; flex: 1; margin-bottom: 0; border-radius: 0; \n border-left: none; border-right: none; border-top: 1px solid #30363d; resize: none; padding: 16px; font-size: 16px; }\n .compose-bar { display: flex; gap: 8px; padding: 10px 12px; border-top: 1px solid #30363d; background: #161b22; }\n .compose-bar .send-btn { flex: 1; padding: 12px; min-height: 48px; font-size: 15px;\n background: #238636; color: #fff; border: none; border-radius: 8px; cursor: pointer; font-weight: 600; }\n .compose-bar .send-btn:disabled { opacity: .5; cursor: not-allowed; }\n .compose-bar .reduce-btn { min-width: 80px; padding: 12px; min-height: 48px; font-size: 14px;\n background: none; color: #8b949e; border: 1px solid #30363d; border-radius: 8px; cursor: pointer; }\n .loading { text-align: center; padding: 40px 16px; color: #8b949e; }\n .loading .spinner { display: inline-block; width: 24px; height: 24px; border: 3px solid #30363d;\n border-top-color: #58a6ff; border-radius: 50%; animation: spin .8s linear infinite; margin-bottom: 12px; }\n @keyframes spin { to { transform: rotate(360deg); } }\n .empty-state { text-align: center; padding: 60px 20px; color: #8b949e; }\n .empty-state .icon { font-size: 48px; margin-bottom: 16px; }\n .empty-state h3 { font-size: 18px; margin: 0 0 8px; color: #c9d1d9; }\n .status-bar { padding: 8px 16px; background: #161b22; border-top: 1px solid #30363d;\n font-size: 12px; color: #8b949e; display: flex; justify-content: space-between; min-height: 36px; }\n .status-bar.hidden { display: none; }\n .toast { position: fixed; bottom: 80px; left: 50%; transform: translateX(-50%); \n background: #161b22; border: 1px solid #30363d; padding: 10px 20px; border-radius: 8px;\n font-size: 14px; color: #c9d1d9; z-index: 100; display: none; max-width: 90%; text-align: center; }\n .toast.error { border-color: #f85149; background: rgba(248,81,73,0.15); }\n .toast.success { border-color: #238636; background: rgba(35,134,54,0.15); }\n .ptr-indicator { position: absolute; top: 0; left: 0; width: 100%; \n color: #8b949e; font-size: 14px; text-align: center; padding: 12px 0; \n pointer-events: none; z-index: 5; display: none; }\n .ptr-indicator.visible { display: block; }\n .ptr-indicator .hint { color: #58a6ff; font-size: 12px; }\n @media (max-width: 480px) {\n .login-form { margin: 30px 8px 0; padding: 20px 16px; }\n .content { padding: 0; }\n .header { padding: 10px 12px; gap: 6px; }\n .header .header-btn { padding: 6px 8px; font-size: 13px; }\n .compose-fields { padding: 12px; }\n .compose-fields.compose-expanded { padding: 0; }\n }\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"app\">\n <div class=\"header\" id=\"header\">\n <button class=\"back-btn\" id=\"backBtn\" style=\"display:none\">β</button>\n <h1 id=\"title\">π§ Email</h1>\n <button class=\"header-btn compose-btn\" id=\"composeBtn\" style=\"display:none\">βοΈ</button>\n <button class=\"header-btn refresh-btn\" id=\"refreshBtn\" style=\"display:none\">π</button>\n <button class=\"send-header-btn\" id=\"sendHeaderBtn\" style=\"display:none\">π€ Send</button>\n <button class=\"header-btn logout-btn\" id=\"logoutBtn\" style=\"display:none\">Logout</button>\n </div>\n <div class=\"content\" id=\"content\"><div class=\"ptr-indicator\" id=\"ptrIndicator\"><span id=\"ptrText\">β¬οΈ Hold & pull down to refresh</span></div></div>\n <div class=\"status-bar\" id=\"statusBar\"><span id=\"statusLeft\">Not connected</span><span id=\"statusRight\">jmap.server</span></div>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n<script>\nconst STORAGE_KEY='msw_tab_jmap_email';\nlet state={serverUrl:'https://jmap.gakoy.com',credentials:null,session:null,apiUrl:null,downloadUrlTpl:null,accountId:null,submissionAccountId:null,identities:[],mailboxes:[],mailboxMap:{},sendMailboxId:null,trashMailboxId:null,currentView:'login',currentMailboxId:null,currentMailboxName:'',emails:[],currentEmail:null,composeData:{to:'',cc:'',subject:'',body:'',replyToEmailId:null},composeExpanded:false,loading:false};\nconst $=id=>document.getElementById(id),content=$('content'),title=$('title'),backBtn=$('backBtn'),composeBtn=$('composeBtn'),refreshBtn=$('refreshBtn'),logoutBtn=$('logoutBtn'),sendHeaderBtn=$('sendHeaderBtn'),statusBar=$('statusBar'),statusLeft=$('statusLeft'),statusRight=$('statusRight'),toast=$('toast');\nlet toastTimer=null;\nfunction showToast(msg,type=''){toast.textContent=msg;toast.className='toast '+type;toast.style.display='block';clearTimeout(toastTimer);toastTimer=setTimeout(()=>{toast.style.display='none'},4000)}\nfunction saveState(){try{localStorage.setItem(STORAGE_KEY,JSON.stringify({serverUrl:state.serverUrl,credentials:state.credentials,session:state.session,apiUrl:state.apiUrl,accountId:state.accountId,submissionAccountId:state.submissionAccountId}))}catch(e){}}\nfunction loadState(){try{const r=localStorage.getItem(STORAGE_KEY);if(r){const s=JSON.parse(r);if(s.serverUrl)state.serverUrl=s.serverUrl;if(s.credentials)state.credentials=s.credentials;if(s.session)state.session=s.session;if(s.apiUrl)state.apiUrl=s.apiUrl;if(s.accountId)state.accountId=s.accountId;if(s.submissionAccountId)state.submissionAccountId=s.submissionAccountId}}catch(e){}}\nasync function jmapFetch(path,options={}){const url=path.startsWith('http')?path:state.serverUrl+path;const headers={'Content-Type':'application/json','Accept':'application/json',...options.headers};if(state.credentials){if(state.credentials.token)headers['Authorization']='Bearer '+state.credentials.token;else if(state.credentials.username&&state.credentials.password)headers['Authorization']='Basic '+btoa(state.credentials.username+':'+state.credentials.password)}const res=await fetch(url,{method:options.method||'GET',headers,body:options.body?JSON.stringify(options.body):undefined});if(!res.ok){let detail='';try{const e=await res.json();detail=e.detail||e.type||JSON.stringify(e)}catch(e){detail=res.statusText}throw new Error('HTTP '+res.status+': '+detail)}const text=await res.text();return text?JSON.parse(text):null}\nasync function jmapApiCall(method,params={}){if(!state.apiUrl)throw new Error('No API URL');const result=await jmapFetch(state.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls:[[method,params,'0']]}});return result}\nasync function jmapMultiCall(methodCalls){if(!state.apiUrl)throw new Error('No API URL');const result=await jmapFetch(state.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls}});return result}\nasync function loadIdentities(){try{const sub=state.submissionAccountId||state.accountId;const r=await jmapApiCall('Identity/get',{accountId:sub});state.identities=r.methodResponses[0][1].list||[]}catch(e){state.identities=[]}}\n\n// ---- Custom long-press pull-to-refresh ----\nlet ptrTouchStartY=0, ptrLongPress=false, ptrPulling=false, ptrTimer=null, ptrDy=0;\nconst ptrEl=$('ptrIndicator'), ptrText=$('ptrText');\nconst LONG_PRESS_MS=400, PTR_THRESHOLD=60;\nfunction setupPtr(){\n content.addEventListener('touchstart',onPtrTouchStart,{passive:true});\n content.addEventListener('touchmove',onPtrTouchMove,{passive:true});\n content.addEventListener('touchend',onPtrTouchEnd,{passive:true});\n content.addEventListener('touchcancel',onPtrTouchEnd,{passive:true});\n}\nfunction onPtrTouchStart(e){\n if(state.currentView!=='mailboxes'&&state.currentView!=='emails')return;\n if(content.scrollTop>0)return;\n ptrTouchStartY=e.changedTouches[0].screenY;\n ptrLongPress=false; ptrPulling=false; ptrDy=0;\n ptrEl.classList.remove('visible');\n clearTimeout(ptrTimer);\n ptrTimer=setTimeout(()=>{ptrLongPress=true;ptrText.textContent='β¬οΈ Now pull down to refresh';ptrEl.classList.add('visible');},LONG_PRESS_MS);\n}\nfunction onPtrTouchMove(e){\n if(state.currentView!=='mailboxes'&&state.currentView!=='emails')return;\n const dy=e.changedTouches[0].screenY-ptrTouchStartY;\n if(dy<0){clearTimeout(ptrTimer);ptrLongPress=false;ptrEl.classList.remove('visible');return;}\n if(!ptrLongPress){if(dy>15)clearTimeout(ptrTimer);return;}\n ptrDy=Math.min(dy,120);\n ptrPulling=ptrDy>PTR_THRESHOLD;\n ptrText.textContent=ptrPulling?'β» Release to refresh':'β¬οΈ Pull further...';\n ptrEl.style.transform='translateY('+(ptrDy-30)+'px)';\n}\nfunction onPtrTouchEnd(){\n clearTimeout(ptrTimer);\n if(ptrPulling&&ptrLongPress){ptrText.textContent='π Refreshing...';doPtrRefresh();}\n else{ptrEl.classList.remove('visible');ptrEl.style.transform='';}\n ptrLongPress=false;ptrPulling=false;ptrDy=0;\n}\nasync function doPtrRefresh(){\n try{if(state.currentView==='mailboxes'){await loadMailboxes();}else if(state.currentView==='emails'){await loadEmails();}}catch(e){}\n ptrEl.classList.remove('visible');ptrEl.style.transform='';showToast('β Refreshed','success');\n}\n// ---- End custom pull-to-refresh ----\n\nfunction render(){content.className='content'+(state.currentView==='compose'?' no-pad':'');switch(state.currentView){case'login':renderLogin();break;case'mailboxes':renderMailboxes();break;case'emails':renderEmails();break;case'email-detail':renderEmailDetail();break;case'compose':renderCompose();break}updateHeader();updateStatusBar();}\nfunction goToFolders(){if(state.currentView==='emails'||state.currentView==='email-detail'){state.currentView='mailboxes';render()}}\nfunction goBackToList(){if(state.currentView==='email-detail'){state.currentView='emails';render()}}\nfunction updateHeader(){const logged=state.currentView!=='login';backBtn.style.display=state.currentView==='mailboxes'||state.currentView==='compose'?'none':'flex';composeBtn.style.display=logged&&state.currentView!=='compose'?'flex':'none';refreshBtn.style.display=logged&&state.currentView!=='compose'?'flex':'none';logoutBtn.style.display=logged&&state.currentView!=='compose'?'flex':'none';sendHeaderBtn.style.display=state.currentView==='compose'?'flex':'none';switch(state.currentView){case'login':title.textContent='π§ Email';break;case'mailboxes':title.textContent='π Folders';break;case'emails':title.textContent='π¨ Messages';break;case'email-detail':title.textContent='π¬ Message';break;case'compose':const t=state.composeData.to;title.textContent=t?'βοΈ '+t.split(',')[0].trim():'βοΈ New Message';break}}\nfunction updateStatusBar(){if(state.currentView==='compose'){statusBar.classList.add('hidden')}else{statusBar.classList.remove('hidden');if(state.credentials&&state.session){statusLeft.textContent=state.credentials.username||'Logged in';statusRight.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}else{statusLeft.textContent='Not connected';statusRight.textContent=state.serverUrl.replace('https://','')}}}\nasync function handleRefresh(){if(state.loading)return;showToast('π Refreshing...','');switch(state.currentView){case'mailboxes':await loadMailboxes();break;case'emails':await loadEmails();break;case'email-detail':if(state.currentEmail)await loadEmailDetail(state.currentEmail.id);break}}\nfunction renderLogin(){content.innerHTML='<div class=\"login-form\"><h2>π§ JMAP Email</h2><label for=\"serverUrl\">Server</label><input type=\"text\" id=\"serverUrl\" placeholder=\"https://jmap.example.com\" value=\"'+(state.serverUrl||'https://jmap.gakoy.com')+'\"><label for=\"username\">Username</label><input type=\"text\" id=\"username\" placeholder=\"your@email.com\" autocomplete=\"username\" value=\"'+(state.credentials?.username||'')+'\"><label for=\"password\">Password</label><input type=\"password\" id=\"password\" placeholder=\"β’β’β’β’β’β’β’β’\" autocomplete=\"current-password\"><button class=\"btn-primary\" id=\"loginBtn\">Sign In</button><div class=\"error\" id=\"loginError\"></div></div>';$('loginBtn').addEventListener('click',handleLogin);$('serverUrl').addEventListener('keydown',e=>{if(e.key==='Enter')$('username').focus()});$('username').addEventListener('keydown',e=>{if(e.key==='Enter')$('password').focus()});$('password').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin()});if(state.credentials?.username)$('password').focus();else $('serverUrl').focus();}\nasync function handleLogin(){const serverUrl=$('serverUrl').value.trim()||'https://jmap.gakoy.com',username=$('username').value.trim(),password=$('password').value,errorEl=$('loginError'),btn=$('loginBtn');if(!serverUrl||!username||!password){errorEl.textContent='Please enter server, username and password.';errorEl.style.display='block';return}errorEl.style.display='none';btn.disabled=true;btn.textContent='Signing in...';try{state.serverUrl=serverUrl;state.credentials={username,password};const session=await jmapFetch('/.well-known/jmap');state.session=session;state.apiUrl=session.apiUrl;state.downloadUrlTpl=session.downloadUrl;state.accountId=session.primaryAccounts['urn:ietf:params:jmap:mail'];state.submissionAccountId=session.primaryAccounts['urn:ietf:params:jmap:submission'];saveState();state.currentView='mailboxes';showToast('Signed in successfully!','success');await loadIdentities();await loadMailboxes()}catch(e){state.credentials=null;errorEl.textContent='Login failed: '+e.message;errorEl.style.display='block';btn.disabled=false;btn.textContent='Sign In'}}\nasync function loadMailboxes(){state.loading=true;renderMailboxes();try{const r=await jmapApiCall('Mailbox/get',{accountId:state.accountId});state.mailboxes=r.methodResponses[0][1].list||[];state.mailboxMap={};let o=null,s=null,d=null,tr=null,f=null;for(const mb of state.mailboxes){state.mailboxMap[mb.id]=mb.name;const n=(mb.name||'').toLowerCase(),ro=(mb.role||'').toLowerCase();if(n==='outbox'||ro==='outbox')o=mb.id;if(n==='sent'||ro==='sent')s=mb.id;if(n==='drafts'||ro==='drafts')d=mb.id;if(n==='trash'||n==='deleted messages'||n==='bin'||ro==='trash')tr=mb.id;if(!f)f=mb.id}state.sendMailboxId=o||s||d||f;state.trashMailboxId=tr;state.loading=false;renderMailboxes()}catch(e){state.loading=false;showToast('Failed to load mailboxes: '+e.message,'error');renderMailboxes()}}\nfunction renderMailboxes(){if(state.loading){content.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading mailboxes...</p></div>';return}if(state.mailboxes.length===0){content.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π</div><h3>No mailboxes found</h3><button class=\"btn-primary\" style=\"margin-top:16px;padding:10px 24px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:14px;min-height:44px\" id=\"refreshBtnEmpty\">π Refresh</button></div>';$('refreshBtnEmpty')?.addEventListener('click',loadMailboxes);return}const icons={'inbox':'π₯','sent':'π€','drafts':'π','trash':'ποΈ','spam':'β οΈ','archive':'π¦','junk':'β οΈ','outbox':'π€'};const sorted=[...state.mailboxes].sort((a,b)=>(a.sortOrder||0)-(b.sortOrder||0));let html='<div class=\"ptr-indicator\" id=\"ptrIndicator\"><span id=\"ptrText\">β¬οΈ Hold & pull down to refresh</span></div>';for(const mb of sorted){const name=mb.name||'Unnamed',total=mb.totalEmails||mb.totalMessages||0,unread=mb.unreadEmails||mb.unreadMessages||0,icon=Object.entries(icons).find(([k])=>name.toLowerCase().includes(k))?.[1]||'π';html+='<div class=\"mailbox-item\" data-id=\"'+mb.id+'\"><div class=\"mailbox-icon\">'+icon+'</div><div class=\"mailbox-info\"><div class=\"mailbox-name\">'+name+'</div><div class=\"mailbox-stats\">'+total+' messages</div></div>'+(unread>0?'<div class=\"mailbox-unread\">'+unread+'</div>':'')+'</div>'}content.innerHTML=html;content.querySelectorAll('.mailbox-item').forEach(el=>{el.addEventListener('click',()=>{const id=el.dataset.id,mb=state.mailboxes.find(m=>m.id===id);if(mb){state.currentMailboxId=id;state.currentMailboxName=mb.name;state.currentView='emails';render();loadEmails()}})})}\nasync function loadEmails(){state.loading=true;renderEmails();try{const q=await jmapApiCall('Email/query',{accountId:state.accountId,filter:{inMailbox:state.currentMailboxId},sort:[{property:'receivedAt',isAscending:false}],limit:50});const ids=q.methodResponses[0][1].ids;if(!ids||ids.length===0){state.emails=[];state.loading=false;renderEmails();return}const g=await jmapApiCall('Email/get',{accountId:state.accountId,ids,properties:['id','subject','from','to','receivedAt','hasAttachment','preview','size','keywords']});state.emails=g.methodResponses[0][1].list||[];state.loading=false;renderEmails()}catch(e){state.loading=false;showToast('Failed to load emails: '+e.message,'error');renderEmails()}}\nfunction renderEmails(){updateHeader();if(state.loading){content.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading emails...</p></div>';return}if(state.emails.length===0){content.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¨</div><h3>No emails</h3><p>This mailbox is empty.</p></div>';return}let html='<div class=\"ptr-indicator\" id=\"ptrIndicator\"><span id=\"ptrText\">β¬οΈ Hold & pull down to refresh</span></div><div class=\"email-list\">';for(const e of state.emails){const from=e.from?.[0]?.name||e.from?.[0]?.email||'Unknown',unread=!e.keywords?.$seen,date=new Date(e.receivedAt),ds=date.toLocaleDateString([],{month:'short',day:'numeric'}),ts=date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}),today=new Date().toDateString()===date.toDateString();html+='<div class=\"email-item'+(unread?' unread':'')+'\" data-id=\"'+e.id+'\"><div class=\"delete-bg\">ποΈ Delete</div><div class=\"email-content\"><div class=\"email-sender\">'+escapeHtml(from)+'</div><div class=\"email-subject\">'+escapeHtml(e.subject||'(No subject)')+'</div><div class=\"email-preview\">'+escapeHtml((e.preview||'').substring(0,100))+'</div><div class=\"email-date\">'+(today?ts:ds)+'</div></div></div>'}html+='</div>';content.innerHTML=html;content.querySelectorAll('.email-item').forEach(el=>{const contentDiv=el.querySelector('.email-content');let startX=0,currentX=0,isDragging=false,swiped=false;const deleteBg=el.querySelector('.delete-bg');deleteBg.style.opacity='0';const onStart=function(x){startX=x;currentX=0;isDragging=true;swiped=false;contentDiv.classList.add('swiping');el.classList.add('swiping')};const onMove=function(x){if(!isDragging)return;const dx=x-startX;if(dx<0){currentX=0;contentDiv.style.transform='translateX(0px)';return}currentX=Math.min(dx,120);contentDiv.style.transform='translateX('+currentX+'px)';deleteBg.style.opacity=Math.min(currentX/80,1)};const onEnd=function(){isDragging=false;contentDiv.classList.remove('swiping');el.classList.remove('swiping');if(currentX>60){swiped=true;deleteEmail(el.dataset.id,el)}else{contentDiv.style.transform='translateX(0px)';deleteBg.style.opacity='0'}};el.addEventListener('touchstart',e=>{const t=e.changedTouches[0];onStart(t.screenX)},{passive:true});el.addEventListener('touchmove',e=>{const t=e.changedTouches[0];onMove(t.screenX)},{passive:true});el.addEventListener('touchend',e=>{onEnd()},{passive:true});el.addEventListener('mousedown',e=>{onStart(e.screenX)});el.addEventListener('mousemove',e=>{onMove(e.screenX)});el.addEventListener('mouseup',()=>{onEnd()});el.addEventListener('mouseleave',()=>{if(isDragging)onEnd()});el.addEventListener('click',e=>{if(swiped){e.stopPropagation();swiped=false;return}const id=el.dataset.id,email=state.emails.find(e=>e.id===id);if(email){state.currentEmail=email;state.currentView='email-detail';loadEmailDetail(id)}})})}\nfunction removeEmailEl(emailId){const el=content.querySelector('.email-item[data-id=\"'+emailId+'\"]');if(el)el.style.display='none';}\nasync function deleteEmail(emailId,emailEl){const email=state.emails.find(e=>e.id===emailId);if(!email)return;state.emails=state.emails.filter(e=>e.id!==emailId);removeEmailEl(emailId);showToast('ποΈ Deleting...','');const emailBackup=JSON.parse(JSON.stringify(email));const isTrash=state.currentMailboxId===state.trashMailboxId;try{if(!state.trashMailboxId){showToast('ποΈ Removed','success');return}if(isTrash){await jmapApiCall('Email/set',{accountId:state.accountId,destroy:[emailId]});showToast('ποΈ Permanently deleted','success')}else{const fullR=await jmapApiCall('Email/get',{accountId:state.accountId,ids:[emailId],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true});const fullEmail=fullR.methodResponses[0][1].list?.[0];if(!fullEmail)throw new Error('Could not fetch email data');let bodyText='';const textBody=fullEmail.textBody?.[0];const htmlBody=fullEmail.htmlBody?.[0];if(textBody&&fullEmail.bodyValues&&fullEmail.bodyValues[textBody.partId])bodyText=fullEmail.bodyValues[textBody.partId].value;if(!bodyText&&htmlBody&&fullEmail.bodyValues&&fullEmail.bodyValues[htmlBody.partId])bodyText=fullEmail.bodyValues[htmlBody.partId].value;await jmapApiCall('Email/set',{accountId:state.accountId,destroy:[emailId]});const reimportData={mailboxIds:{[state.trashMailboxId]:true},from:fullEmail.from||email.from,to:fullEmail.to||email.to,cc:fullEmail.cc||email.cc,bcc:fullEmail.bcc||email.bcc,subject:fullEmail.subject||email.subject||'(no subject)',receivedAt:fullEmail.receivedAt||email.receivedAt,keywords:fullEmail.keywords||email.keywords||{},textBody:bodyText?[{partId:'body-0',type:'text/plain'}]:undefined,bodyValues:{}};if(bodyText)reimportData.bodyValues['body-0']={value:bodyText,charset:'UTF-8'};if(!reimportData.cc)delete reimportData.cc;if(!reimportData.bcc)delete reimportData.bcc;if(!reimportData.textBody)delete reimportData.textBody;if(Object.keys(reimportData.bodyValues).length===0)delete reimportData.bodyValues;const cR=await jmapApiCall('Email/set',{accountId:state.accountId,create:{'reimported':reimportData}});if(!cR.methodResponses[0][1].created?.['reimported']){const err=cR.methodResponses[0][1].notCreated?.['reimported'];showToast('Email destroyed but failed to re-import: '+(err?.description||'unknown'),'error')}else{showToast('ποΈ Moved to trash','success')}}}catch(e){state.emails.push(emailBackup);state.emails.sort((a,b)=>new Date(b.receivedAt)-new Date(a.receivedAt));if(state.currentView==='emails')renderEmails();showToast('Failed to delete: '+e.message,'error')}}\nasync function loadEmailDetail(emailId){state.loading=true;renderEmailDetail();try{const r=await jmapApiCall('Email/get',{accountId:state.accountId,ids:[emailId],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true,maxBodyValueLength:10485760});const email=r.methodResponses[0][1].list?.[0];if(email){state.currentEmail=email;const textBody=email.textBody?.[0];const htmlBody=email.htmlBody?.[0];let fullBody=null;if(email.bodyValues){if(textBody&&email.bodyValues[textBody.partId]){fullBody=email.bodyValues[textBody.partId].value}if(!fullBody&&htmlBody&&email.bodyValues[htmlBody.partId]){fullBody=email.bodyValues[htmlBody.partId].value}}if(!fullBody&&textBody&&textBody.blobId&&state.downloadUrlTpl){try{const dlUrl=state.downloadUrlTpl.replace('{accountId}',encodeURIComponent(state.accountId)).replace('{blobId}',textBody.blobId).replace('{name}','body.txt').replace('?type={type}','?type=text/plain');const dlRes=await fetch(dlUrl,{headers:{'Authorization':'Basic '+btoa(state.credentials.username+':'+state.credentials.password)}});if(dlRes.ok){fullBody=await dlRes.text()}}catch(e){}}if(!fullBody&&htmlBody&&htmlBody.blobId&&state.downloadUrlTpl){try{const dlUrl=state.downloadUrlTpl.replace('{accountId}',encodeURIComponent(state.accountId)).replace('{blobId}',htmlBody.blobId).replace('{name}','body.html').replace('?type={type}','?type=text/html');const dlRes=await fetch(dlUrl,{headers:{'Authorization':'Basic '+btoa(state.credentials.username+':'+state.credentials.password)}});if(dlRes.ok){fullBody=await dlRes.text()}}catch(e){}}if(fullBody){state.currentEmail._fullBody=fullBody}}state.loading=false;renderEmailDetail();try{await jmapApiCall('Email/set',{accountId:state.accountId,update:{[emailId]:{keywords:{$seen:true}}}})}catch(e){}}catch(e){state.loading=false;showToast('Failed to load email: '+e.message,'error');renderEmailDetail()}}\nfunction renderEmailDetail(){if(state.loading){content.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading message...</p></div>';return}const email=state.currentEmail;if(!email){content.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¬</div><h3>No message</h3></div>';return}const from=email.from?.map(f=>f.name||f.email).join(', ')||'Unknown',to=email.to?.map(t=>t.name||t.email).join(', ')||'Unknown',date=email.receivedAt?new Date(email.receivedAt).toLocaleString():'Unknown',subject=email.subject||'(No subject)';let bodyHtml='';const htmlBody=email.htmlBody?.[0],textBody=email.textBody?.[0];const fullBody=email._fullBody;if(fullBody){bodyHtml=escapeHtml(fullBody).replace(/\\n/g,'<br>')}else if(htmlBody?.value){bodyHtml=htmlBody.value}else if(textBody?.value){bodyHtml=escapeHtml(textBody.value).replace(/\\n/g,'<br>')}else if(email.preview){bodyHtml=escapeHtml(email.preview)}else{bodyHtml='<p style=\"color:#8b949e\">(No content)</p>'}content.innerHTML='<div class=\"email-detail\"><div class=\"email-detail-header\"><div class=\"email-detail-subject\">'+escapeHtml(subject)+'</div><div class=\"email-detail-from\">π¬ <strong>From:</strong> '+escapeHtml(from)+'</div><div class=\"email-detail-from\">π¨ <strong>To:</strong> '+escapeHtml(to)+'</div><div class=\"email-detail-date\">π '+escapeHtml(date)+'</div>'+(email.hasAttachment?'<div style=\"margin-top:8px;color:#d29922\">π Has attachments</div>':'')+'</div><div class=\"email-detail-body\">'+bodyHtml+'</div><div class=\"email-detail-actions\"><button class=\"action-btn reply-btn\" id=\"replyBtn\">β©οΈ Reply</button><button class=\"action-btn\" id=\"replyAllBtn\">βͺοΈ Reply All</button><button class=\"action-btn\" id=\"forwardBtn\">β‘οΈ Forward</button></div></div>';$('replyBtn')?.addEventListener('click',()=>openReply(email));$('replyAllBtn')?.addEventListener('click',()=>openReplyAll(email));$('forwardBtn')?.addEventListener('click',()=>openForward(email));const detailEl=content.querySelector('.email-detail');if(detailEl){let sx=0,ex=0,swiping=false;detailEl.addEventListener('touchstart',e=>{sx=e.changedTouches[0].screenX;swiping=true},{passive:true});detailEl.addEventListener('touchmove',e=>{if(!swiping)return;ex=e.changedTouches[0].screenX;const dx=ex-sx;if(dx<0)detailEl.style.transform='translateX('+dx+'px)';detailEl.classList.add('swiping')},{passive:true});detailEl.addEventListener('touchend',()=>{swiping=false;detailEl.classList.remove('swiping');detailEl.style.transform='';const dx=ex-sx;if(dx<-80)goBackToList()},{passive:true});}}\nfunction openCompose(prefill={}){state.composeData={to:prefill.to||'',cc:prefill.cc||'',subject:prefill.subject||'',body:prefill.body||'',replyToEmailId:prefill.replyToEmailId||null};state.composeExpanded=false;state.currentView='compose';render()}\nfunction openReply(email){openCompose({to:email.from?.[0]?.email||'',subject:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyToEmailId:email.id})}\nfunction openReplyAll(email){const to=email.from?.map(f=>f.email).join(', ')||'',cc=email.to?.map(t=>t.email).filter(e=>e!==state.credentials?.username).join(', ')||'';openCompose({to,cc,subject:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyToEmailId:email.id})}\nfunction openForward(email){openCompose({subject:email.subject?'Fwd: '+email.subject.replace(/^Fwd:\\s*/i,''):'Fwd: ',body:'\\n\\n\\n--- Forwarded message ---\\n'+(email.preview||''),replyToEmailId:email.id})}\nfunction renderCompose(){const d=state.composeData,userEmail=state.credentials?.username||'',identityName=state.identities.length>0?state.identities[0].name||state.identities[0].email||userEmail:userEmail,expanded=state.composeExpanded;if(expanded){content.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields compose-expanded\"><div class=\"compose-error\" id=\"composeError\"></div><textarea id=\"composeBody\" placeholder=\"Write your message...\">'+escapeHtml(d.body)+'</textarea></div><div class=\"compose-bar\"><button class=\"reduce-btn\" id=\"reduceBtn\">β½ Reduce</button></div></div>';$('reduceBtn').addEventListener('click',()=>{state.composeData.body=$('composeBody').value;state.composeExpanded=false;render()});$('composeBody').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});$('composeBody').focus()}else{content.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields\"><div class=\"compose-error\" id=\"composeError\"></div><div class=\"compose-hdrs\"><div class=\"compose-row\"><label for=\"composeTo\">To</label><input type=\"text\" id=\"composeTo\" placeholder=\"recipient@example.com\" value=\"'+escapeHtml(d.to)+'\"></div><div class=\"compose-row\"><label for=\"composeCc\">CC</label><input type=\"text\" id=\"composeCc\" placeholder=\"cc@example.com\" value=\"'+escapeHtml(d.cc)+'\"></div><div class=\"compose-row\"><label for=\"composeSubject\">Subj</label><input type=\"text\" id=\"composeSubject\" placeholder=\"Subject\" value=\"'+escapeHtml(d.subject)+'\"></div></div><textarea id=\"composeBody\" placeholder=\"Write your message...\">'+escapeHtml(d.body)+'</textarea></div></div>';$('composeBody').addEventListener('focus',()=>{state.composeData.to=$('composeTo').value;state.composeData.cc=$('composeCc').value;state.composeData.subject=$('composeSubject').value;state.composeData.body=$('composeBody').value;state.composeExpanded=true;render()});$('composeBody').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});if(!d.to)$('composeTo').focus();else if(!d.subject)$('composeSubject').focus();else $('composeBody').focus()}}\nasync function handleSend(){let toRaw,ccRaw,subject,body;if(state.composeExpanded){toRaw=state.composeData.to;ccRaw=state.composeData.cc;subject=state.composeData.subject;body=$('composeBody').value}else{toRaw=$('composeTo').value.trim();ccRaw=$('composeCc').value.trim();subject=$('composeSubject').value.trim();body=$('composeBody').value}const errorEl=$('composeError'),btn=$('sendBtn')||sendHeaderBtn;if(!toRaw){errorEl.textContent='Please enter at least one recipient.';errorEl.style.display='block';return}errorEl.style.display='none';btn.disabled=true;btn.textContent='π€ Sending...';sendHeaderBtn.disabled=true;sendHeaderBtn.textContent='π€ Sending...';try{const toAddresses=toRaw.split(',').map(s=>s.trim()).filter(s=>s).map(email=>({email})),ccAddresses=ccRaw.split(',').map(s=>s.trim()).filter(s=>s).map(email=>({email})),userEmail=state.credentials.username;if(state.identities.length===0)await loadIdentities();let identity=state.identities.find(i=>i.email===userEmail);if(!identity&&state.identities.length>0)identity=state.identities[0];if(!identity)throw new Error('No identity found on the server.');if(!state.sendMailboxId)throw new Error('No mailbox available.');const to=toAddresses,cc=ccAddresses.length>0?ccAddresses:undefined;const emailCreate={mailboxIds:{[state.sendMailboxId]:true},keywords:{$seen:false},from:[{email:identity.email||userEmail}],to,subject:subject||'(no subject)',textBody:[{partId:'body-0',type:'text/plain'}],bodyValues:{'body-0':{value:body,charset:'UTF-8'}}};if(cc)emailCreate.cc=cc;if(state.composeData.replyToEmailId)emailCreate.inReplyTo=state.composeData.replyToEmailId;const submissionAccountId=state.submissionAccountId||state.accountId,allRcpt=[...toAddresses];if(ccAddresses.length>0)allRcpt.push(...ccAddresses);const multiResult=await jmapMultiCall([['Email/set',{accountId:state.accountId,create:{'new-email':emailCreate}},'0'],['EmailSubmission/set',{accountId:submissionAccountId,create:{'new-submission':{emailId:'#new-email',identityId:identity.id,envelope:{mailFrom:{email:identity.email||userEmail},rcptTo:allRcpt}}}},'1']]);const emailSetResult=multiResult.methodResponses[0][1],created=emailSetResult.created?.['new-email'];if(!created){const err=emailSetResult.notCreated?.['new-email'];throw new Error(err?.description||'Failed to create email')}const submissionResult=multiResult.methodResponses[1][1],submitted=submissionResult.created?.['new-submission'];if(!submitted){const err=submissionResult.notCreated?.['new-submission'],errDetail=err?(err.description||err.type||JSON.stringify(err)):'Unknown error';if(state.composeData.replyToEmailId&&(errDetail.includes('oldEmail')||errDetail.includes('inReplyTo'))){delete emailCreate.inReplyTo;const retryResult=await jmapMultiCall([['Email/set',{accountId:state.accountId,create:{'new-email':emailCreate}},'0'],['EmailSubmission/set',{accountId:submissionAccountId,create:{'new-submission':{emailId:'#new-email',identityId:identity.id,envelope:{mailFrom:{email:identity.email||userEmail},rcptTo:allRcpt}}}},'1']]);const retrySubmitted=retryResult.methodResponses[1][1].created?.['new-submission'];if(retrySubmitted){showToast('β Email sent successfully!','success');state.currentView='mailboxes';state.composeExpanded=false;render();return}throw new Error('SMTP rejected: '+(retryResult.methodResponses[1][1].notCreated?.['new-submission']?.description||errDetail))}throw new Error('SMTP rejected: '+errDetail)}showToast('β Email sent successfully!','success');state.currentView='mailboxes';state.composeExpanded=false;render()}catch(e){errorEl.textContent='Failed to send: '+e.message;errorEl.style.display='block';btn.disabled=false;btn.textContent='π€ Send';sendHeaderBtn.disabled=false;sendHeaderBtn.textContent='π€ Send'}btn.disabled=false;btn.textContent='π€ Send';sendHeaderBtn.disabled=false;sendHeaderBtn.textContent='π€ Send'}\nfunction escapeHtml(str){if(!str)return'';const d=document.createElement('div');d.textContent=str;return d.innerHTML}\ntitle.addEventListener('click',()=>{goToFolders()});\nbackBtn.addEventListener('click',()=>{if(state.currentView==='email-detail'){state.currentView='emails';render()}else if(state.currentView==='emails'){state.currentView='mailboxes';render()}});\ncomposeBtn.addEventListener('click',()=>openCompose({}));\nrefreshBtn.addEventListener('click',handleRefresh);\nsendHeaderBtn.addEventListener('click',handleSend);\nlogoutBtn.addEventListener('click',()=>{state.credentials=null;state.session=null;state.apiUrl=null;state.downloadUrlTpl=null;state.accountId=null;state.submissionAccountId=null;state.identities=[];state.mailboxes=[];state.mailboxMap={};state.emails=[];state.currentEmail=null;state.sendMailboxId=null;state.trashMailboxId=null;state.composeExpanded=false;state.currentView='login';localStorage.removeItem(STORAGE_KEY);render();showToast('Logged out','success')});\nasync function init(){loadState();if(state.credentials&&state.session&&state.apiUrl&&state.accountId){state.currentView='mailboxes';render();await loadIdentities();await loadMailboxes()}else{state.currentView='login';render()}setupPtr();}\ninit();\n</script>\n</body>\n</html>" | |
| }, | |
| "currentHash": "08724e129ad34d60bbd8ad5aa8ce065bde0ca8d7732ed3fcc4125d0af8c1d86b", | |
| "previousHash": "c6f172fe9e5317be45405fad0d04f93b4b8f1518ce5397a3b0b083ab4426f9f1", | |
| "prompt": "email: don't hard code server, add it as as a field before user and password, prefilled with JMAP" | |
| }, | |
| "2": { | |
| "tab": { | |
| "id": "jmap-email", | |
| "label": "Email", | |
| "html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n<title>JMAP Email</title>\n<style>\nhtml,body{margin:0;height:100%;background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}\n*{box-sizing:border-box}\n.app{display:flex;flex-direction:column;height:100%;max-width:600px;margin:0 auto}\n.header{padding:10px 12px;border-bottom:1px solid #30363d;background:#161b22;display:flex;align-items:center;gap:6px;min-height:52px}\n.header h1{margin:0;font-size:17px;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;-webkit-user-select:none;user-select:none}\n.header h1:active{opacity:.7}\n.header .back-btn{background:none;border:none;color:#58a6ff;font-size:20px;cursor:pointer;padding:8px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}\n.header .hdr-btn{background:none;border:1px solid #30363d;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13px;min-height:36px;display:flex;align-items:center;gap:3px;white-space:nowrap}\n.header .hdr-btn.primary{background:#238636;color:#fff;border:none;font-weight:600}\n.header .hdr-btn:disabled{opacity:.5;cursor:not-allowed}\n.header .hdr-btn.danger{color:#f85149}\n.header .hdr-btn.acct{color:#58a6ff;max-width:100px;overflow:hidden;text-overflow:ellipsis}\n.content{flex:1;overflow-y:auto;padding:16px}\n.content::-webkit-scrollbar{width:6px}\n.content::-webkit-scrollbar-thumb{background:#30363d;border-radius:3px}\n.content.nopad{padding:0}\n.form-card{max-width:400px;margin:40px auto 0;padding:20px;background:#161b22;border:1px solid #30363d;border-radius:12px}\n.form-card h2{margin:0 0 16px;font-size:18px;text-align:center}\n.form-card label{display:block;margin-bottom:4px;color:#8b949e;font-size:13px}\n.form-card input{width:100%;padding:12px 14px;margin-bottom:14px;min-height:44px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#c9d1d9;font-size:15px;outline:none}\n.form-card input:focus{border-color:#58a6ff}\n.form-card .btn-primary{width:100%;padding:12px;min-height:44px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.form-card .btn-primary:disabled{opacity:.5;cursor:not-allowed}\n.form-card .error{color:#f85149;font-size:13px;padding:8px;background:rgba(248,81,73,0.1);border-radius:6px;display:none}\n.acct-item{display:flex;align-items:center;padding:14px 16px;min-height:52px;border-bottom:1px solid #21262d;cursor:pointer;gap:12px}\n.acct-item:hover{background:#161b22}\n.acct-item .acct-icon{font-size:22px;width:40px;text-align:center}\n.acct-item .acct-info{flex:1}\n.acct-item .acct-name{font-size:15px;font-weight:500}\n.acct-item .acct-email{font-size:12px;color:#8b949e;margin-top:2px}\n.acct-item .acct-badge{background:#238636;color:#fff;border-radius:10px;padding:2px 8px;font-size:11px;font-weight:600}\n.acct-item .acct-del{background:none;border:none;color:#f85149;font-size:18px;cursor:pointer;padding:8px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}\n.mailbox-item{display:flex;align-items:center;padding:14px 16px;min-height:52px;border-bottom:1px solid #21262d;cursor:pointer;gap:12px}\n.mailbox-item:hover{background:#161b22}\n.mailbox-icon{font-size:20px;width:36px;text-align:center}\n.mailbox-info{flex:1}\n.mailbox-name{font-size:15px;font-weight:500}\n.mailbox-stats{font-size:12px;color:#8b949e;margin-top:2px}\n.mailbox-unread{background:#238636;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600}\n.email-item{padding:14px 16px;min-height:60px;border-bottom:1px solid #21262d;cursor:pointer;position:relative;background:#0d1117}\n.email-item:hover{background:#161b22}\n.email-item.unread{border-left:3px solid #58a6ff}\n.email-item .del-bg{position:absolute;top:0;right:0;bottom:0;background:#f85149;display:flex;align-items:center;padding:0 20px;font-size:14px;font-weight:600;color:#fff;z-index:0}\n.email-item .ec{position:relative;z-index:1;background:#0d1117;transition:transform .2s ease;padding:0}\n.email-sender{font-size:14px;font-weight:600;color:#e6edf3;margin-bottom:4px}\n.email-subject{font-size:14px;color:#c9d1d9;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.email-preview{font-size:13px;color:#8b949e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.email-date{font-size:11px;color:#8b949e;margin-top:4px}\n.email-item.unread .email-subject{font-weight:600;color:#e6edf3}\n.email-detail{padding:16px}\n.email-detail-subject{font-size:17px;font-weight:600;margin-bottom:10px}\n.email-detail-from{font-size:13px;color:#8b949e;margin-bottom:3px}\n.email-detail-body{padding:12px 0;line-height:1.6;font-size:14px;word-wrap:break-word}\n.email-detail-actions{display:flex;gap:8px;padding:8px 0;border-top:1px solid #30363d}\n.email-detail-actions .act-btn{flex:1;padding:10px;min-height:44px;border:1px solid #30363d;border-radius:8px;background:none;color:#c9d1d9;font-size:14px;cursor:pointer}\n.email-detail-actions .act-btn:active{background:#161b22}\n.email-detail-actions .reply{color:#58a6ff}\n.compose-form{display:flex;flex-direction:column;height:100%}\n.compose-fields{padding:14px;display:flex;flex-direction:column;height:100%}\n.compose-fields label{display:block;margin-bottom:4px;color:#8b949e;font-size:13px;font-weight:500}\n.compose-fields input,.compose-fields textarea{padding:10px 12px;min-height:44px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#c9d1d9;font-size:15px;outline:none;font-family:inherit}\n.compose-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}\n.compose-row label{flex-shrink:0;width:auto;min-width:48px;margin-bottom:0}\n.compose-row input{flex:1;min-width:0}\n.compose-fields textarea{min-height:180px;resize:vertical}\n.compose-fields .send-btn{width:100%;padding:12px;min-height:44px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.compose-fields .send-btn:disabled{opacity:.5;cursor:not-allowed}\n.compose-fields .ce{color:#f85149;font-size:13px;padding:8px;background:rgba(248,81,73,0.1);border-radius:6px;display:none;margin-bottom:10px}\n.compose-fields.expanded{padding:0}\n.compose-fields.expanded .ch,.compose-fields.expanded .cfb{display:none}\n.compose-fields.expanded .ce{margin:8px 8px 0}\n.compose-fields.expanded #cb{min-height:0;flex:1;margin-bottom:0;border-radius:0;border-left:none;border-right:none;border-top:1px solid #30363d;resize:none;padding:16px;font-size:16px}\n.compose-bar{display:flex;gap:8px;padding:10px 12px;border-top:1px solid #30363d;background:#161b22}\n.compose-bar .send-btn{flex:1;padding:12px;min-height:48px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.compose-bar .send-btn:disabled{opacity:.5;cursor:not-allowed}\n.compose-bar .reduce-btn{min-width:80px;padding:12px;min-height:48px;font-size:14px;background:none;color:#8b949e;border:1px solid #30363d;border-radius:8px;cursor:pointer}\n.loading{text-align:center;padding:40px 16px;color:#8b949e}\n.loading .spinner{display:inline-block;width:24px;height:24px;border:3px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:12px}\n@keyframes spin{to{transform:rotate(360deg)}}\n.empty-state{text-align:center;padding:60px 20px;color:#8b949e}\n.empty-state .icon{font-size:48px;margin-bottom:16px}\n.empty-state h3{font-size:18px;margin:0 0 8px;color:#c9d1d9}\n.status-bar{padding:6px 12px;background:#161b22;border-top:1px solid #30363d;font-size:11px;color:#8b949e;display:flex;justify-content:space-between;min-height:32px;align-items:center}\n.status-bar.hidden{display:none}\n.toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#161b22;border:1px solid #30363d;padding:10px 20px;border-radius:8px;font-size:14px;color:#c9d1d9;z-index:100;display:none;max-width:90%;text-align:center}\n.toast.error{border-color:#f85149;background:rgba(248,81,73,0.15)}\n.toast.success{border-color:#238636;background:rgba(35,134,54,0.15)}\n.ptr-indicator{position:absolute;top:0;left:0;width:100%;color:#8b949e;font-size:14px;text-align:center;padding:12px 0;pointer-events:none;z-index:5;display:none}\n.ptr-indicator.visible{display:block}\n@media(max-width:480px){.form-card{margin:20px 8px 0;padding:16px}.content{padding:0}.header{padding:8px 10px;gap:4px}.header .hdr-btn{padding:5px 7px;font-size:12px}.compose-fields{padding:10px}.compose-fields.expanded{padding:0}}\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"app\">\n <div class=\"header\" id=\"header\">\n <button class=\"back-btn\" id=\"backBtn\" style=\"display:none\">β</button>\n <h1 id=\"title\">π§ Email</h1>\n <button class=\"hdr-btn acct\" id=\"acctBtn\" style=\"display:none\">π€</button>\n <button class=\"hdr-btn\" id=\"addAcctHdrBtn\" style=\"display:none\">β</button>\n <button class=\"hdr-btn\" id=\"composeBtn\" style=\"display:none\">βοΈ</button>\n <button class=\"hdr-btn\" id=\"refreshBtn\" style=\"display:none\">π</button>\n <button class=\"hdr-btn primary\" id=\"sendHdrBtn\" style=\"display:none\">π€ Send</button>\n <button class=\"hdr-btn danger\" id=\"logoutBtn\" style=\"display:none\">Exit</button>\n </div>\n <div class=\"content\" id=\"content\"><div class=\"ptr-indicator\" id=\"ptrI\"><span id=\"ptrT\">β¬οΈ Hold & pull down to refresh</span></div></div>\n <div class=\"status-bar\" id=\"statusBar\"><span id=\"stLeft\">Not connected</span><span id=\"stRight\">jmap</span></div>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n<script>\nconst SK='msw_tab_jmap_email';\nlet st={accounts:[],activeId:null,view:'accounts',mboxId:null,mboxName:'',emails:[],curEmail:null,comp:{to:'',cc:'',subj:'',body:'',replyId:null},compExp:false,loading:false};\nconst $=id=>document.getElementById(id),co=$('content'),ti=$('title'),bb=$('backBtn'),ab=$('acctBtn'),ab2=$('addAcctHdrBtn'),cb=$('composeBtn'),rb=$('refreshBtn'),lb=$('logoutBtn'),sh=$('sendHdrBtn'),sb=$('statusBar'),sl=$('stLeft'),sr=$('stRight'),to=$('toast');\nlet tt=null;\nfunction toast(msg,type){to.textContent=msg;to.className='toast '+(type||'');to.style.display='block';clearTimeout(tt);tt=setTimeout(()=>{to.style.display='none'},4000)}\nfunction acct(){return st.accounts.find(a=>a.id===st.activeId)}\nfunction save(){try{localStorage.setItem(SK,JSON.stringify({accounts:st.accounts.map(a=>{const{mailboxes,identities,mailboxMap,sendMailboxId,trashMailboxId,...rest}=a;return{...rest,mailboxes:mailboxes.map(m=>({id:m.id,name:m.name,totalEmails:m.totalEmails||m.totalMessages||0,unreadEmails:m.unreadEmails||m.unreadMessages||0,sortOrder:m.sortOrder||0})),identities:identities.map(i=>({id:i.id,email:i.email,name:i.name})),mailboxMap,smb:sendMailboxId,tmb:trashMailboxId}}),activeId:st.activeId}))}catch(e){}}\nfunction load(){try{const r=localStorage.getItem(SK);if(!r){st.accounts=[];st.activeId=null;return}const d=JSON.parse(r);if(!d||typeof d!=='object'||!Array.isArray(d.accounts)){st.accounts=[];st.activeId=null;return}st.accounts=d.accounts.map(a=>{a.mailboxMap=a.mailboxMap||{};a.sendMailboxId=a.smb||null;a.trashMailboxId=a.tmb||null;delete a.smb;delete a.tmb;return a});st.activeId=(d.activeId&&st.accounts.find(a=>a.id===d.activeId))?d.activeId:st.accounts.length>0?st.accounts[0].id:null}catch(e){st.accounts=[];st.activeId=null}}\nfunction actv(){return acct()||(st.accounts[0]||null)}\nfunction selAcct(id){st.activeId=id;save();st.view='mailboxes';render();loadMailboxes()}\nfunction goAccts(){st.view='accounts';render()}\nfunction goFolders(){if(st.view==='emails'||st.view==='edetail'){st.view='mailboxes';render()}}\nfunction goBack(){if(st.view==='edetail'){st.view='emails';render()}else if(st.view==='emails'||st.view==='compose'){st.view='mailboxes';render()}else if(st.view==='mailboxes'){st.view='accounts';render()}}\nfunction upHdr(){const notAccts=st.view!=='accounts';bb.style.display=(st.view==='accounts'||st.view==='compose'||st.view==='login')?'none':'flex';ab.style.display=notAccts?'flex':'none';ab2.style.display=notAccts?'flex':'none';cb.style.display=notAccts&&st.view!=='compose'?'flex':'none';rb.style.display=notAccts&&st.view!=='compose'?'flex':'none';lb.style.display=notAccts?'flex':'none';sh.style.display=st.view==='compose'?'flex':'none';const a=actv();ab.textContent=a?'π€ '+((a.credentials?.username||a.label||'').substring(0,8)):'π€';switch(st.view){case'accounts':ti.textContent='π§ Accounts';break;case'login':ti.textContent='β Add Account';break;case'mailboxes':ti.textContent='π '+((a||{}).credentials?.username||a?.label||'Folders');break;case'emails':ti.textContent='π¨ '+st.mboxName;break;case'edetail':ti.textContent='π¬ Message';break;case'compose':const t=st.comp.to;ti.textContent=t?'βοΈ '+t.split(',')[0].trim():'βοΈ New';break}}\nfunction upSb(){if(st.view==='compose'){sb.classList.add('hidden')}else{sb.classList.remove('hidden');const a=actv();if(a){sl.textContent=a.credentials?.username||a.label||'Logged in';sr.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}else{sl.textContent='No account';sr.textContent='jmap'}}}\nfunction render(){co.className='content'+(st.view==='compose'?' nopad':'');switch(st.view){case'accounts':renderAccts();break;case'login':renderLogin();break;case'mailboxes':renderMboxes();break;case'emails':renderEmails();break;case'edetail':renderDetail();break;case'compose':renderComp();break}upHdr();upSb()}\nfunction escape(str){if(!str)return'';const d=document.createElement('div');d.textContent=str;return d.innerHTML}\nti.addEventListener('click',goFolders);bb.addEventListener('click',goBack);\nlb.addEventListener('click',()=>{st.activeId=null;st.view='accounts';st.curEmail=null;st.emails=[];render();toast('Switched to account list','success')});\nab.addEventListener('click',goAccts);\nab2.addEventListener('click',()=>{st.view='login';render()});\ncb.addEventListener('click',()=>openComp({}));\nrb.addEventListener('click',async()=>{if(st.loading)return;toast('π Refreshing...','');if(st.view==='mailboxes'){await loadMailboxes()}else if(st.view==='emails'){await loadEmails()}else if(st.view==='edetail'&&st.curEmail){await loadDetail(st.curEmail.id)}});\nsh.addEventListener('click',handleSend);\nasync function jmapFetch(path,opts){opts=opts||{};const a=actv();if(!a)throw new Error('No active account');const url=path.startsWith('http')?path:a.serverUrl+path;const h={'Content-Type':'application/json','Accept':'application/json',...opts.headers};if(a.credentials){if(a.credentials.token)h['Authorization']='Bearer '+a.credentials.token;else if(a.credentials.username&&a.credentials.password)h['Authorization']='Basic '+btoa(a.credentials.username+':'+a.credentials.password)}const res=await fetch(url,{method:opts.method||'GET',headers:h,body:opts.body?JSON.stringify(opts.body):undefined});if(!res.ok){let d='';try{const e=await res.json();d=e.detail||e.type||JSON.stringify(e)}catch(e){d=res.statusText}throw new Error('HTTP '+res.status+': '+d)}const t=await res.text();return t?JSON.parse(t):null}\nasync function japi(method,params){params=params||{};const a=actv();if(!a.apiUrl)throw new Error('No API URL');const r=await jmapFetch(a.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls:[[method,params,'0']]}});return r}\nasync function jmulti(calls){const a=actv();if(!a.apiUrl)throw new Error('No API URL');const r=await jmapFetch(a.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls}});return r}\nfunction renderAccts(){if(st.accounts.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π§</div><h3>No email accounts</h3><p>Add an account to get started.</p><button class=\"btn-primary\" style=\"margin-top:16px;padding:12px 24px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:15px;min-height:44px\" id=\"addFirstBtn\">β Add Account</button></div>';$('addFirstBtn')?.addEventListener('click',()=>{st.view='login';render()});return}let html='';for(const a of st.accounts){const isActive=a.id===st.activeId;const badge=isActive?'<span class=\"acct-badge\">Active</span>':'';const email=a.credentials?.username||'';html+='<div class=\"acct-item\" data-id=\"'+a.id+'\"><div class=\"acct-icon\">π§</div><div class=\"acct-info\"><div class=\"acct-name\">'+escape(email)+'</div></div>'+badge+'<button class=\"acct-del\" data-del=\"'+a.id+'\">β</button></div>'}html+='<div class=\"acct-item\" id=\"addAcctBtn\" style=\"color:#58a6ff;justify-content:center;font-weight:500;gap:6px;border-bottom:none\"><span style=\"font-size:20px\">β</span> Add Account</div>';co.innerHTML=html;co.querySelectorAll('.acct-item[data-id]').forEach(el=>{el.addEventListener('click',e=>{if(e.target.closest('.acct-del'))return;selAcct(el.dataset.id)})});co.querySelectorAll('.acct-del').forEach(el=>{el.addEventListener('click',e=>{e.stopPropagation();const id=el.dataset.del;if(st.accounts.length<=1){st.accounts=[];st.activeId=null;save();render();return}if(!confirm('Remove \"'+(st.accounts.find(a=>a.id===id)?.credentials?.username||'')+'\"?'))return;st.accounts=st.accounts.filter(a=>a.id!==id);if(st.activeId===id)st.activeId=st.accounts[0]?.id||null;save();render()})});$('addAcctBtn')?.addEventListener('click',()=>{st.view='login';render()})}\nfunction renderLogin(){co.innerHTML='<div class=\"form-card\"><h2>β Add Account</h2><label for=\"srvUrl\">JMAP Server</label><input type=\"text\" id=\"srvUrl\" placeholder=\"https://jmap.example.com\" value=\"https://jmap.gakoy.com\"><label for=\"uname\">Username (Email)</label><input type=\"text\" id=\"uname\" placeholder=\"your@email.com\" autocomplete=\"username\"><label for=\"pwd\">Password</label><input type=\"password\" id=\"pwd\" placeholder=\"β’β’β’β’β’β’β’β’\" autocomplete=\"current-password\"><button class=\"btn-primary\" id=\"loginBtn\">Add Account & Sign In</button><div class=\"error\" id=\"loginErr\"></div></div>';$('loginBtn').addEventListener('click',handleLogin);$('srvUrl').addEventListener('keydown',e=>{if(e.key==='Enter')$('uname').focus()});$('uname').addEventListener('keydown',e=>{if(e.key==='Enter')$('pwd').focus()});$('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin()});$('srvUrl').focus()}\nasync function handleLogin(){const srv=$('srvUrl').value.trim()||'https://jmap.gakoy.com',uname=$('uname').value.trim(),pwd=$('pwd').value,err=$('loginErr'),btn=$('loginBtn');if(!srv||!uname||!pwd){err.textContent='Please fill in all fields.';err.style.display='block';return}err.style.display='none';btn.disabled=true;btn.textContent='Signing in...';try{const id='acc_'+Date.now();const creds={username:uname,password:pwd};const session=await jmapFetchFrom(srv,creds,'/.well-known/jmap');const a={id,label:uname,serverUrl:srv,credentials:creds,session,apiUrl:session.apiUrl,downloadUrlTpl:session.downloadUrl,accountId:session.primaryAccounts['urn:ietf:params:jmap:mail'],submissionAccountId:session.primaryAccounts['urn:ietf:params:jmap:submission'],identities:[],mailboxes:[],mailboxMap:{},sendMailboxId:null,trashMailboxId:null};st.accounts.push(a);st.activeId=a.id;save();st.view='mailboxes';render();toast('β Account added!','success');await loadIdentities();await loadMailboxes()}catch(e){err.textContent='Failed: '+e.message;err.style.display='block';btn.disabled=false;btn.textContent='Add Account & Sign In'}}\nasync function jmapFetchFrom(srv,creds,path){const url=srv+path;const h={'Content-Type':'application/json','Accept':'application/json'};if(creds.token)h['Authorization']='Bearer '+creds.token;else if(creds.username&&creds.password)h['Authorization']='Basic '+btoa(creds.username+':'+creds.password);const res=await fetch(url,{headers:h});if(!res.ok){let d='';try{const e=await res.json();d=e.detail||e.type||JSON.stringify(e)}catch(e){d=res.statusText}throw new Error('HTTP '+res.status+': '+d)}const t=await res.text();return t?JSON.parse(t):null}\nasync function loadIdentities(){const a=actv();if(!a)return;try{const sub=a.submissionAccountId||a.accountId;const r=await japi('Identity/get',{accountId:sub});a.identities=r.methodResponses[0][1].list||[];save()}catch(e){a.identities=[]}}\nasync function loadMailboxes(){st.loading=true;renderMboxes();const a=actv();if(!a){st.loading=false;render();return}try{const r=await japi('Mailbox/get',{accountId:a.accountId});a.mailboxes=r.methodResponses[0][1].list||[];a.mailboxMap={};let o=null,s=null,d=null,tr=null,f=null;for(const mb of a.mailboxes){a.mailboxMap[mb.id]=mb.name;const n=(mb.name||'').toLowerCase(),ro=(mb.role||'').toLowerCase();if(n==='outbox'||ro==='outbox')o=mb.id;if(n==='sent'||ro==='sent')s=mb.id;if(n==='drafts'||ro==='drafts')d=mb.id;if(n==='trash'||n==='deleted messages'||n==='bin'||ro==='trash')tr=mb.id;if(!f)f=mb.id}a.sendMailboxId=o||s||d||f;a.trashMailboxId=tr;save();st.loading=false;renderMboxes()}catch(e){st.loading=false;toast('Failed to load mailboxes: '+e.message,'error');renderMboxes()}}\nfunction renderMboxes(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading mailboxes...</p></div>';return}const a=actv();if(!a||a.mailboxes.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π</div><h3>No mailboxes</h3></div>';return}const icons={'inbox':'π₯','sent':'π€','drafts':'π','trash':'ποΈ','spam':'β οΈ','archive':'π¦','junk':'β οΈ','outbox':'π€'};const sorted=[...a.mailboxes].sort((x,y)=>(x.sortOrder||0)-(y.sortOrder||0));let html='';for(const mb of sorted){const name=mb.name||'Unnamed',total=mb.totalEmails||mb.totalMessages||0,unread=mb.unreadEmails||mb.unreadMessages||0,icon=Object.entries(icons).find(([k])=>name.toLowerCase().includes(k))?.[1]||'π';html+='<div class=\"mailbox-item\" data-id=\"'+mb.id+'\"><div class=\"mailbox-icon\">'+icon+'</div><div class=\"mailbox-info\"><div class=\"mailbox-name\">'+escape(name)+'</div><div class=\"mailbox-stats\">'+total+' messages</div></div>'+(unread>0?'<div class=\"mailbox-unread\">'+unread+'</div>':'')+'</div>'}co.innerHTML=html;co.querySelectorAll('.mailbox-item').forEach(el=>{el.addEventListener('click',()=>{const id=el.dataset.id,mb=a.mailboxes.find(m=>m.id===id);if(mb){st.mboxId=id;st.mboxName=mb.name;st.view='emails';render();loadEmails()}})})}\nasync function loadEmails(){st.loading=true;renderEmails();const a=actv();if(!a){st.loading=false;render();return}try{const q=await japi('Email/query',{accountId:a.accountId,filter:{inMailbox:st.mboxId},sort:[{property:'receivedAt',isAscending:false}],limit:50});const ids=q.methodResponses[0][1].ids;if(!ids||ids.length===0){st.emails=[];st.loading=false;renderEmails();return}const g=await japi('Email/get',{accountId:a.accountId,ids,properties:['id','subject','from','to','receivedAt','hasAttachment','preview','size','keywords']});st.emails=g.methodResponses[0][1].list||[];st.loading=false;renderEmails()}catch(e){st.loading=false;toast('Failed to load emails: '+e.message,'error');renderEmails()}}\nfunction renderEmails(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading emails...</p></div>';return}if(st.emails.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¨</div><h3>No emails</h3><p>This mailbox is empty.</p></div>';return}let html='<div class=\"email-list\">';for(const e of st.emails){const from=e.from?.[0]?.name||e.from?.[0]?.email||'Unknown',unread=!e.keywords?.$seen,date=new Date(e.receivedAt),ds=date.toLocaleDateString([],{month:'short',day:'numeric'}),ts=date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}),today=new Date().toDateString()===date.toDateString();html+='<div class=\"email-item'+(unread?' unread':'')+'\" data-id=\"'+e.id+'\"><div class=\"del-bg\">ποΈ Delete</div><div class=\"ec\"><div class=\"email-sender\">'+escape(from)+'</div><div class=\"email-subject\">'+escape(e.subject||'(No subject)')+'</div><div class=\"email-preview\">'+escape((e.preview||'').substring(0,100))+'</div><div class=\"email-date\">'+(today?ts:ds)+'</div></div></div>'}html+='</div>';co.innerHTML=html;co.querySelectorAll('.email-item').forEach(el=>{const ec=el.querySelector('.ec');let sx=0,cx=0,dr=false,sw=false;const dbg=el.querySelector('.del-bg');dbg.style.opacity='0';const onS=function(x){sx=x;cx=0;dr=true;sw=false;ec.classList.add('swiping');el.classList.add('swiping')};const onM=function(x){if(!dr)return;const dx=x-sx;if(dx<0){cx=0;ec.style.transform='translateX(0px)';return}cx=Math.min(dx,120);ec.style.transform='translateX('+cx+'px)';dbg.style.opacity=Math.min(cx/80,1)};const onE=function(){dr=false;ec.classList.remove('swiping');el.classList.remove('swiping');if(cx>60){sw=true;delEmail(el.dataset.id,el)}else{ec.style.transform='translateX(0px)';dbg.style.opacity='0'}};el.addEventListener('touchstart',e=>{onS(e.changedTouches[0].screenX)},{passive:true});el.addEventListener('touchmove',e=>{onM(e.changedTouches[0].screenX)},{passive:true});el.addEventListener('touchend',()=>{onE()},{passive:true});el.addEventListener('mousedown',e=>{onS(e.screenX)});el.addEventListener('mousemove',e=>{onM(e.screenX)});el.addEventListener('mouseup',()=>{onE()});el.addEventListener('mouseleave',()=>{if(dr)onE()});el.addEventListener('click',e=>{if(sw){e.stopPropagation();sw=false;return}const id=el.dataset.id,email=st.emails.find(e=>e.id===id);if(email){st.curEmail=email;st.view='edetail';loadDetail(id)}})})}\nfunction removeEmailEl(id){const el=co.querySelector('.email-item[data-id=\"'+id+'\"]');if(el)el.style.display='none'}\nasync function delEmail(id,el){const email=st.emails.find(e=>e.id===id);if(!email)return;st.emails=st.emails.filter(e=>e.id!==id);removeEmailEl(id);toast('ποΈ Deleting...','');const a=actv();if(!a)return;const backup=JSON.parse(JSON.stringify(email));const isTrash=st.mboxId===a.trashMailboxId;try{if(!a.trashMailboxId){toast('ποΈ Removed','success');return}if(isTrash){await japi('Email/set',{accountId:a.accountId,destroy:[id]});toast('ποΈ Permanently deleted','success')}else{const fr=await japi('Email/get',{accountId:a.accountId,ids:[id],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true});const fe=fr.methodResponses[0][1].list?.[0];if(!fe)throw new Error('Could not fetch email data');let bt='';const tb=fe.textBody?.[0];const hb=fe.htmlBody?.[0];if(tb&&fe.bodyValues&&fe.bodyValues[tb.partId])bt=fe.bodyValues[tb.partId].value;if(!bt&&hb&&fe.bodyValues&&fe.bodyValues[hb.partId])bt=fe.bodyValues[hb.partId].value;await japi('Email/set',{accountId:a.accountId,destroy:[id]});const rd={mailboxIds:{[a.trashMailboxId]:true},from:fe.from||email.from,to:fe.to||email.to,cc:fe.cc||email.cc,bcc:fe.bcc||email.bcc,subject:fe.subject||email.subject||'(no subject)',receivedAt:fe.receivedAt||email.receivedAt,keywords:fe.keywords||email.keywords||{},textBody:bt?[{partId:'body-0',type:'text/plain'}]:undefined,bodyValues:{}};if(bt)rd.bodyValues['body-0']={value:bt,charset:'UTF-8'};if(!rd.cc)delete rd.cc;if(!rd.bcc)delete rd.bcc;if(!rd.textBody)delete rd.textBody;if(Object.keys(rd.bodyValues).length===0)delete rd.bodyValues;const cr=await japi('Email/set',{accountId:a.accountId,create:{'ri':rd}});if(!cr.methodResponses[0][1].created?.['ri']){const err=cr.methodResponses[0][1].notCreated?.['ri'];toast('Email destroyed but re-import failed: '+(err?.description||'unknown'),'error')}else{toast('ποΈ Moved to trash','success')}}}catch(e){st.emails.push(backup);st.emails.sort((a,b)=>new Date(b.receivedAt)-new Date(a.receivedAt));if(st.view==='emails')renderEmails();toast('Failed to delete: '+e.message,'error')}}\nasync function loadDetail(id){st.loading=true;renderDetail();const a=actv();if(!a){st.loading=false;render();return}try{const r=await japi('Email/get',{accountId:a.accountId,ids:[id],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true,maxBodyValueLength:10485760});const email=r.methodResponses[0][1].list?.[0];if(email){st.curEmail=email;const tb=email.textBody?.[0];const hb=email.htmlBody?.[0];let fb=null;if(email.bodyValues){if(tb&&email.bodyValues[tb.partId]){fb=email.bodyValues[tb.partId].value}if(!fb&&hb&&email.bodyValues[hb.partId]){fb=email.bodyValues[hb.partId].value}}if(!fb&&tb&&tb.blobId&&a.downloadUrlTpl){try{const dl=a.downloadUrlTpl.replace('{accountId}',encodeURIComponent(a.accountId)).replace('{blobId}',tb.blobId).replace('{name}','body.txt').replace('?type={type}','?type=text/plain');const dr=await fetch(dl,{headers:{'Authorization':'Basic '+btoa(a.credentials.username+':'+a.credentials.password)}});if(dr.ok)fb=await dr.text()}catch(e){}}if(!fb&&hb&&hb.blobId&&a.downloadUrlTpl){try{const dl=a.downloadUrlTpl.replace('{accountId}',encodeURIComponent(a.accountId)).replace('{blobId}',hb.blobId).replace('{name}','body.html').replace('?type={type}','?type=text/html');const dr=await fetch(dl,{headers:{'Authorization':'Basic '+btoa(a.credentials.username+':'+a.credentials.password)}});if(dr.ok)fb=await dr.text()}catch(e){}}if(fb)st.curEmail._fb=fb}st.loading=false;renderDetail();try{await japi('Email/set',{accountId:a.accountId,update:{[id]:{keywords:{$seen:true}}}})}catch(e){}}catch(e){st.loading=false;toast('Failed to load: '+e.message,'error');renderDetail()}}\nfunction renderDetail(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading message...</p></div>';return}const email=st.curEmail;if(!email){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¬</div><h3>No message</h3></div>';return}const from=email.from?.map(f=>f.name||f.email).join(', ')||'Unknown',to=email.to?.map(t=>t.name||t.email).join(', ')||'Unknown',date=email.receivedAt?new Date(email.receivedAt).toLocaleString():'Unknown',subj=email.subject||'(No subject)';let body='';const fb=email._fb;if(fb){body=escape(fb).replace(/\\n/g,'<br>')}else if(email.htmlBody?.[0]?.value){body=email.htmlBody[0].value}else if(email.textBody?.[0]?.value){body=escape(email.textBody[0].value).replace(/\\n/g,'<br>')}else if(email.preview){body=escape(email.preview)}else{body='<p style=\"color:#8b949e\">(No content)</p>'}co.innerHTML='<div class=\"email-detail\"><div class=\"email-detail-subject\">'+escape(subj)+'</div><div class=\"email-detail-from\">π¬ <strong>From:</strong> '+escape(from)+'</div><div class=\"email-detail-from\">π¨ <strong>To:</strong> '+escape(to)+'</div><div class=\"email-detail-from\">π '+escape(date)+'</div>'+(email.hasAttachment?'<div style=\"margin-top:6px;color:#d29922\">π Has attachments</div>':'')+'<div class=\"email-detail-body\">'+body+'</div><div class=\"email-detail-actions\"><button class=\"act-btn reply\" id=\"rpBtn\">β©οΈ Reply</button><button class=\"act-btn\" id=\"raBtn\">βͺοΈ Reply All</button><button class=\"act-btn\" id=\"fwBtn\">β‘οΈ Forward</button></div></div>';$('rpBtn')?.addEventListener('click',()=>openReply(email));$('raBtn')?.addEventListener('click',()=>openReplyAll(email));$('fwBtn')?.addEventListener('click',()=>openForward(email))}\nfunction openComp(pref){st.comp={to:pref.to||'',cc:pref.cc||'',subj:pref.subj||'',body:pref.body||'',replyId:pref.replyId||null};st.compExp=false;st.view='compose';render()}\nfunction openReply(email){openComp({to:email.from?.[0]?.email||'',subj:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction openReplyAll(email){const to=email.from?.map(f=>f.email).join(', ')||'',cc=email.to?.map(t=>t.email).filter(e=>e!==(actv()?.credentials?.username||'')).join(', ')||'';openComp({to,cc,subj:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction openForward(email){openComp({subj:email.subject?'Fwd: '+email.subject.replace(/^Fwd:\\s*/i,''):'Fwd: ',body:'\\n\\n\\n--- Forwarded message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction renderComp(){const d=st.comp,a=actv();const ue=a?.credentials?.username||'',idn=a?.identities?.[0]?.name||a?.identities?.[0]?.email||ue;if(st.compExp){co.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields expanded\"><div class=\"ce\" id=\"ce\"></div><textarea id=\"cb\" placeholder=\"Write your message...\">'+escape(d.body)+'</textarea></div><div class=\"compose-bar\"><button class=\"reduce-btn\" id=\"rdBtn\">β½ Reduce</button></div></div>';$('rdBtn').addEventListener('click',()=>{st.comp.body=$('cb').value;st.compExp=false;render()});$('cb').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});$('cb').focus()}else{co.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields\"><div class=\"ce\" id=\"ce\"></div><div class=\"ch\"><div class=\"compose-row\"><label for=\"ct\">To</label><input type=\"text\" id=\"ct\" placeholder=\"recipient@example.com\" value=\"'+escape(d.to)+'\"></div><div class=\"compose-row\"><label for=\"cc\">CC</label><input type=\"text\" id=\"cc\" placeholder=\"cc@example.com\" value=\"'+escape(d.cc)+'\"></div><div class=\"compose-row\"><label for=\"cs\">Subj</label><input type=\"text\" id=\"cs\" placeholder=\"Subject\" value=\"'+escape(d.subj)+'\"></div></div><textarea id=\"cb\" placeholder=\"Write your message...\">'+escape(d.body)+'</textarea></div></div>';$('cb').addEventListener('focus',()=>{st.comp.to=$('ct').value;st.comp.cc=$('cc').value;st.comp.subj=$('cs').value;st.comp.body=$('cb').value;st.compExp=true;render()});$('cb').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});if(!d.to)$('ct').focus();else if(!d.subj)$('cs').focus();else $('cb').focus()}}\nasync function handleSend(){let toRaw,ccRaw,subj,body;const a=actv();if(st.compExp){toRaw=st.comp.to;ccRaw=st.comp.cc;subj=st.comp.subj;body=$('cb').value}else{toRaw=$('ct').value.trim();ccRaw=$('cc').value.trim();subj=$('cs').value.trim();body=$('cb').value}const err=$('ce'),btn=sh;if(!toRaw){err.textContent='Please enter at least one recipient.';err.style.display='block';return}err.style.display='none';btn.disabled=true;btn.textContent='π€ Sending...';sh.disabled=true;sh.textContent='π€ Sending...';try{const toA=toRaw.split(',').map(s=>s.trim()).filter(s=>s).map(e=>({email:e})),ccA=ccRaw.split(',').map(s=>s.trim()).filter(s=>s).map(e=>({email:e})),ue=a.credentials.username;if(a.identities.length===0)await loadIdentities();let idn=a.identities.find(i=>i.email===ue);if(!idn&&a.identities.length>0)idn=a.identities[0];if(!idn)throw new Error('No identity found on server.');if(!a.sendMailboxId)throw new Error('No mailbox available.');const ec={mailboxIds:{[a.sendMailboxId]:true},keywords:{$seen:false},from:[{email:idn.email||ue}],to:toA,subject:subj||'(no subject)',textBody:[{partId:'body-0',type:'text/plain'}],bodyValues:{'body-0':{value:body,charset:'UTF-8'}}};if(ccA.length>0)ec.cc=ccA;if(st.comp.replyId)ec.inReplyTo=st.comp.replyId;const sa=a.submissionAccountId||a.accountId,allR=[...toA];if(ccA.length>0)allR.push(...ccA);const mr=await jmulti([['Email/set',{accountId:a.accountId,create:{'ne':ec}},'0'],['EmailSubmission/set',{accountId:sa,create:{'ns':{emailId:'#ne',identityId:idn.id,envelope:{mailFrom:{email:idn.email||ue},rcptTo:allR}}}},'1']]);const esr=mr.methodResponses[0][1],cr=esr.created?.['ne'];if(!cr){const er=esr.notCreated?.['ne'];throw new Error(er?.description||'Failed to create email')}const sr=mr.methodResponses[1][1],sub=sr.created?.['ns'];if(!sub){const er=sr.notCreated?.['ns'],ed=er?(er.description||er.type||JSON.stringify(er)):'Unknown';if(st.comp.replyId&&(ed.includes('oldEmail')||ed.includes('inReplyTo'))){delete ec.inReplyTo;const rr=await jmulti([['Email/set',{accountId:a.accountId,create:{'ne':ec}},'0'],['EmailSubmission/set',{accountId:sa,create:{'ns':{emailId:'#ne',identityId:idn.id,envelope:{mailFrom:{email:idn.email||ue},rcptTo:allR}}}},'1']]);const rs=rr.methodResponses[1][1].created?.['ns'];if(rs){toast('β Email sent!','success');st.view='mailboxes';st.compExp=false;render();return}throw new Error('SMTP rejected: '+(rr.methodResponses[1][1].notCreated?.['ns']?.description||ed))}throw new Error('SMTP rejected: '+ed)}toast('β Email sent!','success');st.view='mailboxes';st.compExp=false;render()}catch(e){err.textContent='Failed to send: '+e.message;err.style.display='block';btn.disabled=false;btn.textContent='π€ Send';sh.disabled=false;sh.textContent='π€ Send'}btn.disabled=false;btn.textContent='π€ Send';sh.disabled=false;sh.textContent='π€ Send'}\nasync function init(){load();if(st.accounts.length>0&&st.activeId){const a=actv();if(a){st.view='mailboxes';render();await loadIdentities();await loadMailboxes()}else{st.view='accounts';render()}}else{st.view='accounts';render()}}\ninit();\n</script>\n</body>\n</html>" | |
| }, | |
| "currentHash": "4043515350a389903fbdfc783b07d30f92110a9ac405ef14ae94f3a713280c5c", | |
| "previousHash": "08724e129ad34d60bbd8ad5aa8ce065bde0ca8d7732ed3fcc4125d0af8c1d86b", | |
| "prompt": "remove account label, we use the username as label." | |
| }, | |
| "3": { | |
| "tab": { | |
| "id": "jmap-email", | |
| "label": "Email", | |
| "html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, viewport-fit=cover\">\n<title>JMAP Email</title>\n<style>\nhtml,body{margin:0;height:100%;background:#0d1117;color:#c9d1d9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif}\n*{box-sizing:border-box}\n.app{display:flex;flex-direction:column;height:100%;max-width:600px;margin:0 auto}\n.header{padding:10px 12px;border-bottom:1px solid #30363d;background:#161b22;display:flex;align-items:center;gap:6px;min-height:52px}\n.header h1{margin:0;font-size:17px;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;-webkit-user-select:none;user-select:none}\n.header h1:active{opacity:.7}\n.header .back-btn{background:none;border:none;color:#58a6ff;font-size:20px;cursor:pointer;padding:8px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}\n.header .hdr-btn{background:none;border:1px solid #30363d;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:13px;min-height:36px;display:flex;align-items:center;gap:3px;white-space:nowrap}\n.header .hdr-btn.primary{background:#238636;color:#fff;border:none;font-weight:600}\n.header .hdr-btn:disabled{opacity:.5;cursor:not-allowed}\n.header .hdr-btn.danger{color:#f85149}\n.header .hdr-btn.acct{color:#58a6ff;max-width:100px;overflow:hidden;text-overflow:ellipsis}\n.content{flex:1;overflow-y:auto;padding:16px}\n.content::-webkit-scrollbar{width:6px}\n.content::-webkit-scrollbar-thumb{background:#30363d;border-radius:3px}\n.content.nopad{padding:0}\n.form-card{max-width:400px;margin:40px auto 0;padding:20px;background:#161b22;border:1px solid #30363d;border-radius:12px}\n.form-card h2{margin:0 0 16px;font-size:18px;text-align:center}\n.form-card label{display:block;margin-bottom:4px;color:#8b949e;font-size:13px}\n.form-card input{width:100%;padding:12px 14px;margin-bottom:14px;min-height:44px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#c9d1d9;font-size:15px;outline:none}\n.form-card input:focus{border-color:#58a6ff}\n.form-card .btn-primary{width:100%;padding:12px;min-height:44px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.form-card .btn-primary:disabled{opacity:.5;cursor:not-allowed}\n.form-card .error{color:#f85149;font-size:13px;padding:8px;background:rgba(248,81,73,0.1);border-radius:6px;display:none}\n.acct-item{display:flex;align-items:center;padding:14px 16px;min-height:52px;border-bottom:1px solid #21262d;cursor:pointer;gap:12px}\n.acct-item:hover{background:#161b22}\n.acct-item .acct-icon{font-size:22px;width:40px;text-align:center}\n.acct-item .acct-info{flex:1}\n.acct-item .acct-name{font-size:15px;font-weight:500}\n.acct-item .acct-email{font-size:12px;color:#8b949e;margin-top:2px}\n.acct-item .acct-badge{background:#238636;color:#fff;border-radius:10px;padding:2px 8px;font-size:11px;font-weight:600}\n.acct-item .acct-del{background:none;border:none;color:#f85149;font-size:18px;cursor:pointer;padding:8px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}\n.mailbox-item{display:flex;align-items:center;padding:14px 16px;min-height:52px;border-bottom:1px solid #21262d;cursor:pointer;gap:12px}\n.mailbox-item:hover{background:#161b22}\n.mailbox-icon{font-size:20px;width:36px;text-align:center}\n.mailbox-info{flex:1}\n.mailbox-name{font-size:15px;font-weight:500}\n.mailbox-stats{font-size:12px;color:#8b949e;margin-top:2px}\n.mailbox-unread{background:#238636;color:#fff;border-radius:12px;padding:2px 10px;font-size:12px;font-weight:600}\n.email-item{padding:14px 16px;min-height:60px;border-bottom:1px solid #21262d;cursor:pointer;position:relative;background:#0d1117}\n.email-item:hover{background:#161b22}\n.email-item.unread{border-left:3px solid #58a6ff}\n.email-item .del-bg{position:absolute;top:0;right:0;bottom:0;background:#f85149;display:flex;align-items:center;padding:0 20px;font-size:14px;font-weight:600;color:#fff;z-index:0}\n.email-item .ec{position:relative;z-index:1;background:#0d1117;transition:transform .2s ease;padding:0}\n.email-sender{font-size:14px;font-weight:600;color:#e6edf3;margin-bottom:4px}\n.email-subject{font-size:14px;color:#c9d1d9;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.email-preview{font-size:13px;color:#8b949e;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}\n.email-date{font-size:11px;color:#8b949e;margin-top:4px}\n.email-item.unread .email-subject{font-weight:600;color:#e6edf3}\n.email-detail{padding:16px}\n.email-detail-subject{font-size:17px;font-weight:600;margin-bottom:10px}\n.email-detail-from{font-size:13px;color:#8b949e;margin-bottom:3px}\n.email-detail-body{padding:12px 0;line-height:1.6;font-size:14px;word-wrap:break-word}\n.email-detail-actions{display:flex;gap:8px;padding:8px 0;border-top:1px solid #30363d}\n.email-detail-actions .act-btn{flex:1;padding:10px;min-height:44px;border:1px solid #30363d;border-radius:8px;background:none;color:#c9d1d9;font-size:14px;cursor:pointer}\n.email-detail-actions .act-btn:active{background:#161b22}\n.email-detail-actions .reply{color:#58a6ff}\n.compose-form{display:flex;flex-direction:column;height:100%}\n.compose-fields{padding:14px;display:flex;flex-direction:column;height:100%}\n.compose-fields label{display:block;margin-bottom:4px;color:#8b949e;font-size:13px;font-weight:500}\n.compose-fields input,.compose-fields textarea{padding:10px 12px;min-height:44px;background:#0d1117;border:1px solid #30363d;border-radius:8px;color:#c9d1d9;font-size:15px;outline:none;font-family:inherit}\n.compose-row{display:flex;align-items:center;gap:8px;margin-bottom:10px}\n.compose-row label{flex-shrink:0;width:auto;min-width:48px;margin-bottom:0}\n.compose-row input{flex:1;min-width:0}\n.compose-fields textarea{min-height:180px;resize:vertical}\n.compose-fields .send-btn{width:100%;padding:12px;min-height:44px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.compose-fields .send-btn:disabled{opacity:.5;cursor:not-allowed}\n.compose-fields .ce{color:#f85149;font-size:13px;padding:8px;background:rgba(248,81,73,0.1);border-radius:6px;display:none;margin-bottom:10px}\n.compose-fields.expanded{padding:0}\n.compose-fields.expanded .ch,.compose-fields.expanded .cfb{display:none}\n.compose-fields.expanded .ce{margin:8px 8px 0}\n.compose-fields.expanded #cb{min-height:0;flex:1;margin-bottom:0;border-radius:0;border-left:none;border-right:none;border-top:1px solid #30363d;resize:none;padding:16px;font-size:16px}\n.compose-bar{display:flex;gap:8px;padding:10px 12px;border-top:1px solid #30363d;background:#161b22}\n.compose-bar .send-btn{flex:1;padding:12px;min-height:48px;font-size:15px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600}\n.compose-bar .send-btn:disabled{opacity:.5;cursor:not-allowed}\n.compose-bar .reduce-btn{min-width:80px;padding:12px;min-height:48px;font-size:14px;background:none;color:#8b949e;border:1px solid #30363d;border-radius:8px;cursor:pointer}\n.loading{text-align:center;padding:40px 16px;color:#8b949e}\n.loading .spinner{display:inline-block;width:24px;height:24px;border:3px solid #30363d;border-top-color:#58a6ff;border-radius:50%;animation:spin .8s linear infinite;margin-bottom:12px}\n@keyframes spin{to{transform:rotate(360deg)}}\n.empty-state{text-align:center;padding:60px 20px;color:#8b949e}\n.empty-state .icon{font-size:48px;margin-bottom:16px}\n.empty-state h3{font-size:18px;margin:0 0 8px;color:#c9d1d9}\n.status-bar{padding:6px 12px;background:#161b22;border-top:1px solid #30363d;font-size:11px;color:#8b949e;display:flex;justify-content:space-between;min-height:32px;align-items:center}\n.status-bar.hidden{display:none}\n.toast{position:fixed;bottom:80px;left:50%;transform:translateX(-50%);background:#161b22;border:1px solid #30363d;padding:10px 20px;border-radius:8px;font-size:14px;color:#c9d1d9;z-index:100;display:none;max-width:90%;text-align:center}\n.toast.error{border-color:#f85149;background:rgba(248,81,73,0.15)}\n.toast.success{border-color:#238636;background:rgba(35,134,54,0.15)}\n.ptr-indicator{position:absolute;top:0;left:0;width:100%;color:#8b949e;font-size:14px;text-align:center;padding:12px 0;pointer-events:none;z-index:5;display:none}\n.ptr-indicator.visible{display:block}\n@media(max-width:480px){.form-card{margin:20px 8px 0;padding:16px}.content{padding:0}.header{padding:8px 10px;gap:4px}.header .hdr-btn{padding:5px 7px;font-size:12px}.compose-fields{padding:10px}.compose-fields.expanded{padding:0}}\n</style>\n</head>\n<body>\n<div class=\"app\" id=\"app\">\n <div class=\"header\" id=\"header\">\n <button class=\"back-btn\" id=\"backBtn\" style=\"display:none\">β</button>\n <h1 id=\"title\">π§ Email</h1>\n <button class=\"hdr-btn acct\" id=\"acctBtn\" style=\"display:none\">π€</button>\n <button class=\"hdr-btn\" id=\"addAcctHdrBtn\" style=\"display:none\">β</button>\n <button class=\"hdr-btn\" id=\"composeBtn\" style=\"display:none\">βοΈ</button>\n <button class=\"hdr-btn\" id=\"refreshBtn\" style=\"display:none\">π</button>\n <button class=\"hdr-btn primary\" id=\"sendHdrBtn\" style=\"display:none\">π€ Send</button>\n <button class=\"hdr-btn danger\" id=\"logoutBtn\" style=\"display:none\">Exit</button>\n </div>\n <div class=\"content\" id=\"content\"><div class=\"ptr-indicator\" id=\"ptrI\"><span id=\"ptrT\">β¬οΈ Hold & pull down to refresh</span></div></div>\n <div class=\"status-bar\" id=\"statusBar\"><span id=\"stLeft\">Not connected</span><span id=\"stRight\">jmap</span></div>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n<script>\nconst SK='msw_tab_jmap_email';\nlet st={accounts:[],activeId:null,view:'accounts',mboxId:null,mboxName:'',emails:[],curEmail:null,comp:{to:'',cc:'',subj:'',body:'',replyId:null},compExp:false,loading:false};\nconst $=id=>document.getElementById(id),co=$('content'),ti=$('title'),bb=$('backBtn'),ab=$('acctBtn'),ab2=$('addAcctHdrBtn'),cb=$('composeBtn'),rb=$('refreshBtn'),lb=$('logoutBtn'),sh=$('sendHdrBtn'),sb=$('statusBar'),sl=$('stLeft'),sr=$('stRight'),to=$('toast');\nlet tt=null;\nfunction toast(msg,type){to.textContent=msg;to.className='toast '+(type||'');to.style.display='block';clearTimeout(tt);tt=setTimeout(()=>{to.style.display='none'},4000)}\nfunction acct(){return st.accounts.find(a=>a.id===st.activeId)}\nfunction save(){try{localStorage.setItem(SK,JSON.stringify({accounts:st.accounts.map(a=>{const{mailboxes,identities,mailboxMap,sendMailboxId,trashMailboxId,...rest}=a;return{...rest,mailboxes:mailboxes.map(m=>({id:m.id,name:m.name,totalEmails:m.totalEmails||m.totalMessages||0,unreadEmails:m.unreadEmails||m.unreadMessages||0,sortOrder:m.sortOrder||0})),identities:identities.map(i=>({id:i.id,email:i.email,name:i.name})),mailboxMap,smb:sendMailboxId,tmb:trashMailboxId}}),activeId:st.activeId}))}catch(e){}}\nfunction load(){try{const r=localStorage.getItem(SK);if(!r){st.accounts=[];st.activeId=null;return}const d=JSON.parse(r);if(!d||typeof d!=='object'||!Array.isArray(d.accounts)){st.accounts=[];st.activeId=null;return}st.accounts=d.accounts.map(a=>{a.mailboxMap=a.mailboxMap||{};a.sendMailboxId=a.smb||null;a.trashMailboxId=a.tmb||null;delete a.smb;delete a.tmb;return a});st.activeId=(d.activeId&&st.accounts.find(a=>a.id===d.activeId))?d.activeId:st.accounts.length>0?st.accounts[0].id:null}catch(e){st.accounts=[];st.activeId=null}}\nfunction actv(){return acct()||(st.accounts[0]||null)}\nfunction selAcct(id){st.activeId=id;save();st.view='mailboxes';render();loadMailboxes()}\nfunction goAccts(){st.view='accounts';render()}\nfunction goFolders(){if(st.view==='emails'||st.view==='edetail'){st.view='mailboxes';render()}}\nfunction goBack(){if(st.view==='edetail'){st.view='emails';render()}else if(st.view==='emails'||st.view==='compose'){st.view='mailboxes';render()}else if(st.view==='mailboxes'){st.view='accounts';render()}}\nfunction upHdr(){const notAccts=st.view!=='accounts';bb.style.display=(st.view==='accounts'||st.view==='compose'||st.view==='login')?'none':'flex';ab.style.display=notAccts?'flex':'none';ab2.style.display=notAccts?'flex':'none';cb.style.display=notAccts&&st.view!=='compose'?'flex':'none';rb.style.display=notAccts&&st.view!=='compose'?'flex':'none';lb.style.display=notAccts?'flex':'none';sh.style.display=st.view==='compose'?'flex':'none';const a=actv();ab.textContent=a?'π€ '+((a.credentials?.username||a.label||'').substring(0,8)):'π€';switch(st.view){case'accounts':ti.textContent='π§ Accounts';break;case'login':ti.textContent='β Add Account';break;case'mailboxes':ti.textContent='π '+((a||{}).credentials?.username||a?.label||'Folders');break;case'emails':ti.textContent='π¨ '+st.mboxName;break;case'edetail':ti.textContent='π¬ Message';break;case'compose':const t=st.comp.to;ti.textContent=t?'βοΈ '+t.split(',')[0].trim():'βοΈ New';break}}\nfunction upSb(){if(st.view==='compose'){sb.classList.add('hidden')}else{sb.classList.remove('hidden');const a=actv();if(a){sl.textContent=a.credentials?.username||a.label||'Logged in';sr.textContent=new Date().toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}else{sl.textContent='No account';sr.textContent='jmap'}}}\nfunction render(){co.className='content'+(st.view==='compose'?' nopad':'');switch(st.view){case'accounts':renderAccts();break;case'login':renderLogin();break;case'mailboxes':renderMboxes();break;case'emails':renderEmails();break;case'edetail':renderDetail();break;case'compose':renderComp();break}upHdr();upSb()}\nfunction escape(str){if(!str)return'';const d=document.createElement('div');d.textContent=str;return d.innerHTML}\nti.addEventListener('click',goFolders);bb.addEventListener('click',goBack);\nlb.addEventListener('click',()=>{st.activeId=null;st.view='accounts';st.curEmail=null;st.emails=[];render();toast('Switched to account list','success')});\nab.addEventListener('click',goAccts);\nab2.addEventListener('click',()=>{st.view='login';render()});\ncb.addEventListener('click',()=>openComp({}));\nrb.addEventListener('click',async()=>{if(st.loading)return;toast('π Refreshing...','');if(st.view==='mailboxes'){await loadMailboxes()}else if(st.view==='emails'){await loadEmails()}else if(st.view==='edetail'&&st.curEmail){await loadDetail(st.curEmail.id)}});\nsh.addEventListener('click',handleSend);\nasync function jmapFetch(path,opts){opts=opts||{};const a=actv();if(!a)throw new Error('No active account');const url=path.startsWith('http')?path:a.serverUrl+path;const h={'Content-Type':'application/json','Accept':'application/json',...opts.headers};if(a.credentials){if(a.credentials.token)h['Authorization']='Bearer '+a.credentials.token;else if(a.credentials.username&&a.credentials.password)h['Authorization']='Basic '+btoa(a.credentials.username+':'+a.credentials.password)}const res=await fetch(url,{method:opts.method||'GET',headers:h,body:opts.body?JSON.stringify(opts.body):undefined});if(!res.ok){let d='';try{const e=await res.json();d=e.detail||e.type||JSON.stringify(e)}catch(e){d=res.statusText}throw new Error('HTTP '+res.status+': '+d)}const t=await res.text();return t?JSON.parse(t):null}\nasync function japi(method,params){params=params||{};const a=actv();if(!a.apiUrl)throw new Error('No API URL');const r=await jmapFetch(a.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls:[[method,params,'0']]}});return r}\nasync function jmulti(calls){const a=actv();if(!a.apiUrl)throw new Error('No API URL');const r=await jmapFetch(a.apiUrl,{method:'POST',body:{using:['urn:ietf:params:jmap:core','urn:ietf:params:jmap:mail','urn:ietf:params:jmap:submission'],methodCalls}});return r}\nfunction renderAccts(){if(st.accounts.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π§</div><h3>No email accounts</h3><p>Add an account to get started.</p><button class=\"btn-primary\" style=\"margin-top:16px;padding:12px 24px;background:#238636;color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:15px;min-height:44px\" id=\"addFirstBtn\">β Add Account</button></div>';$('addFirstBtn')?.addEventListener('click',()=>{st.view='login';render()});return}let html='';for(const a of st.accounts){const isActive=a.id===st.activeId;const badge=isActive?'<span class=\"acct-badge\">Active</span>':'';const email=a.credentials?.username||'';html+='<div class=\"acct-item\" data-id=\"'+a.id+'\"><div class=\"acct-icon\">π§</div><div class=\"acct-info\"><div class=\"acct-name\">'+escape(email)+'</div></div>'+badge+'<button class=\"acct-del\" data-del=\"'+a.id+'\">β</button></div>'}html+='<div class=\"acct-item\" id=\"addAcctBtn\" style=\"color:#58a6ff;justify-content:center;font-weight:500;gap:6px;border-bottom:none\"><span style=\"font-size:20px\">β</span> Add Account</div>';co.innerHTML=html;co.querySelectorAll('.acct-item[data-id]').forEach(el=>{el.addEventListener('click',e=>{if(e.target.closest('.acct-del'))return;selAcct(el.dataset.id)})});co.querySelectorAll('.acct-del').forEach(el=>{el.addEventListener('click',e=>{e.stopPropagation();const id=el.dataset.del;if(st.accounts.length<=1){st.accounts=[];st.activeId=null;save();render();return}if(!confirm('Remove \"'+(st.accounts.find(a=>a.id===id)?.credentials?.username||'')+'\"?'))return;st.accounts=st.accounts.filter(a=>a.id!==id);if(st.activeId===id)st.activeId=st.accounts[0]?.id||null;save();render()})});$('addAcctBtn')?.addEventListener('click',()=>{st.view='login';render()})}\nfunction renderLogin(){co.innerHTML='<div class=\"form-card\"><h2>β Add Account</h2><label for=\"srvUrl\">JMAP Server</label><input type=\"text\" id=\"srvUrl\" placeholder=\"https://jmap.example.com\" value=\"https://jmap.gakoy.com\"><label for=\"uname\">Username (Email)</label><input type=\"text\" id=\"uname\" placeholder=\"your@email.com\" autocomplete=\"username\"><label for=\"pwd\">Password</label><input type=\"password\" id=\"pwd\" placeholder=\"β’β’β’β’β’β’β’β’\" autocomplete=\"current-password\"><button class=\"btn-primary\" id=\"loginBtn\">Add Account & Sign In</button><div class=\"error\" id=\"loginErr\"></div></div>';$('loginBtn').addEventListener('click',handleLogin);$('srvUrl').addEventListener('keydown',e=>{if(e.key==='Enter')$('uname').focus()});$('uname').addEventListener('keydown',e=>{if(e.key==='Enter')$('pwd').focus()});$('pwd').addEventListener('keydown',e=>{if(e.key==='Enter')handleLogin()});$('srvUrl').focus()}\nasync function handleLogin(){const srv=$('srvUrl').value.trim()||'https://jmap.gakoy.com',uname=$('uname').value.trim(),pwd=$('pwd').value,err=$('loginErr'),btn=$('loginBtn');if(!srv||!uname||!pwd){err.textContent='Please fill in all fields.';err.style.display='block';return}err.style.display='none';btn.disabled=true;btn.textContent='Signing in...';try{const id='acc_'+Date.now();const creds={username:uname,password:pwd};const session=await jmapFetchFrom(srv,creds,'/.well-known/jmap');const a={id,label:uname,serverUrl:srv,credentials:creds,session,apiUrl:session.apiUrl,downloadUrlTpl:session.downloadUrl,accountId:session.primaryAccounts['urn:ietf:params:jmap:mail'],submissionAccountId:session.primaryAccounts['urn:ietf:params:jmap:submission'],identities:[],mailboxes:[],mailboxMap:{},sendMailboxId:null,trashMailboxId:null};st.accounts.push(a);st.activeId=a.id;save();st.view='mailboxes';render();toast('β Account added!','success');await loadIdentities();await loadMailboxes()}catch(e){err.textContent='Failed: '+e.message;err.style.display='block';btn.disabled=false;btn.textContent='Add Account & Sign In'}}\nasync function jmapFetchFrom(srv,creds,path){const url=srv+path;const h={'Content-Type':'application/json','Accept':'application/json'};if(creds.token)h['Authorization']='Bearer '+creds.token;else if(creds.username&&creds.password)h['Authorization']='Basic '+btoa(creds.username+':'+creds.password);const res=await fetch(url,{headers:h});if(!res.ok){let d='';try{const e=await res.json();d=e.detail||e.type||JSON.stringify(e)}catch(e){d=res.statusText}throw new Error('HTTP '+res.status+': '+d)}const t=await res.text();return t?JSON.parse(t):null}\nasync function loadIdentities(){const a=actv();if(!a)return;try{const sub=a.submissionAccountId||a.accountId;const r=await japi('Identity/get',{accountId:sub});a.identities=r.methodResponses[0][1].list||[];save()}catch(e){a.identities=[]}}\nasync function loadMailboxes(){st.loading=true;renderMboxes();const a=actv();if(!a){st.loading=false;render();return}try{const r=await japi('Mailbox/get',{accountId:a.accountId});a.mailboxes=r.methodResponses[0][1].list||[];a.mailboxMap={};let o=null,s=null,d=null,tr=null,f=null;for(const mb of a.mailboxes){a.mailboxMap[mb.id]=mb.name;const n=(mb.name||'').toLowerCase(),ro=(mb.role||'').toLowerCase();if(n==='outbox'||ro==='outbox')o=mb.id;if(n==='sent'||ro==='sent')s=mb.id;if(n==='drafts'||ro==='drafts')d=mb.id;if(n==='trash'||n==='deleted messages'||n==='bin'||ro==='trash')tr=mb.id;if(!f)f=mb.id}a.sendMailboxId=o||s||d||f;a.trashMailboxId=tr;save();st.loading=false;renderMboxes()}catch(e){st.loading=false;toast('Failed to load mailboxes: '+e.message,'error');renderMboxes()}}\nfunction renderMboxes(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading mailboxes...</p></div>';return}const a=actv();if(!a||a.mailboxes.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π</div><h3>No mailboxes</h3></div>';return}const icons={'inbox':'π₯','sent':'π€','drafts':'π','trash':'ποΈ','spam':'β οΈ','archive':'π¦','junk':'β οΈ','outbox':'π€'};const sorted=[...a.mailboxes].sort((x,y)=>(x.sortOrder||0)-(y.sortOrder||0));let html='';for(const mb of sorted){const name=mb.name||'Unnamed',total=mb.totalEmails||mb.totalMessages||0,unread=mb.unreadEmails||mb.unreadMessages||0,icon=Object.entries(icons).find(([k])=>name.toLowerCase().includes(k))?.[1]||'π';html+='<div class=\"mailbox-item\" data-id=\"'+mb.id+'\"><div class=\"mailbox-icon\">'+icon+'</div><div class=\"mailbox-info\"><div class=\"mailbox-name\">'+escape(name)+'</div><div class=\"mailbox-stats\">'+total+' messages</div></div>'+(unread>0?'<div class=\"mailbox-unread\">'+unread+'</div>':'')+'</div>'}co.innerHTML=html;co.querySelectorAll('.mailbox-item').forEach(el=>{el.addEventListener('click',()=>{const id=el.dataset.id,mb=a.mailboxes.find(m=>m.id===id);if(mb){st.mboxId=id;st.mboxName=mb.name;st.view='emails';render();loadEmails()}})})}\nasync function loadEmails(){st.loading=true;renderEmails();const a=actv();if(!a){st.loading=false;render();return}try{const q=await japi('Email/query',{accountId:a.accountId,filter:{inMailbox:st.mboxId},sort:[{property:'receivedAt',isAscending:false}],limit:50});const ids=q.methodResponses[0][1].ids;if(!ids||ids.length===0){st.emails=[];st.loading=false;renderEmails();return}const g=await japi('Email/get',{accountId:a.accountId,ids,properties:['id','subject','from','to','receivedAt','hasAttachment','preview','size','keywords']});st.emails=g.methodResponses[0][1].list||[];st.loading=false;renderEmails()}catch(e){st.loading=false;toast('Failed to load emails: '+e.message,'error');renderEmails()}}\nfunction renderEmails(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading emails...</p></div>';return}if(st.emails.length===0){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¨</div><h3>No emails</h3><p>This mailbox is empty.</p></div>';return}let html='<div class=\"email-list\">';for(const e of st.emails){const from=e.from?.[0]?.name||e.from?.[0]?.email||'Unknown',unread=!e.keywords?.$seen,date=new Date(e.receivedAt),ds=date.toLocaleDateString([],{month:'short',day:'numeric'}),ts=date.toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}),today=new Date().toDateString()===date.toDateString();html+='<div class=\"email-item'+(unread?' unread':'')+'\" data-id=\"'+e.id+'\"><div class=\"del-bg\">ποΈ Delete</div><div class=\"ec\"><div class=\"email-sender\">'+escape(from)+'</div><div class=\"email-subject\">'+escape(e.subject||'(No subject)')+'</div><div class=\"email-preview\">'+escape((e.preview||'').substring(0,100))+'</div><div class=\"email-date\">'+(today?ts:ds)+'</div></div></div>'}html+='</div>';co.innerHTML=html;co.querySelectorAll('.email-item').forEach(el=>{const ec=el.querySelector('.ec');let sx=0,cx=0,dr=false,sw=false;const dbg=el.querySelector('.del-bg');dbg.style.opacity='0';const onS=function(x){sx=x;cx=0;dr=true;sw=false;ec.classList.add('swiping');el.classList.add('swiping')};const onM=function(x){if(!dr)return;const dx=x-sx;if(dx<0){cx=0;ec.style.transform='translateX(0px)';return}cx=Math.min(dx,120);ec.style.transform='translateX('+cx+'px)';dbg.style.opacity=Math.min(cx/80,1)};const onE=function(){dr=false;ec.classList.remove('swiping');el.classList.remove('swiping');if(cx>60){sw=true;delEmail(el.dataset.id,el)}else{ec.style.transform='translateX(0px)';dbg.style.opacity='0'}};el.addEventListener('touchstart',e=>{onS(e.changedTouches[0].screenX)},{passive:true});el.addEventListener('touchmove',e=>{onM(e.changedTouches[0].screenX)},{passive:true});el.addEventListener('touchend',()=>{onE()},{passive:true});el.addEventListener('mousedown',e=>{onS(e.screenX)});el.addEventListener('mousemove',e=>{onM(e.screenX)});el.addEventListener('mouseup',()=>{onE()});el.addEventListener('mouseleave',()=>{if(dr)onE()});el.addEventListener('click',e=>{if(sw){e.stopPropagation();sw=false;return}const id=el.dataset.id,email=st.emails.find(e=>e.id===id);if(email){st.curEmail=email;st.view='edetail';loadDetail(id)}})})}\nfunction removeEmailEl(id){const el=co.querySelector('.email-item[data-id=\"'+id+'\"]');if(el)el.style.display='none'}\nasync function delEmail(id,el){const email=st.emails.find(e=>e.id===id);if(!email)return;st.emails=st.emails.filter(e=>e.id!==id);removeEmailEl(id);toast('ποΈ Deleting...','');const a=actv();if(!a)return;const backup=JSON.parse(JSON.stringify(email));const isTrash=st.mboxId===a.trashMailboxId;try{if(!a.trashMailboxId){toast('ποΈ Removed','success');return}if(isTrash){await japi('Email/set',{accountId:a.accountId,destroy:[id]});toast('ποΈ Permanently deleted','success')}else{const fr=await japi('Email/get',{accountId:a.accountId,ids:[id],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true});const fe=fr.methodResponses[0][1].list?.[0];if(!fe)throw new Error('Could not fetch email data');let bt='';const tb=fe.textBody?.[0];const hb=fe.htmlBody?.[0];if(tb&&fe.bodyValues&&fe.bodyValues[tb.partId])bt=fe.bodyValues[tb.partId].value;if(!bt&&hb&&fe.bodyValues&&fe.bodyValues[hb.partId])bt=fe.bodyValues[hb.partId].value;await japi('Email/set',{accountId:a.accountId,destroy:[id]});const rd={mailboxIds:{[a.trashMailboxId]:true},from:fe.from||email.from,to:fe.to||email.to,cc:fe.cc||email.cc,bcc:fe.bcc||email.bcc,subject:fe.subject||email.subject||'(no subject)',receivedAt:fe.receivedAt||email.receivedAt,keywords:fe.keywords||email.keywords||{},textBody:bt?[{partId:'body-0',type:'text/plain'}]:undefined,bodyValues:{}};if(bt)rd.bodyValues['body-0']={value:bt,charset:'UTF-8'};if(!rd.cc)delete rd.cc;if(!rd.bcc)delete rd.bcc;if(!rd.textBody)delete rd.textBody;if(Object.keys(rd.bodyValues).length===0)delete rd.bodyValues;const cr=await japi('Email/set',{accountId:a.accountId,create:{'ri':rd}});if(!cr.methodResponses[0][1].created?.['ri']){const err=cr.methodResponses[0][1].notCreated?.['ri'];toast('Email destroyed but re-import failed: '+(err?.description||'unknown'),'error')}else{toast('ποΈ Moved to trash','success')}}}catch(e){st.emails.push(backup);st.emails.sort((a,b)=>new Date(b.receivedAt)-new Date(a.receivedAt));if(st.view==='emails')renderEmails();toast('Failed to delete: '+e.message,'error')}}\nasync function loadDetail(id){st.loading=true;renderDetail();const a=actv();if(!a){st.loading=false;render();return}try{const r=await japi('Email/get',{accountId:a.accountId,ids:[id],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true,maxBodyValueLength:10485760});const email=r.methodResponses[0][1].list?.[0];if(email){st.curEmail=email;const tb=email.textBody?.[0];const hb=email.htmlBody?.[0];let fb=null;if(email.bodyValues){if(tb&&email.bodyValues[tb.partId]){fb=email.bodyValues[tb.partId].value}if(!fb&&hb&&email.bodyValues[hb.partId]){fb=email.bodyValues[hb.partId].value}}if(!fb&&tb&&tb.blobId&&a.downloadUrlTpl){try{const dl=a.downloadUrlTpl.replace('{accountId}',encodeURIComponent(a.accountId)).replace('{blobId}',tb.blobId).replace('{name}','body.txt').replace('?type={type}','?type=text/plain');const dr=await fetch(dl,{headers:{'Authorization':'Basic '+btoa(a.credentials.username+':'+a.credentials.password)}});if(dr.ok)fb=await dr.text()}catch(e){}}if(!fb&&hb&&hb.blobId&&a.downloadUrlTpl){try{const dl=a.downloadUrlTpl.replace('{accountId}',encodeURIComponent(a.accountId)).replace('{blobId}',hb.blobId).replace('{name}','body.html').replace('?type={type}','?type=text/html');const dr=await fetch(dl,{headers:{'Authorization':'Basic '+btoa(a.credentials.username+':'+a.credentials.password)}});if(dr.ok)fb=await dr.text()}catch(e){}}if(fb)st.curEmail._fb=fb}st.loading=false;renderDetail();try{await japi('Email/set',{accountId:a.accountId,update:{[id]:{keywords:{$seen:true}}}})}catch(e){}}catch(e){st.loading=false;toast('Failed to load: '+e.message,'error');renderDetail()}}\nfunction renderDetail(){if(st.loading){co.innerHTML='<div class=\"loading\"><div class=\"spinner\"></div><p>Loading message...</p></div>';return}const email=st.curEmail;if(!email){co.innerHTML='<div class=\"empty-state\"><div class=\"icon\">π¬</div><h3>No message</h3></div>';return}const from=email.from?.map(f=>f.name||f.email).join(', ')||'Unknown',to=email.to?.map(t=>t.name||t.email).join(', ')||'Unknown',date=email.receivedAt?new Date(email.receivedAt).toLocaleString():'Unknown',subj=email.subject||'(No subject)';let body='';const fb=email._fb;if(fb){body=escape(fb).replace(/\\n/g,'<br>')}else if(email.htmlBody?.[0]?.value){body=email.htmlBody[0].value}else if(email.textBody?.[0]?.value){body=escape(email.textBody[0].value).replace(/\\n/g,'<br>')}else if(email.preview){body=escape(email.preview)}else{body='<p style=\"color:#8b949e\">(No content)</p>'}co.innerHTML='<div class=\"email-detail\"><div class=\"email-detail-subject\">'+escape(subj)+'</div><div class=\"email-detail-from\">π¬ <strong>From:</strong> '+escape(from)+'</div><div class=\"email-detail-from\">π¨ <strong>To:</strong> '+escape(to)+'</div><div class=\"email-detail-from\">π '+escape(date)+'</div>'+(email.hasAttachment?'<div style=\"margin-top:6px;color:#d29922\">π Has attachments</div>':'')+'<div class=\"email-detail-body\">'+body+'</div><div class=\"email-detail-actions\"><button class=\"act-btn reply\" id=\"rpBtn\">β©οΈ Reply</button><button class=\"act-btn\" id=\"raBtn\">βͺοΈ Reply All</button><button class=\"act-btn\" id=\"fwBtn\">β‘οΈ Forward</button></div></div>';$('rpBtn')?.addEventListener('click',()=>openReply(email));$('raBtn')?.addEventListener('click',()=>openReplyAll(email));$('fwBtn')?.addEventListener('click',()=>openForward(email))}\nfunction openComp(pref){st.comp={to:pref.to||'',cc:pref.cc||'',subj:pref.subj||'',body:pref.body||'',replyId:pref.replyId||null};st.compExp=false;st.view='compose';render()}\nfunction openReply(email){openComp({to:email.from?.[0]?.email||'',subj:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction openReplyAll(email){const to=email.from?.map(f=>f.email).join(', ')||'',cc=email.to?.map(t=>t.email).filter(e=>e!==(actv()?.credentials?.username||'')).join(', ')||'';openComp({to,cc,subj:email.subject?'Re: '+email.subject.replace(/^Re:\\s*/i,''):'Re: ',body:'\\n\\n\\n--- Original message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction openForward(email){openComp({subj:email.subject?'Fwd: '+email.subject.replace(/^Fwd:\\s*/i,''):'Fwd: ',body:'\\n\\n\\n--- Forwarded message ---\\n'+(email.preview||''),replyId:email.id})}\nfunction renderComp(){const d=st.comp,a=actv();const ue=a?.credentials?.username||'',idn=a?.identities?.[0]?.name||a?.identities?.[0]?.email||ue;if(st.compExp){co.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields expanded\"><div class=\"ce\" id=\"ce\"></div><textarea id=\"cb\" placeholder=\"Write your message...\">'+escape(d.body)+'</textarea></div><div class=\"compose-bar\"><button class=\"reduce-btn\" id=\"rdBtn\">β½ Reduce</button></div></div>';$('rdBtn').addEventListener('click',()=>{st.comp.body=$('cb').value;st.compExp=false;render()});$('cb').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});$('cb').focus()}else{co.innerHTML='<div class=\"compose-form\"><div class=\"compose-fields\"><div class=\"ce\" id=\"ce\"></div><div class=\"ch\"><div class=\"compose-row\"><label for=\"ct\">To</label><input type=\"text\" id=\"ct\" placeholder=\"recipient@example.com\" value=\"'+escape(d.to)+'\"></div><div class=\"compose-row\"><label for=\"cc\">CC</label><input type=\"text\" id=\"cc\" placeholder=\"cc@example.com\" value=\"'+escape(d.cc)+'\"></div><div class=\"compose-row\"><label for=\"cs\">Subj</label><input type=\"text\" id=\"cs\" placeholder=\"Subject\" value=\"'+escape(d.subj)+'\"></div></div><textarea id=\"cb\" placeholder=\"Write your message...\">'+escape(d.body)+'</textarea></div></div>';$('cb').addEventListener('focus',()=>{st.comp.to=$('ct').value;st.comp.cc=$('cc').value;st.comp.subj=$('cs').value;st.comp.body=$('cb').value;st.compExp=true;render()});$('cb').addEventListener('keydown',e=>{if((e.ctrlKey||e.metaKey)&&e.key==='Enter')handleSend()});if(!d.to)$('ct').focus();else if(!d.subj)$('cs').focus();else $('cb').focus()}}\nasync function handleSend(){let toRaw,ccRaw,subj,body;const a=actv();if(st.compExp){toRaw=st.comp.to;ccRaw=st.comp.cc;subj=st.comp.subj;body=$('cb').value}else{toRaw=$('ct').value.trim();ccRaw=$('cc').value.trim();subj=$('cs').value.trim();body=$('cb').value}const err=$('ce'),btn=sh;if(!toRaw){err.textContent='Please enter at least one recipient.';err.style.display='block';return}err.style.display='none';btn.disabled=true;btn.textContent='π€ Sending...';sh.disabled=true;sh.textContent='π€ Sending...';try{const toA=toRaw.split(',').map(s=>s.trim()).filter(s=>s).map(e=>({email:e})),ccA=ccRaw.split(',').map(s=>s.trim()).filter(s=>s).map(e=>({email:e})),ue=a.credentials.username;if(a.identities.length===0)await loadIdentities();let idn=a.identities.find(i=>i.email===ue);if(!idn&&a.identities.length>0)idn=a.identities[0];if(!idn)throw new Error('No identity found on server.');if(!a.sendMailboxId)throw new Error('No mailbox available.');const ec={mailboxIds:{[a.sendMailboxId]:true},keywords:{$seen:false},from:[{email:idn.email||ue}],to:toA,subject:subj||'(no subject)',textBody:[{partId:'body-0',type:'text/plain'}],bodyValues:{'body-0':{value:body,charset:'UTF-8'}}};if(ccA.length>0)ec.cc=ccA;if(st.comp.replyId)ec.inReplyTo=st.comp.replyId;const sa=a.submissionAccountId||a.accountId,allR=[...toA];if(ccA.length>0)allR.push(...ccA);const mr=await jmulti([['Email/set',{accountId:a.accountId,create:{'ne':ec}},'0'],['EmailSubmission/set',{accountId:sa,create:{'ns':{emailId:'#ne',identityId:idn.id,envelope:{mailFrom:{email:idn.email||ue},rcptTo:allR}}}},'1']]);const esr=mr.methodResponses[0][1],cr=esr.created?.['ne'];if(!cr){const er=esr.notCreated?.['ne'];throw new Error(er?.description||'Failed to create email')}const sr=mr.methodResponses[1][1],sub=sr.created?.['ns'];if(!sub){const er=sr.notCreated?.['ns'],ed=er?(er.description||er.type||JSON.stringify(er)):'Unknown';if(st.comp.replyId&&(ed.includes('oldEmail')||ed.includes('inReplyTo'))){delete ec.inReplyTo;const rr=await jmulti([['Email/set',{accountId:a.accountId,create:{'ne':ec}},'0'],['EmailSubmission/set',{accountId:sa,create:{'ns':{emailId:'#ne',identityId:idn.id,envelope:{mailFrom:{email:idn.email||ue},rcptTo:allR}}}},'1']]);const rs=rr.methodResponses[1][1].created?.['ns'];if(rs){toast('β Email sent!','success');st.view='mailboxes';st.compExp=false;render();return}throw new Error('SMTP rejected: '+(rr.methodResponses[1][1].notCreated?.['ns']?.description||ed))}throw new Error('SMTP rejected: '+ed)}toast('β Email sent!','success');st.view='mailboxes';st.compExp=false;render()}catch(e){err.textContent='Failed to send: '+e.message;err.style.display='block';btn.disabled=false;btn.textContent='π€ Send';sh.disabled=false;sh.textContent='π€ Send'}btn.disabled=false;btn.textContent='π€ Send';sh.disabled=false;sh.textContent='π€ Send'}\nasync function init(){load();if(st.accounts.length>0&&st.activeId){const a=actv();if(a){st.view='mailboxes';render();await loadIdentities();await loadMailboxes()}else{st.view='accounts';render()}}else{st.view='accounts';render()}}\ninit();\n</script>\n</body>\n</html>" | |
| }, | |
| "currentHash": "67dc659403d11726955c4eb9bd5f8548eb56a8ea24c4080b4b52ef803297ad62", | |
| "previousHash": "4043515350a389903fbdfc783b07d30f92110a9ac405ef14ae94f3a713280c5c", | |
| "prompt": "Published shared tab", | |
| "publishedAt": "2026-08-25T17:48:34.861Z", | |
| "appId": "dfda7e57-b0ee-4ab3-aa45-5005bd0d4af3" | |
| }, | |
| "appId": "dfda7e57-b0ee-4ab3-aa45-5005bd0d4af3" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment