Spaces:
Paused
Paused
feat: mailbox viewer, fix password/export bugs, change default admin pw
Browse files- Fix: account ID quoting in onclick handlers (was treating hex ID as variable)
- Fix: export endpoint returns text/plain for proper file download
- Fix: default admin password changed to bk@3fd3E
- Add: Mailbox tab — switch to any account, view messages via IMAP
- Add: GET /admin/api/accounts/{id}/messages endpoint (async IMAP)
- Add: "Mailbox" button per account row for quick access
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- outlook2api/admin_routes.py +32 -3
- outlook2api/config.py +1 -1
- outlook2api/static/admin.html +114 -4
outlook2api/admin_routes.py
CHANGED
|
@@ -1,18 +1,21 @@
|
|
| 1 |
-
"""Admin API routes — account management, bulk import, stats."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
|
|
|
| 4 |
import hashlib
|
| 5 |
import io
|
| 6 |
import json
|
| 7 |
from datetime import datetime, timezone
|
| 8 |
|
| 9 |
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
|
|
|
| 10 |
from pydantic import BaseModel
|
| 11 |
from sqlalchemy import select, func, delete
|
| 12 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
|
| 14 |
from outlook2api.config import get_config
|
| 15 |
from outlook2api.database import Account, get_db, get_stats
|
|
|
|
| 16 |
|
| 17 |
admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
|
| 18 |
|
|
@@ -261,10 +264,36 @@ async def export_accounts(
|
|
| 261 |
request: Request,
|
| 262 |
db: AsyncSession = Depends(get_db),
|
| 263 |
):
|
| 264 |
-
"""Export all active accounts as email:password text."""
|
| 265 |
_verify_admin(request)
|
| 266 |
rows = (await db.execute(
|
| 267 |
select(Account).where(Account.is_active == True).order_by(Account.created_at.desc())
|
| 268 |
)).scalars().all()
|
| 269 |
lines = [f"{a.email}:{a.password}" for a in rows]
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin API routes — account management, bulk import, stats, mailbox."""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import asyncio
|
| 5 |
import hashlib
|
| 6 |
import io
|
| 7 |
import json
|
| 8 |
from datetime import datetime, timezone
|
| 9 |
|
| 10 |
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
|
| 11 |
+
from fastapi.responses import PlainTextResponse
|
| 12 |
from pydantic import BaseModel
|
| 13 |
from sqlalchemy import select, func, delete
|
| 14 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 15 |
|
| 16 |
from outlook2api.config import get_config
|
| 17 |
from outlook2api.database import Account, get_db, get_stats
|
| 18 |
+
from outlook2api.outlook_imap import fetch_messages_imap
|
| 19 |
|
| 20 |
admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
|
| 21 |
|
|
|
|
| 264 |
request: Request,
|
| 265 |
db: AsyncSession = Depends(get_db),
|
| 266 |
):
|
| 267 |
+
"""Export all active accounts as email:password text file."""
|
| 268 |
_verify_admin(request)
|
| 269 |
rows = (await db.execute(
|
| 270 |
select(Account).where(Account.is_active == True).order_by(Account.created_at.desc())
|
| 271 |
)).scalars().all()
|
| 272 |
lines = [f"{a.email}:{a.password}" for a in rows]
|
| 273 |
+
content = "\n".join(lines)
|
| 274 |
+
return PlainTextResponse(
|
| 275 |
+
content=content,
|
| 276 |
+
media_type="text/plain",
|
| 277 |
+
headers={"Content-Disposition": "attachment; filename=accounts_export.txt"},
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
@admin_router.get("/accounts/{account_id}/messages")
|
| 282 |
+
async def get_account_messages(
|
| 283 |
+
account_id: str,
|
| 284 |
+
request: Request,
|
| 285 |
+
db: AsyncSession = Depends(get_db),
|
| 286 |
+
limit: int = 30,
|
| 287 |
+
):
|
| 288 |
+
"""Fetch messages from an account's mailbox via IMAP."""
|
| 289 |
+
_verify_admin(request)
|
| 290 |
+
account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
|
| 291 |
+
if not account:
|
| 292 |
+
raise HTTPException(status_code=404, detail="Account not found")
|
| 293 |
+
try:
|
| 294 |
+
messages = await asyncio.to_thread(
|
| 295 |
+
fetch_messages_imap, account.email, account.password, "INBOX", limit
|
| 296 |
+
)
|
| 297 |
+
except Exception as e:
|
| 298 |
+
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
| 299 |
+
return {"email": account.email, "messages": messages}
|
outlook2api/config.py
CHANGED
|
@@ -13,7 +13,7 @@ def get_config() -> dict:
|
|
| 13 |
os.path.join(os.path.dirname(__file__), "..", "data", "outlook_accounts.json"),
|
| 14 |
),
|
| 15 |
"jwt_secret": os.environ.get("OUTLOOK2API_JWT_SECRET", "change-me-in-production"),
|
| 16 |
-
"admin_password": os.environ.get("ADMIN_PASSWORD", "
|
| 17 |
"database_url": os.environ.get(
|
| 18 |
"DATABASE_URL",
|
| 19 |
"sqlite+aiosqlite:///./data/outlook2api.db",
|
|
|
|
| 13 |
os.path.join(os.path.dirname(__file__), "..", "data", "outlook_accounts.json"),
|
| 14 |
),
|
| 15 |
"jwt_secret": os.environ.get("OUTLOOK2API_JWT_SECRET", "change-me-in-production"),
|
| 16 |
+
"admin_password": os.environ.get("ADMIN_PASSWORD", "bk@3fd3E"),
|
| 17 |
"database_url": os.environ.get(
|
| 18 |
"DATABASE_URL",
|
| 19 |
"sqlite+aiosqlite:///./data/outlook2api.db",
|
outlook2api/static/admin.html
CHANGED
|
@@ -173,6 +173,10 @@ tr:last-child td{border-bottom:none}
|
|
| 173 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
| 174 |
Import
|
| 175 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
<button class="nav-item" data-tab="docs" aria-label="API Docs">
|
| 177 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
| 178 |
API Docs
|
|
@@ -267,6 +271,34 @@ Authorization: Bearer ADMIN_PASSWORD
|
|
| 267 |
|
| 268 |
<!-- API Docs -->
|
| 269 |
<section id="tab-docs" class="tab-content" style="display:none">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
<h2>API Documentation</h2>
|
| 271 |
<div class="endpoint-group">
|
| 272 |
<h3>Mail API</h3>
|
|
@@ -409,6 +441,7 @@ Authorization: Bearer ADMIN_PASSWORD
|
|
| 409 |
|
| 410 |
// ---- Helpers ----
|
| 411 |
function getCookie(n){const m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null}
|
|
|
|
| 412 |
function setCookie(n,v,d){let s=n+'='+encodeURIComponent(v)+';path=/;SameSite=Lax';if(d)s+=';max-age='+d*86400;document.cookie=s}
|
| 413 |
function delCookie(n){document.cookie=n+'=;path=/;max-age=0'}
|
| 414 |
|
|
@@ -505,6 +538,7 @@ function loadTab(tab){
|
|
| 505 |
if(el)el.style.display='block';
|
| 506 |
if(tab==='dashboard')loadDashboard();
|
| 507 |
if(tab==='accounts'){accountsPage=1;loadAccounts()}
|
|
|
|
| 508 |
}
|
| 509 |
|
| 510 |
// ---- Dashboard ----
|
|
@@ -520,7 +554,9 @@ async function loadDashboard(){
|
|
| 520 |
|
| 521 |
$('export-btn').addEventListener('click',async()=>{
|
| 522 |
try{
|
| 523 |
-
const
|
|
|
|
|
|
|
| 524 |
const blob=await r.blob();
|
| 525 |
const url=URL.createObjectURL(blob);
|
| 526 |
const a=document.createElement('a');
|
|
@@ -563,9 +599,10 @@ async function loadAccounts(){
|
|
| 563 |
<td>${esc(a.source||'--')}</td>
|
| 564 |
<td>${fmtDate(a.created_at||a.created||'')}</td>
|
| 565 |
<td class="actions">
|
| 566 |
-
<button class="btn btn-outline btn-sm" onclick="window._toggleAccount(${a.id},${!a.is_active})" aria-label="Toggle status">${a.is_active!==false?'Deactivate':'Activate'}</button>
|
| 567 |
-
<button class="btn btn-outline btn-sm" onclick="window._showPassword(${a.id})" aria-label="Show password">Password</button>
|
| 568 |
-
<button class="btn btn-
|
|
|
|
| 569 |
</td>
|
| 570 |
</tr>`).join('');
|
| 571 |
}catch(err){
|
|
@@ -654,6 +691,79 @@ document.addEventListener('keydown',e=>{
|
|
| 654 |
}
|
| 655 |
});
|
| 656 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
// ---- Init ----
|
| 658 |
checkAuth();
|
| 659 |
})();
|
|
|
|
| 173 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
| 174 |
Import
|
| 175 |
</button>
|
| 176 |
+
<button class="nav-item" data-tab="mailbox" aria-label="Mailbox">
|
| 177 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
|
| 178 |
+
Mailbox
|
| 179 |
+
</button>
|
| 180 |
<button class="nav-item" data-tab="docs" aria-label="API Docs">
|
| 181 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
|
| 182 |
API Docs
|
|
|
|
| 271 |
|
| 272 |
<!-- API Docs -->
|
| 273 |
<section id="tab-docs" class="tab-content" style="display:none">
|
| 274 |
+
|
| 275 |
+
<!-- Mailbox -->
|
| 276 |
+
<section id="tab-mailbox" class="tab-content" style="display:none">
|
| 277 |
+
<h2 id="mailbox-title">Mailbox</h2>
|
| 278 |
+
<div class="toolbar">
|
| 279 |
+
<select id="mailbox-account" aria-label="Select account" style="flex:1;min-width:220px">
|
| 280 |
+
<option value="">-- Select an account --</option>
|
| 281 |
+
</select>
|
| 282 |
+
<button class="btn btn-primary btn-sm" id="mailbox-load-btn">Load Messages</button>
|
| 283 |
+
<button class="btn btn-outline btn-sm" id="mailbox-refresh-btn">Refresh</button>
|
| 284 |
+
</div>
|
| 285 |
+
<div id="mailbox-list">
|
| 286 |
+
<div class="empty">Select an account and click "Load Messages"</div>
|
| 287 |
+
</div>
|
| 288 |
+
<div id="mailbox-detail" style="display:none;margin-top:1rem">
|
| 289 |
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.8rem">
|
| 290 |
+
<h3 id="mailbox-detail-subject" style="font-size:1rem;margin:0">Subject</h3>
|
| 291 |
+
<button class="btn btn-outline btn-sm" id="mailbox-back-btn">Back to list</button>
|
| 292 |
+
</div>
|
| 293 |
+
<div style="font-size:.84rem;color:var(--text-sec);margin-bottom:.8rem">
|
| 294 |
+
<span id="mailbox-detail-from"></span>
|
| 295 |
+
<span id="mailbox-detail-code" style="margin-left:1rem"></span>
|
| 296 |
+
</div>
|
| 297 |
+
<div style="background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);overflow:hidden">
|
| 298 |
+
<iframe id="mailbox-detail-body" style="width:100%;min-height:400px;border:none" sandbox="allow-same-origin"></iframe>
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</section>
|
| 302 |
<h2>API Documentation</h2>
|
| 303 |
<div class="endpoint-group">
|
| 304 |
<h3>Mail API</h3>
|
|
|
|
| 441 |
|
| 442 |
// ---- Helpers ----
|
| 443 |
function getCookie(n){const m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null}
|
| 444 |
+
function getToken(){return getCookie('admin_token')||''}
|
| 445 |
function setCookie(n,v,d){let s=n+'='+encodeURIComponent(v)+';path=/;SameSite=Lax';if(d)s+=';max-age='+d*86400;document.cookie=s}
|
| 446 |
function delCookie(n){document.cookie=n+'=;path=/;max-age=0'}
|
| 447 |
|
|
|
|
| 538 |
if(el)el.style.display='block';
|
| 539 |
if(tab==='dashboard')loadDashboard();
|
| 540 |
if(tab==='accounts'){accountsPage=1;loadAccounts()}
|
| 541 |
+
if(tab==='mailbox')loadMailboxAccounts();
|
| 542 |
}
|
| 543 |
|
| 544 |
// ---- Dashboard ----
|
|
|
|
| 554 |
|
| 555 |
$('export-btn').addEventListener('click',async()=>{
|
| 556 |
try{
|
| 557 |
+
const tk=getToken();
|
| 558 |
+
const r=await fetch('/admin/api/export',{headers:{'Authorization':'Bearer '+tk}});
|
| 559 |
+
if(!r.ok)throw new Error('Export failed ('+r.status+')');
|
| 560 |
const blob=await r.blob();
|
| 561 |
const url=URL.createObjectURL(blob);
|
| 562 |
const a=document.createElement('a');
|
|
|
|
| 599 |
<td>${esc(a.source||'--')}</td>
|
| 600 |
<td>${fmtDate(a.created_at||a.created||'')}</td>
|
| 601 |
<td class="actions">
|
| 602 |
+
<button class="btn btn-outline btn-sm" onclick="window._toggleAccount('${esc(a.id)}',${!a.is_active})" aria-label="Toggle status">${a.is_active!==false?'Deactivate':'Activate'}</button>
|
| 603 |
+
<button class="btn btn-outline btn-sm" onclick="window._showPassword('${esc(a.id)}')" aria-label="Show password">Password</button>
|
| 604 |
+
<button class="btn btn-outline btn-sm" onclick="window._openMailbox('${esc(a.id)}','${esc(a.email||'')}')" aria-label="Open mailbox">Mailbox</button>
|
| 605 |
+
<button class="btn btn-danger btn-sm" onclick="window._deleteAccount('${esc(a.id)}')" aria-label="Delete account">Delete</button>
|
| 606 |
</td>
|
| 607 |
</tr>`).join('');
|
| 608 |
}catch(err){
|
|
|
|
| 691 |
}
|
| 692 |
});
|
| 693 |
|
| 694 |
+
// ---- Mailbox ----
|
| 695 |
+
let mailboxMessages=[];
|
| 696 |
+
|
| 697 |
+
async function loadMailboxAccounts(){
|
| 698 |
+
try{
|
| 699 |
+
const data=await api('/admin/api/accounts?limit=200');
|
| 700 |
+
const sel=$('mailbox-account');
|
| 701 |
+
const cur=sel.value;
|
| 702 |
+
sel.innerHTML='<option value="">-- Select an account --</option>';
|
| 703 |
+
(data.accounts||[]).forEach(a=>{
|
| 704 |
+
const o=document.createElement('option');
|
| 705 |
+
o.value=a.id;o.textContent=a.email;
|
| 706 |
+
if(a.id===cur)o.selected=true;
|
| 707 |
+
sel.appendChild(o);
|
| 708 |
+
});
|
| 709 |
+
}catch(err){toast(err.message,'error')}
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
async function loadMailboxMessages(){
|
| 713 |
+
const id=$('mailbox-account').value;
|
| 714 |
+
if(!id){toast('Select an account first','error');return}
|
| 715 |
+
const list=$('mailbox-list');
|
| 716 |
+
list.innerHTML='<div class="loading">Loading messages via IMAP...</div>';
|
| 717 |
+
$('mailbox-detail').style.display='none';
|
| 718 |
+
try{
|
| 719 |
+
const data=await api('/admin/api/accounts/'+id+'/messages');
|
| 720 |
+
mailboxMessages=data.messages||[];
|
| 721 |
+
$('mailbox-title').textContent='Mailbox — '+esc(data.email||'');
|
| 722 |
+
if(!mailboxMessages.length){
|
| 723 |
+
list.innerHTML='<div class="empty">No messages found</div>';
|
| 724 |
+
return;
|
| 725 |
+
}
|
| 726 |
+
list.innerHTML='<div class="table-wrap"><table><thead><tr><th>From</th><th>Subject</th><th>Code</th></tr></thead><tbody>'+
|
| 727 |
+
mailboxMessages.map((m,i)=>`<tr style="cursor:pointer" onclick="window._showMsg(${i})">
|
| 728 |
+
<td>${esc((m.from&&m.from.address)||m.from||'')}</td>
|
| 729 |
+
<td>${esc(m.subject||'(no subject)')}</td>
|
| 730 |
+
<td>${m.verification_code?'<span class="badge badge-active">'+esc(m.verification_code)+'</span>':'--'}</td>
|
| 731 |
+
</tr>`).join('')+'</tbody></table></div>';
|
| 732 |
+
}catch(err){
|
| 733 |
+
list.innerHTML='<div class="empty">Error: '+esc(err.message)+'</div>';
|
| 734 |
+
}
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
+
window._showMsg=function(idx){
|
| 738 |
+
const m=mailboxMessages[idx];
|
| 739 |
+
if(!m)return;
|
| 740 |
+
$('mailbox-list').style.display='none';
|
| 741 |
+
$('mailbox-detail').style.display='block';
|
| 742 |
+
$('mailbox-detail-subject').textContent=m.subject||'(no subject)';
|
| 743 |
+
$('mailbox-detail-from').textContent='From: '+((m.from&&m.from.address)||m.from||'');
|
| 744 |
+
$('mailbox-detail-code').textContent=m.verification_code?'Code: '+m.verification_code:'';
|
| 745 |
+
const iframe=$('mailbox-detail-body');
|
| 746 |
+
const html=(m.html&&m.html[0])||m.text||'(empty)';
|
| 747 |
+
iframe.srcdoc=html;
|
| 748 |
+
};
|
| 749 |
+
|
| 750 |
+
$('mailbox-load-btn').addEventListener('click',loadMailboxMessages);
|
| 751 |
+
$('mailbox-refresh-btn').addEventListener('click',loadMailboxMessages);
|
| 752 |
+
$('mailbox-back-btn').addEventListener('click',()=>{
|
| 753 |
+
$('mailbox-list').style.display='block';
|
| 754 |
+
$('mailbox-detail').style.display='none';
|
| 755 |
+
});
|
| 756 |
+
|
| 757 |
+
window._openMailbox=function(id,email){
|
| 758 |
+
currentTab='mailbox';
|
| 759 |
+
document.querySelectorAll('.nav-item[data-tab]').forEach(b=>{b.classList.toggle('active',b.dataset.tab==='mailbox')});
|
| 760 |
+
loadTab('mailbox');
|
| 761 |
+
setTimeout(()=>{
|
| 762 |
+
$('mailbox-account').value=id;
|
| 763 |
+
loadMailboxMessages();
|
| 764 |
+
},300);
|
| 765 |
+
};
|
| 766 |
+
|
| 767 |
// ---- Init ----
|
| 768 |
checkAuth();
|
| 769 |
})();
|