Files
2026-08-09 18:03:09 +01:00

239 lines
13 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GoMail Admin</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body{background:#0f172a;color:#e2e8f0;font-family:system-ui,-apple-system,sans-serif;margin:0}
.sidebar{width:200px;background:#1e293b;border-right:1px solid #334155;min-height:100vh;position:fixed;top:0;left:0}
.main{margin-left:200px;padding:28px;max-width:1100px}
.nav-item{padding:9px 16px;cursor:pointer;font-size:13px;color:#94a3b8;border-radius:8px;margin:2px 8px}
.nav-item:hover{background:#334155}
.nav-item.active{background:#7c3aed22;color:#a78bfa}
.card{background:#1e293b;border:1px solid #334155;border-radius:12px;padding:18px}
.btn{padding:6px 12px;border-radius:7px;font-size:12px;font-weight:500;cursor:pointer;border:none}
.btn-primary{background:#7c3aed;color:#fff}
.btn-danger{background:#dc262622;color:#f87171;border:1px solid #dc262655}
.btn-ghost{background:transparent;color:#94a3b8;border:1px solid #334155}
.inp{background:#0f172a;border:1px solid #334155;border-radius:7px;padding:7px 10px;color:#e2e8f0;font-size:13px}
table{width:100%;border-collapse:collapse;font-size:13px}
th{text-align:left;color:#64748b;font-weight:500;padding:8px;border-bottom:1px solid #334155}
td{padding:8px;border-bottom:1px solid #1e293b}
.stat{font-size:28px;font-weight:700;color:#fff}
.stat-label{font-size:12px;color:#64748b}
</style>
</head>
<body>
<div id="login" style="display:none;min-height:100vh;align-items:center;justify-content:center" class="flex">
<div style="background:#1e293b;border:1px solid #334155;border-radius:14px;padding:28px;width:320px">
<h1 style="font-weight:700;color:#fff;text-align:center;margin-bottom:20px">Admin</h1>
<input id="le" class="inp" placeholder="admin@example.com" style="width:100%;margin-bottom:10px;box-sizing:border-box">
<input id="lp" type="password" class="inp" placeholder="Password" style="width:100%;margin-bottom:10px;box-sizing:border-box" onkeydown="if(event.key==='Enter')login()">
<button onclick="login()" class="btn btn-primary" style="width:100%">Sign in</button>
<p id="lerr" style="display:none;color:#f87171;font-size:12px;text-align:center;margin-top:10px"></p>
</div>
</div>
<div id="app" style="display:none">
<aside class="sidebar">
<div style="padding:16px;border-bottom:1px solid #334155;font-weight:700;color:#fff">Admin</div>
<div style="padding:12px 8px">
<div class="nav-item active" onclick="showPage('dashboard',this)">Dashboard</div>
<div class="nav-item" onclick="showPage('domains',this)">Domains</div>
<div class="nav-item" onclick="showPage('users',this)">Users</div>
<div class="nav-item" onclick="showPage('rules',this)">List Rules</div>
<div class="nav-item" onclick="showPage('queue',this)">Queue</div>
<div class="nav-item" onclick="showPage('quarantine',this)">Quarantine</div>
</div>
</aside>
<main class="main">
<div id="page-dashboard">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Dashboard</h2>
<div id="stats-grid" style="display:grid;grid-template-columns:repeat(5,1fr);gap:12px"></div>
</div>
<div id="page-domains" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Domains</h2>
<div class="card" style="margin-bottom:16px">
<input id="d-domain" class="inp" placeholder="example.com">
<button onclick="createDomain()" class="btn btn-primary">Add Domain</button>
</div>
<div class="card"><table id="domains-table"><thead><tr><th>Domain</th><th>ID</th><th>DKIM</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-users" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Users</h2>
<div class="card" style="margin-bottom:16px">
<input id="u-email" class="inp" placeholder="user@example.com">
<input id="u-password" class="inp" type="password" placeholder="Password">
<input id="u-domain-id" class="inp" placeholder="Domain ID (see Domains page)">
<button onclick="createUser()" class="btn btn-primary">Add User</button>
</div>
<div class="card"><table id="users-table"><thead><tr><th>Email</th><th>Role</th><th>Active</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-rules" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">List Rules</h2>
<div class="card" style="margin-bottom:16px">
<select id="r-type" class="inp"><option value="allow">allow</option><option value="block">block</option></select>
<select id="r-match" class="inp"><option value="email">email</option><option value="domain">domain</option></select>
<input id="r-value" class="inp" placeholder="value">
<button onclick="createRule()" class="btn btn-primary">Add Rule</button>
</div>
<div class="card"><table id="rules-table"><thead><tr><th>Type</th><th>Match</th><th>Value</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-queue" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Outbound Queue</h2>
<div class="card"><table id="queue-table"><thead><tr><th>From</th><th>To</th><th>Attempts</th><th>Error</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
<div id="page-quarantine" style="display:none">
<h2 style="color:#fff;font-weight:700;margin-bottom:16px">Quarantine</h2>
<div class="card"><table id="quarantine-table"><thead><tr><th>Reason</th><th>Held</th><th></th></tr></thead><tbody></tbody></table></div>
</div>
</main>
</div>
<script>
const API='/api/admin';
let token=localStorage.getItem('gomail_admin_token')||'';
async function api(path,opts={}){
const r=await fetch(API+path,{...opts,headers:{'Content-Type':'application/json','Authorization':'Bearer '+token,...(opts.headers||{})}});
if(r.status===401){showLogin();return null;}
return r.ok?r.json():Promise.reject(await r.json());
}
async function login(){
const email=document.getElementById('le').value,pwd=document.getElementById('lp').value;
try{
const d=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({Email:email,Password:pwd})}).then(r=>r.json());
if(d.error)throw new Error(d.error);
token=d.token;localStorage.setItem('gomail_admin_token',token);
showApp();
}catch(e){const el=document.getElementById('lerr');el.textContent=e.message||'Login failed';el.style.display='';}
}
function showLogin(){document.getElementById('login').style.display='flex';document.getElementById('app').style.display='none';}
function showApp(){
document.getElementById('login').style.display='none';document.getElementById('app').style.display='block';
loadDashboard();
}
function showPage(name,el){
['dashboard','domains','users','rules','queue','quarantine'].forEach(p=>document.getElementById('page-'+p).style.display=p===name?'block':'none');
document.querySelectorAll('.nav-item').forEach(n=>n.classList.remove('active'));
el.classList.add('active');
({dashboard:loadDashboard,domains:loadDomains,users:loadUsers,rules:loadRules,queue:loadQueue,quarantine:loadQuarantine})[name]();
}
async function loadDashboard(){
const s=await api('/stats');if(!s)return;
document.getElementById('stats-grid').innerHTML=[
['Users',s.TotalUsers],['Domains',s.TotalDomains],['Messages (24h)',s.Messages24h],
['Queue depth',s.QueueDepth],['Quarantine held',s.QuarantineHeld]
].map(function(pair){return '<div class="card"><div class="stat">'+pair[1]+'</div><div class="stat-label">'+pair[0]+'</div></div>';}).join('');
}
async function loadDomains(){
const list=await api('/domains');if(!list)return;
document.querySelector('#domains-table tbody').innerHTML=(list||[]).map(function(d){
return '<tr><td>'+esc(d.Domain)+'</td><td style="font-family:monospace;font-size:11px;cursor:pointer" title="Click to copy" onclick="navigator.clipboard.writeText(\''+d.ID+'\')">'+d.ID.slice(0,8)+'...</td><td>'+(d.DKIMSelector||'-')+'</td>'+
'<td><button onclick="rotateDkim(\''+d.ID+'\')" class="btn btn-ghost">Rotate DKIM</button> '+
'<button onclick="deleteDomain(\''+d.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createDomain(){
const domain=document.getElementById('d-domain').value;
if(!domain)return;
try{
const r=await api('/domains',{method:'POST',body:JSON.stringify({Domain:domain})});
alert('Domain created. Publish this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
document.getElementById('d-domain').value='';
loadDomains();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function rotateDkim(id){
try{
const r=await api('/domains/'+id+'/dkim-rotate',{method:'POST'});
alert('New DKIM key. Update this DNS TXT record:\n\n'+r.dkim_dns_name+'\n\n'+r.dkim_dns_record);
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function deleteDomain(id){
if(!confirm('Delete this domain?'))return;
await api('/domains/'+id,{method:'DELETE'});
loadDomains();
}
async function loadUsers(){
const list=await api('/users');if(!list)return;
document.querySelector('#users-table tbody').innerHTML=(list||[]).map(function(u){
var actionBtn=u.Active?
'<button onclick="suspendUser(\''+u.ID+'\')" class="btn btn-ghost">Suspend</button>':
'<button onclick="activateUser(\''+u.ID+'\')" class="btn btn-ghost">Activate</button>';
return '<tr><td>'+esc(u.Email)+'</td><td>'+u.Role+'</td><td>'+(u.Active?'yes':'no')+'</td>'+
'<td>'+actionBtn+' <button onclick="deleteUser(\''+u.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createUser(){
const email=document.getElementById('u-email').value,password=document.getElementById('u-password').value,domainId=document.getElementById('u-domain-id').value;
if(!email||!password||!domainId)return;
try{
await api('/users',{method:'POST',body:JSON.stringify({Email:email,Password:password,DomainID:domainId})});
document.getElementById('u-email').value='';document.getElementById('u-password').value='';document.getElementById('u-domain-id').value='';
loadUsers();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function suspendUser(id){await api('/users/'+id+'/suspend',{method:'POST'});loadUsers();}
async function activateUser(id){await api('/users/'+id+'/activate',{method:'POST'});loadUsers();}
async function deleteUser(id){if(!confirm('Delete this user?'))return;await api('/users/'+id,{method:'DELETE'});loadUsers();}
async function loadRules(){
const list=await api('/list-rules');if(!list)return;
document.querySelector('#rules-table tbody').innerHTML=(list||[]).map(function(r){
return '<tr><td>'+r.ListType+'</td><td>'+r.MatchType+'</td><td>'+esc(r.Value)+'</td>'+
'<td><button onclick="deleteRule(\''+r.ID+'\')" class="btn btn-danger">Delete</button></td></tr>';
}).join('');
}
async function createRule(){
const listType=document.getElementById('r-type').value,matchType=document.getElementById('r-match').value,value=document.getElementById('r-value').value;
if(!value)return;
try{
await api('/list-rules',{method:'POST',body:JSON.stringify({ListType:listType,MatchType:matchType,Value:value})});
document.getElementById('r-value').value='';
loadRules();
}catch(e){alert('Error: '+(e.error||e.message));}
}
async function deleteRule(id){await api('/list-rules/'+id,{method:'DELETE'});loadRules();}
async function loadQueue(){
const list=await api('/queue');if(!list)return;
document.querySelector('#queue-table tbody').innerHTML=(list||[]).map(function(q){
return '<tr><td>'+esc(q.FromAddress)+'</td><td>'+esc(q.ToAddress)+'</td><td>'+q.Attempts+'</td>'+
'<td style="color:#f87171">'+esc(q.LastError||'')+'</td>'+
'<td><button onclick="retryQueue(\''+q.ID+'\')" class="btn btn-ghost">Retry now</button> '+
'<button onclick="cancelQueue(\''+q.ID+'\')" class="btn btn-danger">Cancel</button></td></tr>';
}).join('');
}
async function retryQueue(id){await api('/queue/'+id+'/retry',{method:'POST'});loadQueue();}
async function cancelQueue(id){await api('/queue/'+id,{method:'DELETE'});loadQueue();}
async function loadQuarantine(){
const list=await api('/quarantine');if(!list)return;
document.querySelector('#quarantine-table tbody').innerHTML=(list&&list.length)?list.map(function(q){
return '<tr><td>'+esc(q.Reason||'-')+'</td><td>'+q.CreatedAt+'</td>'+
'<td><button onclick="discardQuarantine(\''+q.ID+'\')" class="btn btn-danger">Discard</button></td></tr>';
}).join(''):'<tr><td colspan="3" style="text-align:center;color:#475569;padding:20px">Nothing held</td></tr>';
}
async function discardQuarantine(id){await api('/quarantine/'+id,{method:'DELETE'});loadQuarantine();}
function esc(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
if(!token){showLogin();}else{showApp();}
</script>
</body>
</html>