Skip to content

Instantly share code, notes, and snippets.

@monperrus
Created August 22, 2026 20:45
Show Gist options
  • Select an option

  • Save monperrus/a78215e2add07cb597283e9ef5bf7b62 to your computer and use it in GitHub Desktop.

Select an option

Save monperrus/a78215e2add07cb597283e9ef5bf7b62 to your computer and use it in GitHub Desktop.
Martin's Super Webapp tab: Email
{
"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 .login-form .server-info { margin-top: 16px; font-size: 12px; color: #8b949e; text-align: center; }\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 /* Pull-to-refresh indicator */\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\">Ready</span><span id=\"statusRight\">jmap.gakoy.com</span></div>\n</div>\n<div class=\"toast\" id=\"toast\"></div>\n<script>\nconst JMAP_SERVER='https://jmap.gakoy.com',STORAGE_KEY='msw_tab_jmap_email';\nlet state={serverUrl:JMAP_SERVER,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;\n\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 // Only activate on mailboxes/emails view, at scroll top\n if(state.currentView!=='mailboxes'&&state.currentView!=='emails')return;\n if(content.scrollTop>0)return;\n ptrTouchStartY=e.changedTouches[0].screenY;\n ptrLongPress=false;\n ptrPulling=false;\n ptrDy=0;\n ptrEl.classList.remove('visible');\n clearTimeout(ptrTimer);\n ptrTimer=setTimeout(()=>{\n ptrLongPress=true;\n ptrText.textContent='⬇️ Now pull down to refresh';\n ptrEl.classList.add('visible');\n },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){\n // Scrolling upward - cancel long press\n clearTimeout(ptrTimer);\n ptrLongPress=false;\n ptrEl.classList.remove('visible');\n return;\n }\n if(!ptrLongPress){\n // If they moved too much before long press, cancel\n if(dy>15){\n clearTimeout(ptrTimer);\n }\n return;\n }\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){\n // Trigger refresh!\n ptrText.textContent='πŸ”„ Refreshing...';\n doPtrRefresh();\n }else{\n ptrEl.classList.remove('visible');\n ptrEl.style.transform='';\n }\n ptrLongPress=false;\n ptrPulling=false;\n ptrDy=0;\n}\nasync function doPtrRefresh(){\n try{\n if(state.currentView==='mailboxes'){\n await loadMailboxes();\n }else if(state.currentView==='emails'){\n await loadEmails();\n }\n }catch(e){}\n ptrEl.classList.remove('visible');\n ptrEl.style.transform='';\n 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='jmap.gakoy.com'}}}\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=\"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 class=\"server-info\">πŸ”— '+state.serverUrl+'</div></div>';$('loginBtn').addEventListener('click',handleLogin);$('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 $('username').focus();}\nasync function handleLogin(){const username=$('username').value.trim(),password=$('password').value,errorEl=$('loginError'),btn=$('loginBtn');if(!username||!password){errorEl.textContent='Please enter both username and password.';errorEl.style.display='block';return}errorEl.style.display='none';btn.disabled=true;btn.textContent='Signing in...';try{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){\n const el=content.querySelector('.email-item[data-id=\"'+emailId+'\"]');\n if(el)el.style.display='none';\n}\nasync function deleteEmail(emailId,emailEl){\n const email=state.emails.find(e=>e.id===emailId);\n if(!email)return;\n // Optimistic: remove from state AND animate DOM element out\n state.emails=state.emails.filter(e=>e.id!==emailId);\n removeEmailEl(emailId);\n showToast('πŸ—‘οΈ Deleting...','');\n const emailBackup=JSON.parse(JSON.stringify(email));\n const emailBackupIdx=emailBackup._index;\n const isTrash=state.currentMailboxId===state.trashMailboxId;\n try{\n if(!state.trashMailboxId){\n showToast('πŸ—‘οΈ Removed','success');\n return;\n }\n if(isTrash){\n await jmapApiCall('Email/set',{accountId:state.accountId,destroy:[emailId]});\n showToast('πŸ—‘οΈ Permanently deleted','success');\n }else{\n const fullR=await jmapApiCall('Email/get',{accountId:state.accountId,ids:[emailId],bodyProperties:['textBody','htmlBody'],fetchTextBodyValues:true,fetchHTMLBodyValues:true});\n const fullEmail=fullR.methodResponses[0][1].list?.[0];\n if(!fullEmail)throw new Error('Could not fetch email data');\n let bodyText='';\n const textBody=fullEmail.textBody?.[0];\n const htmlBody=fullEmail.htmlBody?.[0];\n if(textBody&&fullEmail.bodyValues&&fullEmail.bodyValues[textBody.partId])bodyText=fullEmail.bodyValues[textBody.partId].value;\n if(!bodyText&&htmlBody&&fullEmail.bodyValues&&fullEmail.bodyValues[htmlBody.partId])bodyText=fullEmail.bodyValues[htmlBody.partId].value;\n await jmapApiCall('Email/set',{accountId:state.accountId,destroy:[emailId]});\n const reimportData={\n mailboxIds:{[state.trashMailboxId]:true},\n from:fullEmail.from||email.from,to:fullEmail.to||email.to,\n cc:fullEmail.cc||email.cc,bcc:fullEmail.bcc||email.bcc,\n subject:fullEmail.subject||email.subject||'(no subject)',\n receivedAt:fullEmail.receivedAt||email.receivedAt,\n keywords:fullEmail.keywords||email.keywords||{},\n textBody:bodyText?[{partId:'body-0',type:'text/plain'}]:undefined,\n bodyValues:{}\n };\n if(bodyText)reimportData.bodyValues['body-0']={value:bodyText,charset:'UTF-8'};\n if(!reimportData.cc)delete reimportData.cc;\n if(!reimportData.bcc)delete reimportData.bcc;\n if(!reimportData.textBody)delete reimportData.textBody;\n if(Object.keys(reimportData.bodyValues).length===0)delete reimportData.bodyValues;\n const cR=await jmapApiCall('Email/set',{accountId:state.accountId,create:{'reimported':reimportData}});\n if(!cR.methodResponses[0][1].created?.['reimported']){\n const err=cR.methodResponses[0][1].notCreated?.['reimported'];\n showToast('Email destroyed but failed to re-import: '+(err?.description||'unknown'),'error');\n }else{\n showToast('πŸ—‘οΈ Moved to trash','success');\n }\n }\n }catch(e){\n // Revert: re-insert into state, re-add DOM element\n state.emails.push(emailBackup);\n state.emails.sort((a,b)=>new Date(b.receivedAt)-new Date(a.receivedAt));\n // Re-render only if the list view is still showing\n if(state.currentView==='emails')renderEmails();\n showToast('Failed to delete: '+e.message,'error');\n }\n}\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));\n const detailEl=content.querySelector('.email-detail');\n if(detailEl){\n let sx=0,ex=0,swiping=false;\n detailEl.addEventListener('touchstart',e=>{sx=e.changedTouches[0].screenX;swiping=true},{passive:true});\n 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});\n detailEl.addEventListener('touchend',()=>{swiping=false;detailEl.classList.remove('swiping');detailEl.style.transform='';const dx=ex-sx;if(dx<-80)goBackToList()},{passive:true});\n }\n}\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()}\nsetupPtr();}\ninit();\n</script>\n</body>\n</html>"
},
"currentHash": "7bd72572df816749a39b2193c614b88f7fb666948302d22468dc7b12782bf480",
"previousHash": "f45175a9b75ce91d63ba20f9adc7c55f866b8d2720cfa74275dcffbd25bffa61",
"prompt": "Restored from history"
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment