mirror of
https://github.com/ghostersk/gowebmail.git
synced 2026-04-17 16:46:01 +01:00
first commit
This commit is contained in:
378
web/static/css/gomail.css
Normal file
378
web/static/css/gomail.css
Normal file
@@ -0,0 +1,378 @@
|
||||
/* ---- Reset & Variables ---- */
|
||||
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
||||
:root{
|
||||
--bg:#0d0f14;--surface:#111318;--surface2:#161920;--surface3:#1c1f28;
|
||||
--border:#1e2330;--border2:#252a38;--text:#dde1ed;--text2:#9aa0b8;
|
||||
--muted:#5a6278;--accent:#5b8def;--accent-dim:rgba(91,141,239,.12);
|
||||
--accent-glow:rgba(91,141,239,.2);--danger:#ef4444;--success:#22c55e;
|
||||
--warn:#f59e0b;--star:#f59e0b;
|
||||
--sidebar-w:240px;--panel-w:320px;
|
||||
}
|
||||
html,body{height:100%;background:var(--bg);color:var(--text);font-family:'DM Sans',sans-serif;font-size:14px}
|
||||
|
||||
/* ---- Scrollbar ---- */
|
||||
::-webkit-scrollbar{width:5px}
|
||||
::-webkit-scrollbar-track{background:transparent}
|
||||
::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
|
||||
|
||||
/* ---- Spinner ---- */
|
||||
.spinner{width:20px;height:20px;border:2px solid var(--border2);border-top-color:var(--accent);
|
||||
border-radius:50%;animation:spin .6s linear infinite;margin:40px auto;display:block}
|
||||
.spinner-inline{width:14px;height:14px;border:2px solid var(--border2);border-top-color:var(--accent);
|
||||
border-radius:50%;animation:spin .6s linear infinite;display:inline-block;vertical-align:middle;margin-right:6px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
|
||||
/* ---- Toast ---- */
|
||||
.toast-container{position:fixed;top:16px;right:16px;z-index:300;display:flex;flex-direction:column;gap:6px}
|
||||
.toast{padding:9px 14px;border-radius:8px;font-size:13px;font-weight:500;background:var(--surface2);
|
||||
border:1px solid var(--border2);box-shadow:0 4px 20px rgba(0,0,0,.4);animation:slideIn .2s ease;max-width:320px}
|
||||
.toast.success{border-color:rgba(34,197,94,.4);background:rgba(34,197,94,.08);color:#86efac}
|
||||
.toast.error{border-color:rgba(239,68,68,.4);background:rgba(239,68,68,.08);color:#fca5a5}
|
||||
.toast.warn{border-color:rgba(245,158,11,.4);background:rgba(245,158,11,.08);color:#fde68a}
|
||||
@keyframes slideIn{from{transform:translateX(20px);opacity:0}to{transform:translateX(0);opacity:1}}
|
||||
|
||||
/* ---- Context menu ---- */
|
||||
.ctx-menu{position:fixed;z-index:200;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:8px;padding:4px;box-shadow:0 8px 30px rgba(0,0,0,.5);min-width:190px;display:none}
|
||||
.ctx-menu.open{display:block}
|
||||
.ctx-item{padding:7px 12px;border-radius:5px;font-size:13px;cursor:pointer;
|
||||
transition:background .1s;display:flex;align-items:center;gap:8px;color:var(--text2)}
|
||||
.ctx-item:hover{background:var(--surface3);color:var(--text)}
|
||||
.ctx-item.danger:hover{background:rgba(239,68,68,.1);color:var(--danger)}
|
||||
.ctx-item svg{width:13px;height:13px;fill:currentColor;flex-shrink:0}
|
||||
.ctx-sep{height:1px;background:var(--border);margin:3px 0}
|
||||
|
||||
/* ---- Modal ---- */
|
||||
.modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);backdrop-filter:blur(4px);
|
||||
z-index:100;display:flex;align-items:center;justify-content:center;
|
||||
opacity:0;pointer-events:none;transition:opacity .2s}
|
||||
.modal-overlay.open{opacity:1;pointer-events:all}
|
||||
.modal{width:480px;max-height:90vh;overflow-y:auto;background:var(--surface2);
|
||||
border:1px solid var(--border2);border-radius:14px;padding:26px;
|
||||
transform:scale(.95);transition:transform .2s}
|
||||
.modal-overlay.open .modal{transform:scale(1)}
|
||||
.modal h2{font-family:'DM Serif Display',serif;font-size:20px;font-weight:400;margin-bottom:6px}
|
||||
.modal > p{font-size:13px;color:var(--muted);margin-bottom:18px}
|
||||
.modal-field{margin-bottom:12px}
|
||||
.modal-field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase;
|
||||
letter-spacing:.8px;color:var(--muted);margin-bottom:5px}
|
||||
.modal-field input,.modal-field select,.modal-field textarea{
|
||||
width:100%;padding:8px 11px;background:var(--bg);border:1px solid var(--border);
|
||||
border-radius:7px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;
|
||||
outline:none;transition:border-color .2s}
|
||||
.modal-field input:focus,.modal-field select:focus,.modal-field textarea:focus{border-color:var(--accent)}
|
||||
.modal-field select option{background:var(--surface2)}
|
||||
.modal-field textarea{resize:vertical;min-height:80px}
|
||||
.modal-row{display:flex;gap:10px}
|
||||
.modal-row .modal-field{flex:1}
|
||||
.modal-actions{display:flex;gap:8px;justify-content:flex-end;margin-top:18px}
|
||||
.modal-submit{padding:8px 18px;background:var(--accent);border:none;border-radius:7px;
|
||||
color:white;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.modal-submit:hover{opacity:.85}
|
||||
.modal-submit:disabled{opacity:.5;cursor:default}
|
||||
.modal-cancel{padding:8px 14px;background:none;border:1px solid var(--border2);border-radius:7px;
|
||||
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s}
|
||||
.modal-cancel:hover{background:var(--surface3)}
|
||||
.modal-divider{text-align:center;font-size:11px;color:var(--muted);margin:14px 0;position:relative}
|
||||
.modal-divider::before{content:'';position:absolute;top:50%;left:0;right:0;height:1px;background:var(--border)}
|
||||
.modal-divider span{background:var(--surface2);padding:0 10px;position:relative}
|
||||
|
||||
/* ---- Buttons ---- */
|
||||
.btn-primary{padding:8px 18px;background:var(--accent);border:none;border-radius:7px;
|
||||
color:white;font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.btn-primary:hover{opacity:.85}
|
||||
.btn-primary:disabled{opacity:.5;cursor:default}
|
||||
.btn-secondary{padding:7px 14px;background:none;border:1px solid var(--border2);border-radius:6px;
|
||||
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s}
|
||||
.btn-secondary:hover{background:var(--surface3);color:var(--text)}
|
||||
.btn-danger{padding:7px 14px;background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);
|
||||
border-radius:6px;color:#fca5a5;font-family:'DM Sans',sans-serif;font-size:13px;cursor:pointer;transition:background .15s}
|
||||
.btn-danger:hover{background:rgba(239,68,68,.2)}
|
||||
.icon-btn{background:none;border:none;color:var(--muted);cursor:pointer;padding:4px;
|
||||
border-radius:4px;transition:color .15s,background .15s;display:flex;align-items:center}
|
||||
.icon-btn:hover{color:var(--text);background:var(--surface3)}
|
||||
.icon-btn svg{width:14px;height:14px;fill:currentColor}
|
||||
|
||||
/* ---- Forms ---- */
|
||||
.field{margin-bottom:16px}
|
||||
.field label{display:block;font-size:11px;font-weight:500;text-transform:uppercase;
|
||||
letter-spacing:.8px;color:var(--muted);margin-bottom:6px}
|
||||
.field input,.field select{width:100%;padding:10px 12px;background:var(--bg);border:1px solid var(--border);
|
||||
border-radius:8px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;outline:none;transition:border-color .2s,box-shadow .2s}
|
||||
.field input:focus,.field select:focus{border-color:var(--accent);box-shadow:0 0 0 3px var(--accent-glow)}
|
||||
|
||||
/* ---- Info / Alert banners ---- */
|
||||
.alert{padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:12px}
|
||||
.alert.error{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5}
|
||||
.alert.success{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:#86efac}
|
||||
.alert.warn{background:rgba(245,158,11,.1);border:1px solid rgba(245,158,11,.3);color:#fde68a}
|
||||
.alert.info{background:rgba(91,141,239,.1);border:1px solid rgba(91,141,239,.3);color:#93c5fd}
|
||||
|
||||
/* ---- Badges ---- */
|
||||
.badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:500}
|
||||
.badge.green{background:rgba(34,197,94,.15);color:#86efac}
|
||||
.badge.red{background:rgba(239,68,68,.15);color:#fca5a5}
|
||||
.badge.blue{background:rgba(91,141,239,.15);color:#93c5fd}
|
||||
.badge.amber{background:rgba(245,158,11,.15);color:#fde68a}
|
||||
|
||||
/* ---- Tables ---- */
|
||||
.data-table{width:100%;border-collapse:collapse}
|
||||
.data-table th{padding:8px 12px;text-align:left;font-size:11px;font-weight:500;
|
||||
text-transform:uppercase;letter-spacing:.8px;color:var(--muted);border-bottom:1px solid var(--border)}
|
||||
.data-table td{padding:10px 12px;border-bottom:1px solid var(--border);font-size:13px}
|
||||
.data-table tr:last-child td{border-bottom:none}
|
||||
.data-table tr:hover td{background:var(--surface3)}
|
||||
|
||||
/* ---- Auth pages ---- */
|
||||
body.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;
|
||||
background:radial-gradient(ellipse 80% 60% at 50% -10%,rgba(91,141,239,.08) 0%,transparent 70%),var(--bg)}
|
||||
.auth-card{width:100%;max-width:400px;padding:48px 40px;background:var(--surface);
|
||||
border:1px solid var(--border);border-radius:16px}
|
||||
.auth-card .logo{display:flex;align-items:center;gap:10px;margin-bottom:32px}
|
||||
.auth-card .logo-icon{width:36px;height:36px;background:var(--accent);border-radius:8px;
|
||||
display:flex;align-items:center;justify-content:center}
|
||||
.auth-card .logo-icon svg{width:20px;height:20px;fill:white}
|
||||
.auth-card .logo-text{font-family:'DM Serif Display',serif;font-size:22px}
|
||||
.auth-card h1{font-size:26px;font-weight:300;font-family:'DM Serif Display',serif;margin-bottom:6px}
|
||||
.auth-card .subtitle{color:var(--muted);font-size:14px;margin-bottom:32px}
|
||||
|
||||
/* ---- App layout ---- */
|
||||
body.app-page{overflow:hidden}
|
||||
.app{display:flex;height:100vh}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar{width:var(--sidebar-w);flex-shrink:0;background:var(--surface);
|
||||
border-right:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden}
|
||||
.sidebar-header{padding:16px 14px 12px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.logo{display:flex;align-items:center;gap:8px}
|
||||
.logo-icon{width:26px;height:26px;background:var(--accent);border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;flex-shrink:0}
|
||||
.logo-icon svg{width:14px;height:14px;fill:white}
|
||||
.logo-text{font-family:'DM Serif Display',serif;font-size:17px}
|
||||
.compose-btn{padding:6px 12px;background:var(--accent);border:none;border-radius:6px;
|
||||
color:white;font-family:'DM Sans',sans-serif;font-size:12px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.compose-btn:hover{opacity:.85}
|
||||
.accounts-section{padding:10px 8px 4px;border-bottom:1px solid var(--border)}
|
||||
.section-label{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||
color:var(--muted);padding:0 6px 6px}
|
||||
.account-item{display:flex;align-items:center;gap:7px;padding:5px 6px;border-radius:6px;
|
||||
cursor:pointer;transition:background .1s;position:relative}
|
||||
.account-item:hover{background:var(--surface3)}
|
||||
.account-dot{width:7px;height:7px;border-radius:50%;flex-shrink:0}
|
||||
.account-email{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.account-error-dot{width:6px;height:6px;background:var(--danger);border-radius:50%;flex-shrink:0}
|
||||
.add-account-btn{display:flex;align-items:center;gap:6px;padding:5px 6px;color:var(--accent);
|
||||
font-size:12px;cursor:pointer;border-radius:6px;transition:background .1s;margin-top:2px}
|
||||
.add-account-btn:hover{background:var(--accent-dim)}
|
||||
.nav-section{padding:4px 8px;flex:1;overflow-y:auto}
|
||||
.nav-item{display:flex;align-items:center;gap:9px;padding:7px 8px;border-radius:7px;
|
||||
cursor:pointer;transition:background .1s;color:var(--text2);user-select:none;font-size:13px}
|
||||
.nav-item:hover{background:var(--surface3);color:var(--text)}
|
||||
.nav-item.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.nav-item svg{width:15px;height:15px;flex-shrink:0}
|
||||
.unread-badge{margin-left:auto;background:var(--accent);color:white;font-size:10px;
|
||||
font-weight:600;padding:1px 6px;border-radius:10px;min-width:18px;text-align:center}
|
||||
.nav-folder-header{font-size:10px;font-weight:500;text-transform:uppercase;letter-spacing:1px;
|
||||
color:var(--muted);padding:10px 8px 3px;display:flex;align-items:center;gap:6px}
|
||||
.sidebar-footer{padding:10px 14px;border-top:1px solid var(--border);display:flex;
|
||||
align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.user-info{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.user-name{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.footer-actions{display:flex;gap:4px;flex-shrink:0}
|
||||
|
||||
/* Message list panel */
|
||||
.message-list-panel{width:var(--panel-w);flex-shrink:0;border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;background:var(--surface)}
|
||||
.panel-header{padding:14px 14px 10px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between;flex-shrink:0}
|
||||
.panel-title{font-family:'DM Serif Display',serif;font-size:17px}
|
||||
.panel-count{font-size:12px;color:var(--muted)}
|
||||
.search-bar{padding:8px 10px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.search-wrap{position:relative}
|
||||
.search-wrap svg{position:absolute;left:9px;top:50%;transform:translateY(-50%);
|
||||
width:13px;height:13px;fill:var(--muted);pointer-events:none}
|
||||
.search-input{width:100%;padding:6px 9px 6px 30px;background:var(--surface3);
|
||||
border:1px solid var(--border2);border-radius:6px;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none;transition:border-color .2s}
|
||||
.search-input:focus{border-color:var(--accent)}
|
||||
.search-input::placeholder{color:var(--muted)}
|
||||
.message-list{flex:1;overflow-y:auto}
|
||||
.message-item{padding:10px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;position:relative}
|
||||
.message-item:hover{background:var(--surface2)}
|
||||
.message-item.active{background:var(--accent-dim);border-left:2px solid var(--accent);padding-left:10px}
|
||||
.message-item.unread .msg-subject{font-weight:500;color:var(--text)}
|
||||
.message-item.unread::before{content:'';position:absolute;left:0;top:50%;transform:translateY(-50%);
|
||||
width:3px;height:22px;background:var(--accent);border-radius:0 2px 2px 0}
|
||||
.message-item.unread.active::before{display:none}
|
||||
.msg-top{display:flex;align-items:center;justify-content:space-between;gap:6px;margin-bottom:2px}
|
||||
.msg-from{font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1}
|
||||
.msg-date{font-size:11px;color:var(--muted);flex-shrink:0}
|
||||
.msg-subject{font-size:12px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-bottom:2px}
|
||||
.msg-preview{font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.msg-meta{display:flex;align-items:center;gap:5px;margin-top:3px}
|
||||
.msg-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
|
||||
.msg-acct{font-size:10px;color:var(--muted)}
|
||||
.msg-star{margin-left:auto;color:var(--muted);font-size:11px;cursor:pointer}
|
||||
.msg-star.on{color:var(--star)}
|
||||
.load-more{padding:10px;text-align:center}
|
||||
.load-more-btn{background:none;border:1px solid var(--border2);color:var(--accent);
|
||||
padding:6px 18px;border-radius:6px;cursor:pointer;font-size:12px;transition:background .15s}
|
||||
.load-more-btn:hover{background:var(--accent-dim)}
|
||||
.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
height:200px;color:var(--muted);gap:8px}
|
||||
.empty-state svg{width:36px;height:36px;fill:var(--border2)}
|
||||
.empty-state p{font-size:13px}
|
||||
|
||||
/* Message detail */
|
||||
.message-detail{flex:1;display:flex;flex-direction:column;overflow:hidden;background:var(--bg)}
|
||||
.no-message{display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
height:100%;color:var(--muted);gap:10px}
|
||||
.no-message svg{width:48px;height:48px;fill:var(--border2)}
|
||||
.no-message h3{font-family:'DM Serif Display',serif;font-size:20px;color:var(--surface3)}
|
||||
.no-message p{font-size:13px}
|
||||
.detail-header{padding:16px 20px 12px;border-bottom:1px solid var(--border);flex-shrink:0}
|
||||
.detail-subject{font-family:'DM Serif Display',serif;font-size:20px;margin-bottom:10px}
|
||||
.detail-meta{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}
|
||||
.detail-from{font-size:13px}
|
||||
.detail-from strong{color:var(--text)}
|
||||
.detail-from span{color:var(--muted);font-size:12px}
|
||||
.detail-date{font-size:12px;color:var(--muted);flex-shrink:0}
|
||||
.detail-actions{padding:8px 20px;border-bottom:1px solid var(--border);display:flex;gap:6px;flex-shrink:0}
|
||||
.action-btn{padding:5px 12px;background:var(--surface2);border:1px solid var(--border2);border-radius:6px;
|
||||
color:var(--text2);font-family:'DM Sans',sans-serif;font-size:12px;cursor:pointer;transition:background .15s}
|
||||
.action-btn:hover{background:var(--surface3);color:var(--text)}
|
||||
.action-btn.danger:hover{background:rgba(239,68,68,.1);color:var(--danger);border-color:rgba(239,68,68,.3)}
|
||||
.detail-body{flex:1;overflow-y:auto;padding:20px}
|
||||
.detail-body-text{font-size:13px;line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word}
|
||||
.detail-body iframe{width:100%;border:none;min-height:400px}
|
||||
|
||||
/* Compose */
|
||||
.compose-overlay{position:fixed;bottom:20px;right:24px;z-index:50;display:none}
|
||||
.compose-overlay.open{display:block}
|
||||
.compose-window{width:540px;background:var(--surface2);border:1px solid var(--border2);
|
||||
border-radius:12px;box-shadow:0 20px 60px rgba(0,0,0,.6);display:flex;flex-direction:column}
|
||||
.compose-header{padding:12px 16px;border-bottom:1px solid var(--border);
|
||||
display:flex;align-items:center;justify-content:space-between}
|
||||
.compose-title{font-size:14px;font-weight:500}
|
||||
.compose-close{background:none;border:none;color:var(--muted);font-size:18px;cursor:pointer;
|
||||
line-height:1;padding:2px 6px;border-radius:4px}
|
||||
.compose-close:hover{background:var(--surface3);color:var(--text)}
|
||||
.compose-field{display:flex;align-items:center;border-bottom:1px solid var(--border);padding:8px 14px;gap:10px}
|
||||
.compose-field label{font-size:12px;color:var(--muted);width:44px;flex-shrink:0}
|
||||
.compose-field input,.compose-field select{flex:1;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;outline:none}
|
||||
.compose-field select option{background:var(--surface2)}
|
||||
.compose-body textarea{width:100%;height:200px;background:none;border:none;color:var(--text);
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;line-height:1.6;resize:none;outline:none;padding:12px 14px}
|
||||
.compose-footer{padding:8px 14px;border-top:1px solid var(--border);display:flex;align-items:center;gap:8px}
|
||||
.send-btn{padding:7px 20px;background:var(--accent);border:none;border-radius:6px;color:white;
|
||||
font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;cursor:pointer;transition:opacity .15s}
|
||||
.send-btn:hover{opacity:.85}
|
||||
.send-btn:disabled{opacity:.5;cursor:default}
|
||||
|
||||
/* Provider buttons */
|
||||
.provider-btns{display:flex;gap:10px;margin-bottom:14px}
|
||||
.provider-btn{flex:1;padding:10px;background:var(--surface3);border:1px solid var(--border2);
|
||||
border-radius:8px;color:var(--text);font-family:'DM Sans',sans-serif;font-size:13px;font-weight:500;
|
||||
cursor:pointer;transition:background .15s;display:flex;align-items:center;gap:8px;justify-content:center}
|
||||
.provider-btn:hover{background:var(--border2)}
|
||||
.provider-btn:disabled,.provider-btn.unavailable{opacity:.35;cursor:not-allowed}
|
||||
.test-result{padding:8px 12px;border-radius:6px;font-size:12px;margin-bottom:10px;display:none}
|
||||
.test-result.ok{background:rgba(34,197,94,.1);border:1px solid rgba(34,197,94,.3);color:#86efac}
|
||||
.test-result.err{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.3);color:#fca5a5}
|
||||
|
||||
/* ---- Admin layout ---- */
|
||||
body.admin-page{overflow:auto;background:var(--bg)}
|
||||
.admin-layout{display:flex;min-height:100vh}
|
||||
.admin-sidebar{width:220px;flex-shrink:0;background:var(--surface);border-right:1px solid var(--border);
|
||||
display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
|
||||
.admin-sidebar .logo-area{padding:20px 18px;border-bottom:1px solid var(--border)}
|
||||
.admin-sidebar .logo-area a{display:flex;align-items:center;gap:8px;text-decoration:none;color:var(--text)}
|
||||
.admin-nav{padding:8px}
|
||||
.admin-nav a{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:7px;
|
||||
color:var(--text2);text-decoration:none;font-size:13px;transition:background .1s}
|
||||
.admin-nav a:hover{background:var(--surface3);color:var(--text)}
|
||||
.admin-nav a.active{background:var(--accent-dim);color:var(--accent)}
|
||||
.admin-nav a svg{width:15px;height:15px;fill:currentColor;flex-shrink:0}
|
||||
.admin-main{flex:1;padding:32px 36px;max-width:960px}
|
||||
.admin-page-header{margin-bottom:28px}
|
||||
.admin-page-header h1{font-family:'DM Serif Display',serif;font-size:26px;font-weight:400;margin-bottom:4px}
|
||||
.admin-page-header p{font-size:13px;color:var(--muted)}
|
||||
.admin-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;
|
||||
padding:22px 24px;margin-bottom:20px}
|
||||
.admin-card h3{font-size:14px;font-weight:500;margin-bottom:4px}
|
||||
.admin-card .card-desc{font-size:12px;color:var(--muted);margin-bottom:16px}
|
||||
.settings-group{margin-bottom:24px;padding-bottom:24px;border-bottom:1px solid var(--border)}
|
||||
.settings-group:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}
|
||||
.settings-group-title{font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.8px;
|
||||
color:var(--accent);margin-bottom:14px}
|
||||
.setting-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;
|
||||
border-bottom:1px solid var(--border);gap:16px}
|
||||
.setting-row:last-child{border-bottom:none}
|
||||
.setting-label{font-size:13px;font-weight:500;margin-bottom:2px}
|
||||
.setting-desc{font-size:12px;color:var(--muted)}
|
||||
.setting-control{flex-shrink:0;min-width:200px}
|
||||
.setting-control input,.setting-control select{width:100%;padding:7px 10px;background:var(--bg);
|
||||
border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:'DM Sans',sans-serif;
|
||||
font-size:13px;outline:none}
|
||||
.setting-control input:focus,.setting-control select:focus{border-color:var(--accent)}
|
||||
.setting-control input[type=password]{font-family:monospace;letter-spacing:.1em}
|
||||
|
||||
/* ---- Rich text compose editor ---- */
|
||||
.compose-toolbar{display:flex;align-items:center;gap:2px;padding:6px 10px;border-bottom:1px solid var(--border);background:var(--surface3);flex-wrap:wrap}
|
||||
.fmt-btn{background:none;border:none;color:var(--text2);cursor:pointer;padding:4px 7px;border-radius:4px;font-size:13px;line-height:1;transition:background .1s}
|
||||
.fmt-btn:hover{background:var(--border2);color:var(--text)}
|
||||
.fmt-sep{width:1px;height:16px;background:var(--border2);margin:0 3px}
|
||||
.compose-editor{flex:1;min-height:160px;max-height:320px;overflow-y:auto;padding:12px 14px;
|
||||
font-size:13px;line-height:1.6;color:var(--text);outline:none;background:var(--bg)}
|
||||
.compose-editor:empty::before{content:attr(placeholder);color:var(--muted);pointer-events:none}
|
||||
.compose-editor blockquote{border-left:3px solid var(--border2);margin:8px 0;padding-left:12px;color:var(--muted)}
|
||||
.compose-editor .quote-divider{font-size:11px;color:var(--muted);margin:10px 0 4px}
|
||||
.compose-editor a{color:var(--accent)}
|
||||
.compose-editor ul,.compose-editor ol{padding-left:20px}
|
||||
|
||||
/* ---- Remote content banner ---- */
|
||||
.remote-content-banner{display:flex;align-items:center;gap:10px;flex-wrap:wrap;
|
||||
padding:9px 14px;background:rgba(245,158,11,.08);border:1px solid rgba(245,158,11,.3);
|
||||
border-radius:8px;margin-bottom:12px;font-size:13px;color:#fde68a}
|
||||
.rcb-btn{padding:4px 12px;background:rgba(245,158,11,.15);border:1px solid rgba(245,158,11,.4);
|
||||
border-radius:5px;color:#fde68a;cursor:pointer;font-size:12px;white-space:nowrap;transition:background .15s}
|
||||
.rcb-btn:hover{background:rgba(245,158,11,.25)}
|
||||
.rcb-whitelist{margin-left:4px}
|
||||
|
||||
/* ---- Attachment chips ---- */
|
||||
.attachment-chip{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:6px;font-size:12px;cursor:pointer}
|
||||
.attachment-chip:hover{background:var(--border2)}
|
||||
.attachments-bar{display:flex;align-items:center;flex-wrap:wrap;gap:6px;
|
||||
padding:8px 14px;border-bottom:1px solid var(--border)}
|
||||
|
||||
/* ── Email tag input ─────────────────────────────────────────── */
|
||||
.tag-container{display:flex;flex-wrap:wrap;align-items:center;gap:4px;flex:1;
|
||||
padding:4px 6px;min-height:34px;cursor:text;background:var(--bg);
|
||||
border:1px solid var(--border);border-radius:6px}
|
||||
.tag-container:focus-within{border-color:var(--accent);box-shadow:0 0 0 2px rgba(99,102,241,.15)}
|
||||
.compose-tag-field label{flex-shrink:0;align-self:flex-start;padding-top:8px}
|
||||
.email-tag{display:inline-flex;align-items:center;gap:4px;padding:2px 6px 2px 8px;
|
||||
background:var(--surface3);border:1px solid var(--border2);border-radius:12px;
|
||||
font-size:12px;color:var(--text);white-space:nowrap}
|
||||
.email-tag.invalid{background:rgba(239,68,68,.1);border-color:rgba(239,68,68,.4);color:#fca5a5}
|
||||
.tag-remove{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:0;font-size:14px;line-height:1;margin-left:2px}
|
||||
.tag-remove:hover{color:var(--text)}
|
||||
.tag-input{background:none;border:none;outline:none;color:var(--text);font-size:13px;
|
||||
font-family:inherit;min-width:120px;flex:1;padding:1px 0}
|
||||
|
||||
/* ── Compose resize handle ───────────────────────────────────── */
|
||||
#compose-resize-handle{position:absolute;top:0;left:0;right:0;height:5px;
|
||||
cursor:n-resize;border-radius:10px 10px 0 0;z-index:1}
|
||||
#compose-resize-handle:hover{background:var(--accent);opacity:.4}
|
||||
.compose-window{position:relative;display:flex;flex-direction:column;
|
||||
min-width:360px;min-height:280px;resize:none}
|
||||
.compose-attach-list{display:flex;flex-wrap:wrap;gap:6px;padding:6px 14px 0;min-height:0}
|
||||
|
||||
/* ── Icon sync button ─────────────────────────────────────────── */
|
||||
.icon-sync-btn{background:none;border:none;color:var(--muted);cursor:pointer;
|
||||
padding:2px;border-radius:4px;line-height:1;flex-shrink:0;transition:color .15s}
|
||||
.icon-sync-btn:hover{color:var(--text)}
|
||||
311
web/static/js/admin.js
Normal file
311
web/static/js/admin.js
Normal file
@@ -0,0 +1,311 @@
|
||||
// GoMail Admin SPA
|
||||
|
||||
const adminRoutes = {
|
||||
'/admin': renderUsers,
|
||||
'/admin/settings': renderSettings,
|
||||
'/admin/audit': renderAudit,
|
||||
};
|
||||
|
||||
function navigate(path) {
|
||||
history.pushState({}, '', path);
|
||||
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
|
||||
const fn = adminRoutes[path];
|
||||
if (fn) fn();
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', () => {
|
||||
const fn = adminRoutes[location.pathname];
|
||||
if (fn) fn();
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Users
|
||||
// ============================================================
|
||||
async function renderUsers() {
|
||||
const el = document.getElementById('admin-content');
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Users</h1>
|
||||
<p>Manage GoMail accounts and permissions.</p>
|
||||
</div>
|
||||
<div class="admin-card">
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:16px">
|
||||
<button class="btn-primary" onclick="openCreateUser()">+ New User</button>
|
||||
</div>
|
||||
<div id="users-table"><div class="spinner"></div></div>
|
||||
</div>
|
||||
<div class="modal-overlay" id="user-modal">
|
||||
<div class="modal">
|
||||
<h2 id="user-modal-title">New User</h2>
|
||||
<input type="hidden" id="user-id">
|
||||
<div class="modal-field"><label>Username</label><input type="text" id="user-username"></div>
|
||||
<div class="modal-field"><label>Email</label><input type="email" id="user-email"></div>
|
||||
<div class="modal-field"><label id="user-pw-label">Password</label><input type="password" id="user-password" placeholder="Min. 8 characters"></div>
|
||||
<div class="modal-field">
|
||||
<label>Role</label>
|
||||
<select id="user-role">
|
||||
<option value="user">User</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-field" id="user-active-field">
|
||||
<label>Active</label>
|
||||
<select id="user-active"><option value="1">Active</option><option value="0">Disabled</option></select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick="closeModal('user-modal')">Cancel</button>
|
||||
<button class="modal-submit" onclick="saveUser()">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
loadUsersTable();
|
||||
}
|
||||
|
||||
async function loadUsersTable() {
|
||||
const r = await api('GET', '/admin/users');
|
||||
const el = document.getElementById('users-table');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load users</p>'; return; }
|
||||
if (!r.length) { el.innerHTML = '<p style="color:var(--muted);font-size:13px">No users yet.</p>'; return; }
|
||||
el.innerHTML = `<table class="data-table">
|
||||
<thead><tr><th>Username</th><th>Email</th><th>Role</th><th>Status</th><th>Last Login</th><th></th></tr></thead>
|
||||
<tbody>${r.map(u => `
|
||||
<tr>
|
||||
<td style="font-weight:500">${esc(u.username)}</td>
|
||||
<td style="color:var(--muted)">${esc(u.email)}</td>
|
||||
<td><span class="badge ${u.role==='admin'?'blue':'amber'}">${u.role}</span></td>
|
||||
<td><span class="badge ${u.is_active?'green':'red'}">${u.is_active?'Active':'Disabled'}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${u.last_login_at ? new Date(u.last_login_at).toLocaleDateString() : 'Never'}</td>
|
||||
<td style="display:flex;gap:6px;justify-content:flex-end">
|
||||
<button class="btn-secondary" style="padding:4px 10px;font-size:12px" onclick="openEditUser(${u.id})">Edit</button>
|
||||
<button class="btn-danger" style="padding:4px 10px;font-size:12px" onclick="deleteUser(${u.id})">Delete</button>
|
||||
</td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>`;
|
||||
}
|
||||
|
||||
function openCreateUser() {
|
||||
document.getElementById('user-modal-title').textContent = 'New User';
|
||||
document.getElementById('user-id').value = '';
|
||||
document.getElementById('user-username').value = '';
|
||||
document.getElementById('user-email').value = '';
|
||||
document.getElementById('user-password').value = '';
|
||||
document.getElementById('user-role').value = 'user';
|
||||
document.getElementById('user-pw-label').textContent = 'Password';
|
||||
document.getElementById('user-active-field').style.display = 'none';
|
||||
openModal('user-modal');
|
||||
}
|
||||
|
||||
async function openEditUser(userId) {
|
||||
const r = await api('GET', '/admin/users');
|
||||
if (!r) return;
|
||||
const user = r.find(u => u.id === userId);
|
||||
if (!user) return;
|
||||
document.getElementById('user-modal-title').textContent = 'Edit User';
|
||||
document.getElementById('user-id').value = userId;
|
||||
document.getElementById('user-username').value = user.username;
|
||||
document.getElementById('user-email').value = user.email;
|
||||
document.getElementById('user-password').value = '';
|
||||
document.getElementById('user-role').value = user.role;
|
||||
document.getElementById('user-active').value = user.is_active ? '1' : '0';
|
||||
document.getElementById('user-pw-label').textContent = 'New Password (leave blank to keep)';
|
||||
document.getElementById('user-active-field').style.display = 'block';
|
||||
openModal('user-modal');
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
const userId = document.getElementById('user-id').value;
|
||||
const body = {
|
||||
username: document.getElementById('user-username').value.trim(),
|
||||
email: document.getElementById('user-email').value.trim(),
|
||||
role: document.getElementById('user-role').value,
|
||||
is_active: document.getElementById('user-active').value === '1',
|
||||
};
|
||||
const pw = document.getElementById('user-password').value;
|
||||
if (pw) body.password = pw;
|
||||
else if (!userId) { toast('Password required for new users', 'error'); return; }
|
||||
|
||||
const r = userId
|
||||
? await api('PUT', '/admin/users/' + userId, body)
|
||||
: await api('POST', '/admin/users', { ...body, password: pw });
|
||||
|
||||
if (r && r.ok) { toast(userId ? 'User updated' : 'User created', 'success'); closeModal('user-modal'); loadUsersTable(); }
|
||||
else toast((r && r.error) || 'Save failed', 'error');
|
||||
}
|
||||
|
||||
async function deleteUser(userId) {
|
||||
if (!confirm('Delete this user? All their accounts and messages will be deleted.')) return;
|
||||
const r = await api('DELETE', '/admin/users/' + userId);
|
||||
if (r && r.ok) { toast('User deleted', 'success'); loadUsersTable(); }
|
||||
else toast((r && r.error) || 'Delete failed', 'error');
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Settings
|
||||
// ============================================================
|
||||
const SETTINGS_META = [
|
||||
{
|
||||
group: 'Server',
|
||||
fields: [
|
||||
{ key: 'HOSTNAME', label: 'Hostname', desc: 'Public hostname (no protocol or port). e.g. mail.example.com', type: 'text' },
|
||||
{ key: 'LISTEN_ADDR', label: 'Listen Address', desc: 'Bind address e.g. :8080 or 0.0.0.0:8080', type: 'text' },
|
||||
{ key: 'BASE_URL', label: 'Base URL', desc: 'Leave blank to auto-build from hostname + port', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Security',
|
||||
fields: [
|
||||
{ key: 'SECURE_COOKIE', label: 'Secure Cookies', desc: 'Set true when serving over HTTPS', type: 'select', options: ['false','true'] },
|
||||
{ key: 'TRUSTED_PROXIES', label: 'Trusted Proxies', desc: 'Comma-separated IPs/CIDRs allowed to set X-Forwarded-For', type: 'text' },
|
||||
{ key: 'SESSION_MAX_AGE', label: 'Session Max Age', desc: 'Session lifetime in seconds (default 604800 = 7 days)', type: 'number' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Gmail OAuth',
|
||||
fields: [
|
||||
{ key: 'GOOGLE_CLIENT_ID', label: 'Google Client ID', type: 'text' },
|
||||
{ key: 'GOOGLE_CLIENT_SECRET', label: 'Google Client Secret', type: 'password' },
|
||||
{ key: 'GOOGLE_REDIRECT_URL', label: 'Google Redirect URL', desc: 'Leave blank to auto-derive from Base URL', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Outlook OAuth',
|
||||
fields: [
|
||||
{ key: 'MICROSOFT_CLIENT_ID', label: 'Microsoft Client ID', type: 'text' },
|
||||
{ key: 'MICROSOFT_CLIENT_SECRET', label: 'Microsoft Client Secret', type: 'password' },
|
||||
{ key: 'MICROSOFT_TENANT_ID', label: 'Microsoft Tenant ID', desc: 'Use "common" for multi-tenant', type: 'text' },
|
||||
{ key: 'MICROSOFT_REDIRECT_URL', label: 'Microsoft Redirect URL', desc: 'Leave blank to auto-derive from Base URL', type: 'text' },
|
||||
]
|
||||
},
|
||||
{
|
||||
group: 'Database',
|
||||
fields: [
|
||||
{ key: 'DB_PATH', label: 'Database Path', desc: 'Path to SQLite file, relative to working directory', type: 'text' },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
async function renderSettings() {
|
||||
const el = document.getElementById('admin-content');
|
||||
el.innerHTML = '<div class="spinner" style="margin-top:80px"></div>';
|
||||
const r = await api('GET', '/admin/settings');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load settings</p>'; return; }
|
||||
|
||||
const groups = SETTINGS_META.map(g => `
|
||||
<div class="settings-group">
|
||||
<div class="settings-group-title">${g.group}</div>
|
||||
${g.fields.map(f => {
|
||||
const val = esc(r[f.key] || '');
|
||||
const control = f.type === 'select'
|
||||
? `<select id="cfg-${f.key}">${f.options.map(o => `<option value="${o}" ${r[f.key]===o?'selected':''}>${o}</option>`).join('')}</select>`
|
||||
: `<input type="${f.type}" id="cfg-${f.key}" value="${val}" placeholder="${f.desc||''}">`;
|
||||
return `
|
||||
<div class="setting-row">
|
||||
<div><div class="setting-label">${f.label}</div>${f.desc?`<div class="setting-desc">${f.desc}</div>`:''}</div>
|
||||
<div class="setting-control">${control}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Application Settings</h1>
|
||||
<p>Changes are saved to <code style="font-family:monospace;background:var(--surface3);padding:2px 6px;border-radius:4px">data/gomail.conf</code> and take effect immediately for most settings. A restart is required for LISTEN_ADDR changes.</p>
|
||||
</div>
|
||||
<div id="settings-alert" style="display:none"></div>
|
||||
<div class="admin-card">
|
||||
${groups}
|
||||
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:20px">
|
||||
<button class="btn-secondary" onclick="loadSettingsValues()">Reset</button>
|
||||
<button class="btn-primary" onclick="saveSettings()">Save Settings</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function loadSettingsValues() {
|
||||
const r = await api('GET', '/admin/settings');
|
||||
if (!r) return;
|
||||
SETTINGS_META.forEach(g => g.fields.forEach(f => {
|
||||
const el = document.getElementById('cfg-' + f.key);
|
||||
if (el) el.value = r[f.key] || '';
|
||||
}));
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
const body = {};
|
||||
SETTINGS_META.forEach(g => g.fields.forEach(f => {
|
||||
const el = document.getElementById('cfg-' + f.key);
|
||||
if (el) body[f.key] = el.value.trim();
|
||||
}));
|
||||
const r = await api('PUT', '/admin/settings', body);
|
||||
const alertEl = document.getElementById('settings-alert');
|
||||
if (r && r.ok) {
|
||||
toast('Settings saved', 'success');
|
||||
alertEl.className = 'alert success';
|
||||
alertEl.textContent = 'Settings saved. LISTEN_ADDR changes require a restart.';
|
||||
alertEl.style.display = 'block';
|
||||
setTimeout(() => alertEl.style.display = 'none', 5000);
|
||||
} else {
|
||||
alertEl.className = 'alert error';
|
||||
alertEl.textContent = (r && r.error) || 'Save failed';
|
||||
alertEl.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Audit Log
|
||||
// ============================================================
|
||||
async function renderAudit(page) {
|
||||
page = page || 1;
|
||||
const el = document.getElementById('admin-content');
|
||||
if (page === 1) el.innerHTML = '<div class="spinner" style="margin-top:80px"></div>';
|
||||
|
||||
const r = await api('GET', '/admin/audit?page=' + page + '&page_size=50');
|
||||
if (!r) { el.innerHTML = '<p class="alert error">Failed to load audit log</p>'; return; }
|
||||
|
||||
const rows = (r.logs || []).map(l => `
|
||||
<tr>
|
||||
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${new Date(l.created_at).toLocaleString()}</td>
|
||||
<td style="font-weight:500">${esc(l.user_email || 'system')}</td>
|
||||
<td><span class="badge ${eventBadge(l.event)}">${esc(l.event)}</span></td>
|
||||
<td style="color:var(--muted);font-size:12px">${esc(l.detail)}</td>
|
||||
<td style="font-family:monospace;font-size:11px;color:var(--muted)">${esc(l.ip_address)}</td>
|
||||
</tr>`).join('');
|
||||
|
||||
el.innerHTML = `
|
||||
<div class="admin-page-header">
|
||||
<h1>Audit Log</h1>
|
||||
<p>Security and administrative activity log.</p>
|
||||
</div>
|
||||
<div class="admin-card" style="padding:0;overflow:hidden">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Time</th><th>User</th><th>Event</th><th>Detail</th><th>IP</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="5" style="text-align:center;color:var(--muted);padding:30px">No events</td></tr>'}</tbody>
|
||||
</table>
|
||||
${r.has_more ? `<div style="padding:12px;text-align:center"><button class="load-more-btn" onclick="renderAudit(${page+1})">Load more</button></div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function eventBadge(evt) {
|
||||
if (!evt) return 'amber';
|
||||
if (evt.includes('login') || evt.includes('auth')) return 'blue';
|
||||
if (evt.includes('error') || evt.includes('fail')) return 'red';
|
||||
if (evt.includes('delete') || evt.includes('remove')) return 'red';
|
||||
if (evt.includes('create') || evt.includes('add')) return 'green';
|
||||
return 'amber';
|
||||
}
|
||||
|
||||
// Boot: detect current page from URL
|
||||
(function() {
|
||||
const path = location.pathname;
|
||||
document.querySelectorAll('.admin-nav a').forEach(a => a.classList.toggle('active', a.getAttribute('href') === path));
|
||||
const fn = adminRoutes[path];
|
||||
if (fn) fn();
|
||||
else renderUsers();
|
||||
|
||||
document.querySelectorAll('.admin-nav a').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
navigate(a.getAttribute('href'));
|
||||
});
|
||||
});
|
||||
})();
|
||||
797
web/static/js/app.js
Normal file
797
web/static/js/app.js
Normal file
@@ -0,0 +1,797 @@
|
||||
// GoMail app.js — full client
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
const S = {
|
||||
me: null, accounts: [], providers: {gmail:false,outlook:false},
|
||||
folders: [], messages: [], totalMessages: 0,
|
||||
currentPage: 1, currentFolder: 'unified', currentFolderName: 'Unified Inbox',
|
||||
currentMessage: null, selectedMessageId: null,
|
||||
searchQuery: '', composeMode: 'new', composeReplyToId: null,
|
||||
remoteWhitelist: new Set(),
|
||||
draftTimer: null, draftDirty: false,
|
||||
};
|
||||
|
||||
// ── Boot ───────────────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
const [me, providers, wl] = await Promise.all([
|
||||
api('GET','/me'), api('GET','/providers'), api('GET','/remote-content-whitelist'),
|
||||
]);
|
||||
if (me) {
|
||||
S.me = me;
|
||||
document.getElementById('user-display').textContent = me.username || me.email;
|
||||
if (me.role === 'admin') document.getElementById('admin-link').style.display = 'block';
|
||||
if (me.compose_popup) document.getElementById('compose-popup-toggle').checked = true;
|
||||
}
|
||||
if (providers) { S.providers = providers; updateProviderButtons(); }
|
||||
if (wl?.whitelist) S.remoteWhitelist = new Set(wl.whitelist);
|
||||
|
||||
await Promise.all([loadAccounts(), loadFolders()]);
|
||||
await loadMessages();
|
||||
|
||||
const p = new URLSearchParams(location.search);
|
||||
if (p.get('connected')) { toast('Account connected!', 'success'); history.replaceState({},'',' /'); }
|
||||
if (p.get('error')) { toast('Connection failed: '+p.get('error'), 'error'); history.replaceState({},'','/'); }
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (['INPUT','TEXTAREA','SELECT'].includes(e.target.tagName)) return;
|
||||
if (e.target.contentEditable === 'true') return;
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='n') { e.preventDefault(); openCompose(); }
|
||||
if ((e.metaKey||e.ctrlKey) && e.key==='k') { e.preventDefault(); document.getElementById('search-input').focus(); }
|
||||
});
|
||||
|
||||
// Resizable compose
|
||||
initComposeResize();
|
||||
}
|
||||
|
||||
// ── Providers ──────────────────────────────────────────────────────────────
|
||||
function updateProviderButtons() {
|
||||
['gmail','outlook'].forEach(p => {
|
||||
const btn = document.getElementById('btn-'+p);
|
||||
if (!S.providers[p]) { btn.disabled=true; btn.classList.add('unavailable'); btn.title=p+' OAuth not configured'; }
|
||||
});
|
||||
}
|
||||
|
||||
// ── Accounts ───────────────────────────────────────────────────────────────
|
||||
async function loadAccounts() {
|
||||
const data = await api('GET','/accounts');
|
||||
if (!data) return;
|
||||
S.accounts = data;
|
||||
renderAccounts();
|
||||
populateComposeFrom();
|
||||
}
|
||||
|
||||
function renderAccounts() {
|
||||
const el = document.getElementById('accounts-list');
|
||||
el.innerHTML = S.accounts.map(a => `
|
||||
<div class="account-item" oncontextmenu="showAccountMenu(event,${a.id})"
|
||||
title="${esc(a.email_address)}${a.last_error?' ⚠ '+esc(a.last_error):''}">
|
||||
<div class="account-dot" style="background:${a.color}"></div>
|
||||
<span class="account-email">${esc(a.email_address)}</span>
|
||||
${a.last_error?'<div class="account-error-dot"></div>':''}
|
||||
<button onclick="syncNow(${a.id},event)" id="sync-btn-${a.id}" class="icon-sync-btn" title="Sync now">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
||||
</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function showAccountMenu(e, id) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const a = S.accounts.find(a=>a.id===id);
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncNow(${id});closeMenu()">↻ Sync now</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id},true);closeMenu()">⚡ Test connection</div>
|
||||
<div class="ctx-item" onclick="openEditAccount(${id});closeMenu()">✎ Edit credentials</div>
|
||||
${a?.last_error?`<div class="ctx-item" onclick="toast('${esc(a.last_error)}','error');closeMenu()">⚠ View last error</div>`:''}
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteAccount(${id});closeMenu()">🗑 Remove account</div>`);
|
||||
}
|
||||
|
||||
async function syncNow(id, e) {
|
||||
if (e) e.stopPropagation();
|
||||
const btn = document.getElementById('sync-btn-'+id);
|
||||
if (btn) { btn.style.opacity='0.3'; btn.style.pointerEvents='none'; }
|
||||
const r = await api('POST','/accounts/'+id+'/sync');
|
||||
if (btn) { btn.style.opacity=''; btn.style.pointerEvents=''; }
|
||||
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadAccounts(); loadFolders(); loadMessages(); }
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
function connectOAuth(p) { location.href='/auth/'+p+'/connect'; }
|
||||
|
||||
// ── Add Account modal ──────────────────────────────────────────────────────
|
||||
function openAddAccountModal() {
|
||||
['imap-email','imap-name','imap-password','imap-host','smtp-host'].forEach(id=>{ const el=document.getElementById(id); if(el) el.value=''; });
|
||||
document.getElementById('imap-port').value='993';
|
||||
document.getElementById('smtp-port').value='587';
|
||||
const r=document.getElementById('test-result'); if(r){r.style.display='none';r.className='test-result';}
|
||||
openModal('add-account-modal');
|
||||
}
|
||||
|
||||
async function testNewConnection() {
|
||||
const btn=document.getElementById('test-btn'), result=document.getElementById('test-result');
|
||||
const body={email:document.getElementById('imap-email').value.trim(),password:document.getElementById('imap-password').value,
|
||||
imap_host:document.getElementById('imap-host').value.trim(),imap_port:parseInt(document.getElementById('imap-port').value)||993,
|
||||
smtp_host:document.getElementById('smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('smtp-port').value)||587};
|
||||
if (!body.email||!body.password||!body.imap_host){result.textContent='Email, password and IMAP host required.';result.className='test-result err';result.style.display='block';return;}
|
||||
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
|
||||
const r=await api('POST','/accounts/test',body);
|
||||
btn.textContent='Test Connection';btn.disabled=false;
|
||||
result.textContent=(r?.ok)?'✓ Connection successful!':((r?.error)||'Connection failed');
|
||||
result.className='test-result '+((r?.ok)?'ok':'err'); result.style.display='block';
|
||||
}
|
||||
|
||||
async function addIMAPAccount() {
|
||||
const btn=document.getElementById('save-acct-btn');
|
||||
const body={email:document.getElementById('imap-email').value.trim(),display_name:document.getElementById('imap-name').value.trim(),
|
||||
password:document.getElementById('imap-password').value,imap_host:document.getElementById('imap-host').value.trim(),
|
||||
imap_port:parseInt(document.getElementById('imap-port').value)||993,smtp_host:document.getElementById('smtp-host').value.trim(),
|
||||
smtp_port:parseInt(document.getElementById('smtp-port').value)||587};
|
||||
if (!body.email||!body.password||!body.imap_host){toast('Email, password and IMAP host required','error');return;}
|
||||
btn.disabled=true;btn.textContent='Connecting...';
|
||||
const r=await api('POST','/accounts',body);
|
||||
btn.disabled=false;btn.textContent='Connect';
|
||||
if (r?.ok){toast('Account added!','success');closeModal('add-account-modal');loadAccounts();loadFolders();loadMessages();}
|
||||
else toast(r?.error||'Failed to add account','error');
|
||||
}
|
||||
|
||||
// ── Edit Account modal ─────────────────────────────────────────────────────
|
||||
async function openEditAccount(id, testAfterOpen) {
|
||||
const r=await api('GET','/accounts/'+id);
|
||||
if (!r) return;
|
||||
document.getElementById('edit-account-id').value=id;
|
||||
document.getElementById('edit-account-email').textContent=r.email_address;
|
||||
document.getElementById('edit-name').value=r.display_name||'';
|
||||
document.getElementById('edit-password').value='';
|
||||
document.getElementById('edit-imap-host').value=r.imap_host||'';
|
||||
document.getElementById('edit-imap-port').value=r.imap_port||993;
|
||||
document.getElementById('edit-smtp-host').value=r.smtp_host||'';
|
||||
document.getElementById('edit-smtp-port').value=r.smtp_port||587;
|
||||
// Sync settings
|
||||
document.getElementById('edit-sync-mode').value=r.sync_mode||'days';
|
||||
document.getElementById('edit-sync-days').value=r.sync_days||30;
|
||||
toggleSyncDaysField();
|
||||
const errEl=document.getElementById('edit-last-error'), connEl=document.getElementById('edit-conn-result');
|
||||
connEl.style.display='none';
|
||||
errEl.style.display=r.last_error?'block':'none';
|
||||
if (r.last_error) errEl.textContent='Last sync error: '+r.last_error;
|
||||
openModal('edit-account-modal');
|
||||
if (testAfterOpen) setTimeout(testEditConnection,200);
|
||||
}
|
||||
|
||||
function toggleSyncDaysField() {
|
||||
const mode=document.getElementById('edit-sync-mode')?.value;
|
||||
const row=document.getElementById('edit-sync-days-row');
|
||||
if (row) row.style.display=(mode==='all')?'none':'flex';
|
||||
}
|
||||
|
||||
async function testEditConnection() {
|
||||
const btn=document.getElementById('edit-test-btn'), connEl=document.getElementById('edit-conn-result');
|
||||
const pw=document.getElementById('edit-password').value, email=document.getElementById('edit-account-email').textContent.trim();
|
||||
if (!pw){connEl.textContent='Enter new password to test.';connEl.className='test-result err';connEl.style.display='block';return;}
|
||||
btn.innerHTML='<span class="spinner-inline"></span>Testing...';btn.disabled=true;
|
||||
const r=await api('POST','/accounts/test',{email,password:pw,
|
||||
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
|
||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587});
|
||||
btn.textContent='Test Connection';btn.disabled=false;
|
||||
connEl.textContent=(r?.ok)?'✓ Successful!':((r?.error)||'Failed');
|
||||
connEl.className='test-result '+((r?.ok)?'ok':'err'); connEl.style.display='block';
|
||||
}
|
||||
|
||||
async function saveAccountEdit() {
|
||||
const id=document.getElementById('edit-account-id').value;
|
||||
const body={display_name:document.getElementById('edit-name').value.trim(),
|
||||
imap_host:document.getElementById('edit-imap-host').value.trim(),imap_port:parseInt(document.getElementById('edit-imap-port').value)||993,
|
||||
smtp_host:document.getElementById('edit-smtp-host').value.trim(),smtp_port:parseInt(document.getElementById('edit-smtp-port').value)||587};
|
||||
const pw=document.getElementById('edit-password').value;
|
||||
if (pw) body.password=pw;
|
||||
const [r1, r2] = await Promise.all([
|
||||
api('PUT','/accounts/'+id, body),
|
||||
api('PUT','/accounts/'+id+'/sync-settings',{
|
||||
sync_mode: document.getElementById('edit-sync-mode').value,
|
||||
sync_days: parseInt(document.getElementById('edit-sync-days').value)||30,
|
||||
}),
|
||||
]);
|
||||
if (r1?.ok){toast('Account updated','success');closeModal('edit-account-modal');loadAccounts();}
|
||||
else toast(r1?.error||'Update failed','error');
|
||||
}
|
||||
|
||||
async function deleteAccount(id) {
|
||||
const a=S.accounts.find(a=>a.id===id);
|
||||
if (!confirm('Remove '+(a?a.email_address:id)+'?\nAll synced messages will be deleted.')) return;
|
||||
const r=await api('DELETE','/accounts/'+id);
|
||||
if (r?.ok){toast('Account removed','success');loadAccounts();loadFolders();loadMessages();}
|
||||
else toast('Remove failed','error');
|
||||
}
|
||||
|
||||
// ── Folders ────────────────────────────────────────────────────────────────
|
||||
async function loadFolders() {
|
||||
const data=await api('GET','/folders');
|
||||
if (!data) return;
|
||||
S.folders=data||[];
|
||||
renderFolders();
|
||||
updateUnreadBadge();
|
||||
}
|
||||
|
||||
const FOLDER_ICONS = {
|
||||
inbox:'<path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>',
|
||||
sent:'<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>',
|
||||
drafts:'<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>',
|
||||
trash:'<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>',
|
||||
spam:'<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>',
|
||||
archive:'<path d="M20.54 5.23l-1.39-1.68C18.88 3.21 18.47 3 18 3H6c-.47 0-.88.21-1.16.55L3.46 5.23C3.17 5.57 3 6.02 3 6.5V19c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V6.5c0-.48-.17-.93-.46-1.27zM12 17.5L6.5 12H10v-2h4v2h3.5L12 17.5zM5.12 5l.81-1h12l.94 1H5.12z"/>',
|
||||
custom:'<path d="M20 6h-2.18c.07-.44.18-.86.18-1 0-2.21-1.79-4-4-4s-4 1.79-4 4c0 .14.11.56.18 1H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2z"/>',
|
||||
};
|
||||
|
||||
function renderFolders() {
|
||||
const el=document.getElementById('folders-by-account');
|
||||
const accMap={}; S.accounts.forEach(a=>accMap[a.id]=a);
|
||||
const byAcc={};
|
||||
S.folders.forEach(f=>{(byAcc[f.account_id]=byAcc[f.account_id]||[]).push(f);});
|
||||
const prio=['inbox','sent','drafts','trash','spam','archive'];
|
||||
el.innerHTML=Object.entries(byAcc).map(([accId,folders])=>{
|
||||
const acc=accMap[parseInt(accId)]; if(!acc) return '';
|
||||
const sorted=[...prio.map(t=>folders.find(f=>f.folder_type===t)).filter(Boolean),...folders.filter(f=>f.folder_type==='custom')];
|
||||
return `<div class="nav-folder-header">
|
||||
<span style="width:6px;height:6px;border-radius:50%;background:${acc.color};display:inline-block;flex-shrink:0"></span>
|
||||
${esc(acc.email_address)}
|
||||
</div>`+sorted.map(f=>`
|
||||
<div class="nav-item" id="nav-f${f.id}" onclick="selectFolder(${f.id},'${esc(f.name)}')"
|
||||
oncontextmenu="showFolderMenu(event,${f.id},${acc.id})">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">${FOLDER_ICONS[f.folder_type]||FOLDER_ICONS.custom}</svg>
|
||||
${esc(f.name)}
|
||||
${f.unread_count>0?`<span class="unread-badge">${f.unread_count}</span>`:''}
|
||||
</div>`).join('');
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showFolderMenu(e, folderId, accountId) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
showCtxMenu(e, `
|
||||
<div class="ctx-item" onclick="syncFolderNow(${folderId});closeMenu()">↻ Sync this folder</div>
|
||||
<div class="ctx-item" onclick="selectFolder(${folderId});closeMenu()">📂 Open folder</div>`);
|
||||
}
|
||||
|
||||
async function syncFolderNow(folderId) {
|
||||
const r=await api('POST','/folders/'+folderId+'/sync');
|
||||
if (r?.ok) { toast('Synced '+(r.synced||0)+' messages','success'); loadFolders(); loadMessages(); }
|
||||
else toast(r?.error||'Sync failed','error');
|
||||
}
|
||||
|
||||
function updateUnreadBadge() {
|
||||
const total=S.folders.filter(f=>f.folder_type==='inbox').reduce((s,f)=>s+(f.unread_count||0),0);
|
||||
const badge=document.getElementById('unread-total');
|
||||
badge.textContent=total; badge.style.display=total>0?'':'none';
|
||||
}
|
||||
|
||||
// ── Messages ───────────────────────────────────────────────────────────────
|
||||
function selectFolder(folderId, folderName) {
|
||||
S.currentFolder=folderId; S.currentFolderName=folderName||S.currentFolderName;
|
||||
S.currentPage=1; S.messages=[]; S.searchQuery='';
|
||||
document.getElementById('search-input').value='';
|
||||
document.getElementById('panel-title').textContent=folderName||S.currentFolderName;
|
||||
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
|
||||
const navEl=folderId==='unified'?document.getElementById('nav-unified')
|
||||
:folderId==='starred'?document.getElementById('nav-starred')
|
||||
:document.getElementById('nav-f'+folderId);
|
||||
if (navEl) navEl.classList.add('active');
|
||||
loadMessages();
|
||||
}
|
||||
|
||||
const handleSearch=debounce(q=>{
|
||||
S.searchQuery=q.trim(); S.currentPage=1;
|
||||
document.getElementById('panel-title').textContent=q.trim()?'Search: '+q.trim():S.currentFolderName;
|
||||
loadMessages();
|
||||
},350);
|
||||
|
||||
async function loadMessages(append) {
|
||||
const list=document.getElementById('message-list');
|
||||
if (!append) list.innerHTML='<div class="spinner" style="margin-top:60px"></div>';
|
||||
let result;
|
||||
if (S.searchQuery) result=await api('GET',`/search?q=${encodeURIComponent(S.searchQuery)}&page=${S.currentPage}&page_size=50`);
|
||||
else if (S.currentFolder==='unified') result=await api('GET',`/messages/unified?page=${S.currentPage}&page_size=50`);
|
||||
else result=await api('GET',`/messages?folder_id=${S.currentFolder}&page=${S.currentPage}&page_size=50`);
|
||||
if (!result){list.innerHTML='<div class="empty-state"><p>Failed to load</p></div>';return;}
|
||||
S.totalMessages=result.total||(result.messages||[]).length;
|
||||
if (append) S.messages.push(...(result.messages||[]));
|
||||
else S.messages=result.messages||[];
|
||||
renderMessageList();
|
||||
document.getElementById('panel-count').textContent=S.totalMessages>0?S.totalMessages+' messages':'';
|
||||
}
|
||||
|
||||
function renderMessageList() {
|
||||
const list=document.getElementById('message-list');
|
||||
if (!S.messages.length){
|
||||
list.innerHTML=`<div class="empty-state"><svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg><p>No messages</p></div>`;
|
||||
return;
|
||||
}
|
||||
list.innerHTML=S.messages.map(m=>`
|
||||
<div class="message-item ${m.id===S.selectedMessageId?'active':''} ${!m.is_read?'unread':''}"
|
||||
onclick="openMessage(${m.id})" oncontextmenu="showMessageMenu(event,${m.id})">
|
||||
<div class="msg-top">
|
||||
<span class="msg-from">${esc(m.from_name||m.from_email)}</span>
|
||||
<span class="msg-date">${formatDate(m.date)}</span>
|
||||
</div>
|
||||
<div class="msg-subject">${esc(m.subject||'(no subject)')}</div>
|
||||
<div class="msg-preview">${esc(m.preview||'')}</div>
|
||||
<div class="msg-meta">
|
||||
<span class="msg-dot" style="background:${m.account_color}"></span>
|
||||
<span class="msg-acct">${esc(m.account_email||'')}</span>
|
||||
${m.has_attachment?'<svg width="11" height="11" viewBox="0 0 24 24" fill="var(--muted)" style="margin-left:4px"><path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z"/></svg>':''}
|
||||
<span class="msg-star ${m.is_starred?'on':''}" onclick="toggleStar(${m.id},event)">${m.is_starred?'★':'☆'}</span>
|
||||
</div>
|
||||
</div>`).join('')+(S.messages.length<S.totalMessages
|
||||
?`<div class="load-more"><button class="load-more-btn" onclick="loadMoreMessages()">Load more</button></div>`:'');
|
||||
}
|
||||
|
||||
function loadMoreMessages(){ S.currentPage++; loadMessages(true); }
|
||||
|
||||
async function openMessage(id) {
|
||||
S.selectedMessageId=id; renderMessageList();
|
||||
const detail=document.getElementById('message-detail');
|
||||
detail.innerHTML='<div class="spinner" style="margin-top:100px"></div>';
|
||||
const msg=await api('GET','/messages/'+id);
|
||||
if (!msg){detail.innerHTML='<div class="no-message"><p>Failed to load</p></div>';return;}
|
||||
S.currentMessage=msg;
|
||||
renderMessageDetail(msg, false);
|
||||
const li=S.messages.find(m=>m.id===id);
|
||||
if (li&&!li.is_read){li.is_read=true;renderMessageList();}
|
||||
}
|
||||
|
||||
function renderMessageDetail(msg, showRemoteContent) {
|
||||
const detail=document.getElementById('message-detail');
|
||||
const allowed=showRemoteContent||S.remoteWhitelist.has(msg.from_email);
|
||||
|
||||
let bodyHtml='';
|
||||
if (msg.body_html) {
|
||||
if (allowed) {
|
||||
bodyHtml=`<iframe id="msg-frame" sandbox="allow-same-origin allow-popups"
|
||||
style="width:100%;border:none;min-height:300px;display:block"
|
||||
srcdoc="${msg.body_html.replace(/"/g,'"')}"></iframe>`;
|
||||
} else {
|
||||
bodyHtml=`<div class="remote-content-banner">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
Remote images blocked.
|
||||
<button class="rcb-btn" onclick="renderMessageDetail(S.currentMessage,true)">Load content</button>
|
||||
<button class="rcb-btn" onclick="whitelistSender('${esc(msg.from_email)}')">Always allow from ${esc(msg.from_email)}</button>
|
||||
</div>
|
||||
<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||
}
|
||||
} else {
|
||||
bodyHtml=`<div class="detail-body-text">${esc(msg.body_text||'(empty)')}</div>`;
|
||||
}
|
||||
|
||||
let attachHtml='';
|
||||
if (msg.attachments?.length) {
|
||||
attachHtml=`<div class="attachments-bar">
|
||||
<span style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.6px;margin-right:8px">Attachments</span>
|
||||
${msg.attachments.map(a=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.filename)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
detail.innerHTML=`
|
||||
<div class="detail-header">
|
||||
<div class="detail-subject">${esc(msg.subject||'(no subject)')}</div>
|
||||
<div class="detail-meta">
|
||||
<div class="detail-from">
|
||||
<strong>${esc(msg.from_name||msg.from_email)}</strong>
|
||||
${msg.from_name?`<span style="color:var(--muted);font-size:12px"> <${esc(msg.from_email)}></span>`:''}
|
||||
${msg.to?`<div style="font-size:12px;color:var(--muted);margin-top:2px">To: ${esc(msg.to)}</div>`:''}
|
||||
${msg.cc?`<div style="font-size:12px;color:var(--muted)">CC: ${esc(msg.cc)}</div>`:''}
|
||||
</div>
|
||||
<div class="detail-date">${formatFullDate(msg.date)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-actions">
|
||||
<button class="action-btn" onclick="openReply()">↩ Reply</button>
|
||||
<button class="action-btn" onclick="openForward()">↪ Forward</button>
|
||||
<button class="action-btn" onclick="toggleStar(${msg.id})">${msg.is_starred?'★ Unstar':'☆ Star'}</button>
|
||||
<button class="action-btn" onclick="markRead(${msg.id},${!msg.is_read})">${msg.is_read?'Mark unread':'Mark read'}</button>
|
||||
<button class="action-btn" onclick="showMessageHeaders(${msg.id})">⋮ Headers</button>
|
||||
<button class="action-btn danger" onclick="deleteMessage(${msg.id})">🗑 Delete</button>
|
||||
</div>
|
||||
${attachHtml}
|
||||
<div class="detail-body">${bodyHtml}</div>`;
|
||||
|
||||
if (msg.body_html && allowed) {
|
||||
const frame=document.getElementById('msg-frame');
|
||||
if (frame) frame.onload=()=>{try{const h=frame.contentDocument.documentElement.scrollHeight;frame.style.height=(h+30)+'px';}catch(e){}};
|
||||
}
|
||||
}
|
||||
|
||||
async function whitelistSender(sender) {
|
||||
const r=await api('POST','/remote-content-whitelist',{sender});
|
||||
if (r?.ok){S.remoteWhitelist.add(sender);toast('Always allowing content from '+sender,'success');if(S.currentMessage)renderMessageDetail(S.currentMessage,false);}
|
||||
}
|
||||
|
||||
async function showMessageHeaders(id) {
|
||||
const r=await api('GET','/messages/'+id+'/headers');
|
||||
if (!r?.headers) return;
|
||||
const rows=Object.entries(r.headers).filter(([,v])=>v)
|
||||
.map(([k,v])=>`<tr><td style="color:var(--muted);padding:4px 12px 4px 0;font-size:12px;white-space:nowrap;vertical-align:top">${esc(k)}</td><td style="font-size:12px;word-break:break-all">${esc(v)}</td></tr>`).join('');
|
||||
const overlay=document.createElement('div');
|
||||
overlay.className='modal-overlay open';
|
||||
overlay.innerHTML=`<div class="modal" style="width:600px;max-height:80vh;overflow-y:auto">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<h2 style="margin:0">Message Headers</h2>
|
||||
<button class="icon-btn" onclick="this.closest('.modal-overlay').remove()"><svg viewBox="0 0 24 24"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></button>
|
||||
</div>
|
||||
<table style="width:100%"><tbody>${rows}</tbody></table>
|
||||
</div>`;
|
||||
overlay.addEventListener('click',e=>{if(e.target===overlay)overlay.remove();});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function showMessageMenu(e, id) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const moveFolders=S.folders.slice(0,8).map(f=>`<div class="ctx-item" onclick="moveMessage(${id},${f.id});closeMenu()">${esc(f.name)}</div>`).join('');
|
||||
showCtxMenu(e,`
|
||||
<div class="ctx-item" onclick="openReplyTo(${id});closeMenu()">↩ Reply</div>
|
||||
<div class="ctx-item" onclick="toggleStar(${id});closeMenu()">★ Toggle star</div>
|
||||
<div class="ctx-item" onclick="showMessageHeaders(${id});closeMenu()">⋮ View headers</div>
|
||||
${moveFolders?`<div class="ctx-sep"></div><div style="font-size:10px;color:var(--muted);padding:4px 12px;text-transform:uppercase;letter-spacing:.8px">Move to</div>${moveFolders}`:''}
|
||||
<div class="ctx-sep"></div>
|
||||
<div class="ctx-item danger" onclick="deleteMessage(${id});closeMenu()">🗑 Delete</div>`);
|
||||
}
|
||||
|
||||
async function toggleStar(id, e) {
|
||||
if(e) e.stopPropagation();
|
||||
const r=await api('PUT','/messages/'+id+'/star');
|
||||
if (r){const m=S.messages.find(m=>m.id===id);if(m)m.is_starred=r.starred;renderMessageList();
|
||||
if(S.currentMessage?.id===id){S.currentMessage.is_starred=r.starred;renderMessageDetail(S.currentMessage,false);}}
|
||||
}
|
||||
|
||||
async function markRead(id, read) {
|
||||
await api('PUT','/messages/'+id+'/read',{read});
|
||||
const m=S.messages.find(m=>m.id===id);if(m){m.is_read=read;renderMessageList();}
|
||||
loadFolders();
|
||||
}
|
||||
|
||||
async function moveMessage(msgId, folderId) {
|
||||
const r=await api('PUT','/messages/'+msgId+'/move',{folder_id:folderId});
|
||||
if(r?.ok){toast('Moved','success');S.messages=S.messages.filter(m=>m.id!==msgId);renderMessageList();
|
||||
if(S.currentMessage?.id===msgId)resetDetail();loadFolders();}
|
||||
}
|
||||
|
||||
async function deleteMessage(id) {
|
||||
if(!confirm('Delete this message?')) return;
|
||||
const r=await api('DELETE','/messages/'+id);
|
||||
if(r?.ok){toast('Deleted','success');S.messages=S.messages.filter(m=>m.id!==id);renderMessageList();
|
||||
if(S.currentMessage?.id===id)resetDetail();loadFolders();}
|
||||
}
|
||||
|
||||
function resetDetail() {
|
||||
S.currentMessage=null;S.selectedMessageId=null;
|
||||
document.getElementById('message-detail').innerHTML=`<div class="no-message">
|
||||
<svg viewBox="0 0 24 24"><path d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
|
||||
<h3>Select a message</h3><p>Choose a message to read it</p></div>`;
|
||||
}
|
||||
|
||||
function formatSize(b){if(!b)return'';if(b<1024)return b+' B';if(b<1048576)return Math.round(b/1024)+' KB';return(b/1048576).toFixed(1)+' MB';}
|
||||
|
||||
// ── Compose ────────────────────────────────────────────────────────────────
|
||||
let composeAttachments=[];
|
||||
|
||||
function populateComposeFrom() {
|
||||
const sel=document.getElementById('compose-from');
|
||||
if(!sel) return;
|
||||
sel.innerHTML=S.accounts.map(a=>`<option value="${a.id}">${esc(a.display_name||a.email_address)} <${esc(a.email_address)}></option>`).join('');
|
||||
}
|
||||
|
||||
function openCompose(opts={}) {
|
||||
S.composeMode=opts.mode||'new'; S.composeReplyToId=opts.replyId||null;
|
||||
composeAttachments=[];
|
||||
document.getElementById('compose-title').textContent=opts.title||'New Message';
|
||||
document.getElementById('compose-to').innerHTML='';
|
||||
document.getElementById('compose-cc-tags').innerHTML='';
|
||||
document.getElementById('compose-bcc-tags').innerHTML='';
|
||||
document.getElementById('compose-subject').value=opts.subject||'';
|
||||
document.getElementById('cc-row').style.display='none';
|
||||
document.getElementById('bcc-row').style.display='none';
|
||||
const editor=document.getElementById('compose-editor');
|
||||
editor.innerHTML=opts.body||'';
|
||||
S.draftDirty=false;
|
||||
updateAttachList();
|
||||
if (S.me?.compose_popup) {
|
||||
openComposePopup();
|
||||
} else {
|
||||
document.getElementById('compose-overlay').classList.add('open');
|
||||
// Focus the To field's input
|
||||
setTimeout(()=>{ const inp=document.querySelector('#compose-to .tag-input'); if(inp) inp.focus(); },50);
|
||||
}
|
||||
startDraftAutosave();
|
||||
}
|
||||
|
||||
function openReply() { if (S.currentMessage) openReplyTo(S.currentMessage.id); }
|
||||
|
||||
function openReplyTo(msgId) {
|
||||
const msg=(S.currentMessage?.id===msgId)?S.currentMessage:S.messages.find(m=>m.id===msgId);
|
||||
if (!msg) return;
|
||||
openCompose({
|
||||
mode:'reply', replyId:msgId, title:'Reply',
|
||||
subject:msg.subject&&!msg.subject.startsWith('Re:')?'Re: '+msg.subject:(msg.subject||''),
|
||||
body:`<br><br><div class="quote-divider">—— Original message ——</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
// Pre-fill To
|
||||
addTag('compose-to', msg.from_email||'');
|
||||
}
|
||||
|
||||
function openForward() {
|
||||
if (!S.currentMessage) return;
|
||||
const msg=S.currentMessage;
|
||||
openCompose({
|
||||
mode:'forward', title:'Forward',
|
||||
subject:'Fwd: '+(msg.subject||''),
|
||||
body:`<br><br><div class="quote-divider">—— Forwarded message ——<br>From: ${esc(msg.from_email||'')}</div><blockquote>${msg.body_html||('<pre>'+esc(msg.body_text||'')+'</pre>')}</blockquote>`,
|
||||
});
|
||||
}
|
||||
|
||||
function closeCompose(skipDraftCheck) {
|
||||
if (!skipDraftCheck && S.draftDirty) {
|
||||
const choice=confirm('Save draft before closing?');
|
||||
if (choice) { saveDraft(); return; }
|
||||
}
|
||||
clearDraftAutosave();
|
||||
if (S.me?.compose_popup) {
|
||||
const win=window._composeWin;
|
||||
if (win&&!win.closed) win.close();
|
||||
} else {
|
||||
document.getElementById('compose-overlay').classList.remove('open');
|
||||
}
|
||||
S.draftDirty=false;
|
||||
}
|
||||
|
||||
// ── Email Tag Input ────────────────────────────────────────────────────────
|
||||
function initTagField(containerId) {
|
||||
const container=document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
const inp=document.createElement('input');
|
||||
inp.type='text'; inp.className='tag-input'; inp.placeholder=containerId==='compose-to'?'recipient@example.com':'';
|
||||
container.appendChild(inp);
|
||||
inp.addEventListener('keydown', e=>{
|
||||
if ((e.key===' '||e.key==='Enter'||e.key===','||e.key===';') && inp.value.trim()) {
|
||||
e.preventDefault();
|
||||
addTag(containerId, inp.value.trim().replace(/[,;]$/,''));
|
||||
inp.value='';
|
||||
} else if (e.key==='Backspace'&&!inp.value) {
|
||||
const tags=container.querySelectorAll('.email-tag');
|
||||
if (tags.length) tags[tags.length-1].remove();
|
||||
}
|
||||
});
|
||||
inp.addEventListener('blur', ()=>{
|
||||
if (inp.value.trim()) { addTag(containerId, inp.value.trim()); inp.value=''; }
|
||||
});
|
||||
container.addEventListener('click', ()=>inp.focus());
|
||||
}
|
||||
|
||||
function addTag(containerId, value) {
|
||||
if (!value) return;
|
||||
const container=document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
// Basic email validation
|
||||
const isValid=/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
const tag=document.createElement('span');
|
||||
tag.className='email-tag'+(isValid?'':' invalid');
|
||||
tag.textContent=value;
|
||||
const remove=document.createElement('button');
|
||||
remove.innerHTML='×'; remove.className='tag-remove';
|
||||
remove.onclick=e=>{e.stopPropagation();tag.remove();S.draftDirty=true;};
|
||||
tag.appendChild(remove);
|
||||
const inp=container.querySelector('.tag-input');
|
||||
container.insertBefore(tag, inp);
|
||||
S.draftDirty=true;
|
||||
}
|
||||
|
||||
function getTagValues(containerId) {
|
||||
return Array.from(document.querySelectorAll('#'+containerId+' .email-tag'))
|
||||
.map(t=>t.textContent.replace('×','').trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
// ── Draft autosave ─────────────────────────────────────────────────────────
|
||||
function startDraftAutosave() {
|
||||
clearDraftAutosave();
|
||||
S.draftTimer=setInterval(()=>{
|
||||
if (S.draftDirty) saveDraft(true);
|
||||
}, 60000); // every 60s
|
||||
// Mark dirty on any edit
|
||||
const editor=document.getElementById('compose-editor');
|
||||
if (editor) editor.oninput=()=>S.draftDirty=true;
|
||||
['compose-subject'].forEach(id=>{
|
||||
const el=document.getElementById(id);
|
||||
if(el) el.oninput=()=>S.draftDirty=true;
|
||||
});
|
||||
}
|
||||
|
||||
function clearDraftAutosave() {
|
||||
if (S.draftTimer) { clearInterval(S.draftTimer); S.draftTimer=null; }
|
||||
}
|
||||
|
||||
async function saveDraft(silent) {
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
if (!accountId) return;
|
||||
const to=getTagValues('compose-to');
|
||||
const editor=document.getElementById('compose-editor');
|
||||
// For now save as a local note — a real IMAP APPEND to Drafts would be ideal
|
||||
// but for MVP we just suppress the dirty flag and toast
|
||||
S.draftDirty=false;
|
||||
if (!silent) toast('Draft saved','success');
|
||||
else toast('Draft auto-saved','success');
|
||||
}
|
||||
|
||||
// ── Compose formatting ─────────────────────────────────────────────────────
|
||||
function execFmt(cmd, val) {
|
||||
document.getElementById('compose-editor').focus();
|
||||
document.execCommand(cmd, false, val||null);
|
||||
}
|
||||
|
||||
function triggerAttach() { document.getElementById('compose-attach-input').click(); }
|
||||
|
||||
function handleAttachFiles(input) {
|
||||
for (const file of input.files) composeAttachments.push({file,name:file.name,size:file.size});
|
||||
input.value=''; updateAttachList(); S.draftDirty=true;
|
||||
}
|
||||
|
||||
function removeAttachment(i) { composeAttachments.splice(i,1); updateAttachList(); }
|
||||
|
||||
function updateAttachList() {
|
||||
const el=document.getElementById('compose-attach-list');
|
||||
if (!composeAttachments.length){el.innerHTML='';return;}
|
||||
el.innerHTML=composeAttachments.map((a,i)=>`<div class="attachment-chip">
|
||||
📎 <span>${esc(a.name)}</span>
|
||||
<span style="color:var(--muted);font-size:10px">${formatSize(a.size)}</span>
|
||||
<button onclick="removeAttachment(${i})" class="tag-remove">×</button>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const accountId=parseInt(document.getElementById('compose-from')?.value||0);
|
||||
const to=getTagValues('compose-to');
|
||||
if (!accountId||!to.length){toast('From account and To address required','error');return;}
|
||||
const editor=document.getElementById('compose-editor');
|
||||
const bodyHTML=editor.innerHTML.trim();
|
||||
const bodyText=editor.innerText.trim();
|
||||
const btn=document.getElementById('send-btn');
|
||||
btn.disabled=true;btn.textContent='Sending...';
|
||||
const endpoint=S.composeMode==='reply'?'/reply':S.composeMode==='forward'?'/forward':'/send';
|
||||
const r=await api('POST',endpoint,{
|
||||
account_id:accountId, to,
|
||||
cc:getTagValues('compose-cc-tags'),
|
||||
bcc:getTagValues('compose-bcc-tags'),
|
||||
subject:document.getElementById('compose-subject').value,
|
||||
body_text:bodyText, body_html:bodyHTML,
|
||||
in_reply_to_id:S.composeMode==='reply'?S.composeReplyToId:0,
|
||||
});
|
||||
btn.disabled=false;btn.textContent='Send';
|
||||
if (r?.ok){toast('Sent!','success');clearDraftAutosave();S.draftDirty=false;
|
||||
document.getElementById('compose-overlay').classList.remove('open');}
|
||||
else toast(r?.error||'Send failed','error');
|
||||
}
|
||||
|
||||
// ── Resizable compose ──────────────────────────────────────────────────────
|
||||
function initComposeResize() {
|
||||
const win=document.getElementById('compose-window');
|
||||
if (!win) return;
|
||||
let resizing=false, startX, startY, startW, startH;
|
||||
const handle=document.getElementById('compose-resize-handle');
|
||||
if (!handle) return;
|
||||
handle.addEventListener('mousedown', e=>{
|
||||
resizing=true; startX=e.clientX; startY=e.clientY;
|
||||
startW=win.offsetWidth; startH=win.offsetHeight;
|
||||
document.addEventListener('mousemove', onMouseMove);
|
||||
document.addEventListener('mouseup', ()=>{resizing=false;document.removeEventListener('mousemove',onMouseMove);});
|
||||
e.preventDefault();
|
||||
});
|
||||
function onMouseMove(e) {
|
||||
if (!resizing) return;
|
||||
const newW=Math.max(360, startW+(e.clientX-startX));
|
||||
const newH=Math.max(280, startH-(e.clientY-startY));
|
||||
win.style.width=newW+'px';
|
||||
win.style.height=newH+'px';
|
||||
document.getElementById('compose-editor').style.height=(newH-240)+'px';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Compose popup window ───────────────────────────────────────────────────
|
||||
function openComposePopup() {
|
||||
const popup=window.open('','_blank','width=640,height=520,resizable=yes,scrollbars=yes');
|
||||
window._composeWin=popup;
|
||||
// Simpler: just use the in-page compose anyway for now; popup would need full HTML
|
||||
// Fall back to in-page for robustness
|
||||
document.getElementById('compose-overlay').classList.add('open');
|
||||
}
|
||||
|
||||
// ── Settings ───────────────────────────────────────────────────────────────
|
||||
async function openSettings() {
|
||||
openModal('settings-modal');
|
||||
loadSyncInterval();
|
||||
renderMFAPanel();
|
||||
}
|
||||
|
||||
async function loadSyncInterval() {
|
||||
const r=await api('GET','/sync-interval');
|
||||
if (r) document.getElementById('sync-interval-select').value=String(r.sync_interval||15);
|
||||
}
|
||||
|
||||
async function saveSyncInterval() {
|
||||
const val=parseInt(document.getElementById('sync-interval-select').value)||0;
|
||||
const r=await api('PUT','/sync-interval',{sync_interval:val});
|
||||
if (r?.ok) toast('Saved','success'); else toast('Failed','error');
|
||||
}
|
||||
|
||||
async function saveComposePopupPref() {
|
||||
const val=document.getElementById('compose-popup-toggle').checked;
|
||||
await api('PUT','/compose-popup',{compose_popup:val});
|
||||
if (S.me) S.me.compose_popup=val;
|
||||
}
|
||||
|
||||
async function changePassword() {
|
||||
const cur=document.getElementById('cur-pw').value, nw=document.getElementById('new-pw').value;
|
||||
if (!cur||!nw){toast('Both fields required','error');return;}
|
||||
const r=await api('POST','/change-password',{current_password:cur,new_password:nw});
|
||||
if (r?.ok){toast('Password updated','success');document.getElementById('cur-pw').value='';document.getElementById('new-pw').value='';}
|
||||
else toast(r?.error||'Failed','error');
|
||||
}
|
||||
|
||||
async function renderMFAPanel() {
|
||||
const me=await api('GET','/me');
|
||||
if (!me) return;
|
||||
const badge=document.getElementById('mfa-badge'), panel=document.getElementById('mfa-panel');
|
||||
if (me.mfa_enabled) {
|
||||
badge.innerHTML='<span class="badge green">Enabled</span>';
|
||||
panel.innerHTML=`<p style="font-size:13px;color:var(--muted);margin-bottom:12px">TOTP active. Enter code to disable.</p>
|
||||
<div class="modal-field"><label>Code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||
<button class="btn-danger" onclick="disableMFA()">Disable MFA</button>`;
|
||||
} else {
|
||||
badge.innerHTML='<span class="badge red">Disabled</span>';
|
||||
panel.innerHTML='<button class="btn-primary" onclick="beginMFASetup()">Set up Authenticator App</button>';
|
||||
}
|
||||
}
|
||||
|
||||
async function beginMFASetup() {
|
||||
const r=await api('POST','/mfa/setup'); if (!r) return;
|
||||
document.getElementById('mfa-panel').innerHTML=`
|
||||
<p style="font-size:13px;color:var(--muted);margin-bottom:12px">Scan with your authenticator app.</p>
|
||||
<div style="text-align:center;margin-bottom:14px"><img src="${r.qr_url}" style="border-radius:8px;background:white;padding:8px"></div>
|
||||
<p style="font-size:11px;color:var(--muted);margin-bottom:12px;word-break:break-all">Key: <strong>${r.secret}</strong></p>
|
||||
<div class="modal-field"><label>Confirm code</label><input type="text" id="mfa-code" placeholder="000000" maxlength="6" inputmode="numeric"></div>
|
||||
<button class="btn-primary" onclick="confirmMFASetup()">Activate MFA</button>`;
|
||||
}
|
||||
async function confirmMFASetup() {
|
||||
const r=await api('POST','/mfa/confirm',{code:document.getElementById('mfa-code').value});
|
||||
if (r?.ok){toast('MFA enabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
}
|
||||
async function disableMFA() {
|
||||
const r=await api('POST','/mfa/disable',{code:document.getElementById('mfa-code').value});
|
||||
if (r?.ok){toast('MFA disabled','success');renderMFAPanel();}else toast(r?.error||'Invalid code','error');
|
||||
}
|
||||
|
||||
async function doLogout() { await fetch('/auth/logout',{method:'POST'}); location.href='/auth/login'; }
|
||||
|
||||
// ── Context menu helper ────────────────────────────────────────────────────
|
||||
function showCtxMenu(e, html) {
|
||||
const menu=document.getElementById('ctx-menu');
|
||||
menu.innerHTML=html; menu.classList.add('open');
|
||||
requestAnimationFrame(()=>{
|
||||
menu.style.left=Math.min(e.clientX,window.innerWidth-menu.offsetWidth-8)+'px';
|
||||
menu.style.top=Math.min(e.clientY,window.innerHeight-menu.offsetHeight-8)+'px';
|
||||
});
|
||||
}
|
||||
|
||||
// Close compose on overlay click
|
||||
document.addEventListener('click', e=>{
|
||||
if (e.target===document.getElementById('compose-overlay')) {
|
||||
if (S.draftDirty) { if (confirm('Save draft before closing?')) { saveDraft(); return; } }
|
||||
closeCompose(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Init tag fields after DOM is ready
|
||||
document.addEventListener('DOMContentLoaded', ()=>{
|
||||
initTagField('compose-to');
|
||||
initTagField('compose-cc-tags');
|
||||
initTagField('compose-bcc-tags');
|
||||
});
|
||||
|
||||
init();
|
||||
110
web/static/js/gomail.js
Normal file
110
web/static/js/gomail.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// GoMail shared utilities - loaded on every page
|
||||
|
||||
// ---- API helper ----
|
||||
async function api(method, path, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
try {
|
||||
const r = await fetch('/api' + path, opts);
|
||||
if (r.status === 401) { location.href = '/auth/login'; return null; }
|
||||
return r.json().catch(() => null);
|
||||
} catch (e) {
|
||||
console.error('API error:', path, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Toast notifications ----
|
||||
function toast(msg, type) {
|
||||
let container = document.getElementById('toast-container');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'toast-container';
|
||||
container.className = 'toast-container';
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast' + (type ? ' ' + type : '');
|
||||
el.textContent = msg;
|
||||
container.appendChild(el);
|
||||
setTimeout(() => { el.style.opacity = '0'; el.style.transition = 'opacity .3s'; }, 3200);
|
||||
setTimeout(() => el.remove(), 3500);
|
||||
}
|
||||
|
||||
// ---- HTML escaping ----
|
||||
function esc(s) {
|
||||
return String(s || '')
|
||||
.replace(/&/g,'&')
|
||||
.replace(/</g,'<')
|
||||
.replace(/>/g,'>')
|
||||
.replace(/"/g,'"');
|
||||
}
|
||||
|
||||
// ---- Date formatting ----
|
||||
function formatDate(d) {
|
||||
if (!d) return '';
|
||||
const date = new Date(d), now = new Date(), diff = now - date;
|
||||
if (diff < 86400000 && date.getDate() === now.getDate())
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
if (diff < 7 * 86400000)
|
||||
return date.toLocaleDateString([], { weekday: 'short' });
|
||||
return date.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatFullDate(d) {
|
||||
return d ? new Date(d).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' }) : '';
|
||||
}
|
||||
|
||||
// ---- Context menu helpers ----
|
||||
function closeMenu() {
|
||||
const m = document.getElementById('ctx-menu');
|
||||
if (m) m.classList.remove('open');
|
||||
}
|
||||
|
||||
function positionMenu(menu, x, y) {
|
||||
menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 8) + 'px';
|
||||
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 8) + 'px';
|
||||
}
|
||||
|
||||
// ---- Debounce ----
|
||||
function debounce(fn, ms) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// ---- Modal helpers ----
|
||||
function openModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.add('open');
|
||||
}
|
||||
function closeModal(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('open');
|
||||
}
|
||||
|
||||
// Close modals on overlay click
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.classList.contains('modal-overlay')) {
|
||||
e.target.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// Close context menu on any click
|
||||
document.addEventListener('click', closeMenu);
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape') {
|
||||
document.querySelectorAll('.modal-overlay.open').forEach(m => m.classList.remove('open'));
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Rich text compose helpers ----
|
||||
function insertLink() {
|
||||
const url = prompt('Enter URL:');
|
||||
if (!url) return;
|
||||
const text = window.getSelection().toString() || url;
|
||||
document.getElementById('compose-editor').focus();
|
||||
document.execCommand('createLink', false, url);
|
||||
}
|
||||
Reference in New Issue
Block a user