Created
August 22, 2026 20:45
-
-
Save monperrus/a78215e2add07cb597283e9ef5bf7b62 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 .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