ohmyapi Claude Opus 4.6 (1M context) commited on
Commit
fdd9654
·
1 Parent(s): 2563fc7

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 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
- return {"count": len(lines), "data": "\n".join(lines)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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", "admin"),
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 r=await api('/admin/api/export');
 
 
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-danger btn-sm" onclick="window._deleteAccount(${a.id})" aria-label="Delete account">Delete</button>
 
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
  })();