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

feat: admin panel overhaul — self-contained CSS, SMTP send, redesigned homepage

Browse files

- Rewrite admin.html: remove Tailwind CDN dependency, all show/hide via
inline style.display (fixes broken modals, tabs, mailbox in China)
- Add outlook_smtp.py: send email via Outlook SMTP with STARTTLS
- Add POST /admin/api/accounts/{id}/send endpoint
- Add GET /admin/api/public-stats (no auth) for homepage stats
- Redesign index.html: gradient bg, colored feature cards, remove
redundant ReDoc link
- Add SMTP config (smtp_host/smtp_port) to config.py and .env.example
- Fix README: correct default password, add send endpoint docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

.env.example CHANGED
@@ -2,7 +2,11 @@
2
  OUTLOOK2API_JWT_SECRET=change-me-in-production
3
  OUTLOOK2API_HOST=0.0.0.0
4
  OUTLOOK2API_PORT=8001
5
- ADMIN_PASSWORD=admin
 
 
 
 
6
 
7
  # === Database ===
8
  # SQLite (default, no setup needed):
 
2
  OUTLOOK2API_JWT_SECRET=change-me-in-production
3
  OUTLOOK2API_HOST=0.0.0.0
4
  OUTLOOK2API_PORT=8001
5
+ ADMIN_PASSWORD=bk@3fd3E
6
+
7
+ # === SMTP (for sending email from admin panel) ===
8
+ OUTLOOK_SMTP_HOST=smtp-mail.outlook.com
9
+ OUTLOOK_SMTP_PORT=587
10
 
11
  # === Database ===
12
  # SQLite (default, no setup needed):
README.md CHANGED
@@ -5,7 +5,7 @@ Mail.tm-compatible REST API for Outlook/Hotmail/Live accounts with admin panel a
5
  ## Features
6
 
7
  - **Mail API** — Mail.tm-compatible Hydra API endpoints (domains, accounts, token, messages)
8
- - **Admin Panel** — Web UI for account management, bulk import/export, stats dashboard
9
  - **Batch Registration** — Automated Outlook account creation via GitHub Actions
10
  - **CI Auto-Import** — Registered accounts automatically imported to admin panel
11
  - **Verification Code Extraction** — `GET /messages/{id}/code` extracts OTP from emails
@@ -20,7 +20,7 @@ pip install -r requirements-api.txt
20
  python -m outlook2api.app
21
 
22
  # Open http://localhost:8001 for homepage
23
- # Open http://localhost:8001/admin for admin panel (default password: admin)
24
  ```
25
 
26
  ### Docker
@@ -58,6 +58,8 @@ docker compose up -d outlook2api
58
  | PATCH | `/admin/api/accounts/{id}` | Toggle active / update notes |
59
  | DELETE | `/admin/api/accounts/{id}` | Delete account |
60
  | GET | `/admin/api/accounts/{id}/password` | Reveal password |
 
 
61
  | GET | `/admin/api/export` | Export all active accounts |
62
 
63
  ## CI Auto-Import
@@ -80,10 +82,12 @@ gh workflow run register-outlook.yml -f count=5 -f threads=1
80
  | Name | Default | Description |
81
  |------|---------|-------------|
82
  | `OUTLOOK2API_JWT_SECRET` | `change-me-in-production` | JWT signing secret |
83
- | `ADMIN_PASSWORD` | `admin` | Admin panel password |
84
  | `DATABASE_URL` | `sqlite+aiosqlite:///./data/outlook2api.db` | Database URL |
85
  | `OUTLOOK2API_HOST` | `0.0.0.0` | API bind host |
86
  | `OUTLOOK2API_PORT` | `8001` | API bind port |
 
 
87
 
88
  ## HuggingFace Deployment
89
 
@@ -101,6 +105,7 @@ outlook2api/
101
  │ ├── auth.py # JWT auth helpers
102
  │ ├── config.py # Environment config
103
  │ ├── outlook_imap.py # IMAP client
 
104
  │ ├── store.py # Legacy JSON file store
105
  │ └── static/ # Frontend
106
  │ ├── index.html # Homepage
 
5
  ## Features
6
 
7
  - **Mail API** — Mail.tm-compatible Hydra API endpoints (domains, accounts, token, messages)
8
+ - **Admin Panel** — Web UI for account management, bulk import/export, webmail with compose/reply, stats dashboard
9
  - **Batch Registration** — Automated Outlook account creation via GitHub Actions
10
  - **CI Auto-Import** — Registered accounts automatically imported to admin panel
11
  - **Verification Code Extraction** — `GET /messages/{id}/code` extracts OTP from emails
 
20
  python -m outlook2api.app
21
 
22
  # Open http://localhost:8001 for homepage
23
+ # Open http://localhost:8001/admin for admin panel (default password: bk@3fd3E)
24
  ```
25
 
26
  ### Docker
 
58
  | PATCH | `/admin/api/accounts/{id}` | Toggle active / update notes |
59
  | DELETE | `/admin/api/accounts/{id}` | Delete account |
60
  | GET | `/admin/api/accounts/{id}/password` | Reveal password |
61
+ | GET | `/admin/api/accounts/{id}/messages` | Fetch messages via IMAP |
62
+ | POST | `/admin/api/accounts/{id}/send` | Send email via SMTP |
63
  | GET | `/admin/api/export` | Export all active accounts |
64
 
65
  ## CI Auto-Import
 
82
  | Name | Default | Description |
83
  |------|---------|-------------|
84
  | `OUTLOOK2API_JWT_SECRET` | `change-me-in-production` | JWT signing secret |
85
+ | `ADMIN_PASSWORD` | `bk@3fd3E` | Admin panel password |
86
  | `DATABASE_URL` | `sqlite+aiosqlite:///./data/outlook2api.db` | Database URL |
87
  | `OUTLOOK2API_HOST` | `0.0.0.0` | API bind host |
88
  | `OUTLOOK2API_PORT` | `8001` | API bind port |
89
+ | `OUTLOOK_SMTP_HOST` | `smtp-mail.outlook.com` | SMTP server host |
90
+ | `OUTLOOK_SMTP_PORT` | `587` | SMTP server port |
91
 
92
  ## HuggingFace Deployment
93
 
 
105
  │ ├── auth.py # JWT auth helpers
106
  │ ├── config.py # Environment config
107
  │ ├── outlook_imap.py # IMAP client
108
+ │ ├── outlook_smtp.py # SMTP client (send email)
109
  │ ├── store.py # Legacy JSON file store
110
  │ └── static/ # Frontend
111
  │ ├── index.html # Homepage
outlook2api/admin_routes.py CHANGED
@@ -16,6 +16,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
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
 
@@ -52,6 +53,16 @@ class BulkImportRequest(BaseModel):
52
  source: str = "import"
53
 
54
 
 
 
 
 
 
 
 
 
 
 
55
  @admin_router.post("/login")
56
  async def admin_login(body: LoginRequest):
57
  cfg = get_config()
@@ -61,6 +72,13 @@ async def admin_login(body: LoginRequest):
61
  return {"token": token}
62
 
63
 
 
 
 
 
 
 
 
64
  @admin_router.get("/stats")
65
  async def admin_stats(
66
  request: Request,
@@ -297,3 +315,33 @@ async def get_account_messages(
297
  except Exception as e:
298
  raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
299
  return {"email": account.email, "messages": messages}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from outlook2api.outlook_smtp import send_email
20
 
21
  admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
22
 
 
53
  source: str = "import"
54
 
55
 
56
+ class SendEmailRequest(BaseModel):
57
+ to: str
58
+ subject: str
59
+ body_text: str = ""
60
+ body_html: str = ""
61
+ cc: str = ""
62
+ in_reply_to: str = ""
63
+ references: str = ""
64
+
65
+
66
  @admin_router.post("/login")
67
  async def admin_login(body: LoginRequest):
68
  cfg = get_config()
 
72
  return {"token": token}
73
 
74
 
75
+ @admin_router.get("/public-stats")
76
+ async def public_stats(db: AsyncSession = Depends(get_db)):
77
+ """Public stats (no auth) — total and active account counts."""
78
+ stats = await get_stats(db)
79
+ return {"total": stats.get("total", 0), "active": stats.get("active", 0)}
80
+
81
+
82
  @admin_router.get("/stats")
83
  async def admin_stats(
84
  request: Request,
 
315
  except Exception as e:
316
  raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
317
  return {"email": account.email, "messages": messages}
318
+
319
+
320
+ @admin_router.post("/accounts/{account_id}/send")
321
+ async def send_account_email(
322
+ account_id: str,
323
+ body: SendEmailRequest,
324
+ request: Request,
325
+ db: AsyncSession = Depends(get_db),
326
+ ):
327
+ """Send an email from an account via SMTP."""
328
+ _verify_admin(request)
329
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
330
+ if not account:
331
+ raise HTTPException(status_code=404, detail="Account not found")
332
+ try:
333
+ result = await asyncio.to_thread(
334
+ send_email,
335
+ from_addr=account.email,
336
+ password=account.password,
337
+ to_addr=body.to,
338
+ subject=body.subject,
339
+ body_text=body.body_text,
340
+ body_html=body.body_html,
341
+ cc=body.cc,
342
+ in_reply_to=body.in_reply_to,
343
+ references=body.references,
344
+ )
345
+ except RuntimeError as e:
346
+ raise HTTPException(status_code=502, detail=str(e))
347
+ return result
outlook2api/config.py CHANGED
@@ -18,4 +18,6 @@ def get_config() -> dict:
18
  "DATABASE_URL",
19
  "sqlite+aiosqlite:///./data/outlook2api.db",
20
  ),
 
 
21
  }
 
18
  "DATABASE_URL",
19
  "sqlite+aiosqlite:///./data/outlook2api.db",
20
  ),
21
+ "smtp_host": os.environ.get("OUTLOOK_SMTP_HOST", "smtp-mail.outlook.com"),
22
+ "smtp_port": int(os.environ.get("OUTLOOK_SMTP_PORT", "587")),
23
  }
outlook2api/outlook_smtp.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Outlook SMTP client for sending emails."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import smtplib
6
+ from email.mime.multipart import MIMEMultipart
7
+ from email.mime.text import MIMEText
8
+
9
+ from outlook2api.config import get_config
10
+
11
+
12
+ def send_email(
13
+ from_addr: str,
14
+ password: str,
15
+ to_addr: str,
16
+ subject: str,
17
+ body_text: str = "",
18
+ body_html: str = "",
19
+ cc: str = "",
20
+ in_reply_to: str = "",
21
+ references: str = "",
22
+ ) -> dict:
23
+ """Send an email via Outlook SMTP with STARTTLS.
24
+
25
+ Returns dict with status, from, to, subject on success.
26
+ Raises RuntimeError on failure.
27
+ """
28
+ cfg = get_config()
29
+ smtp_host = cfg.get("smtp_host", "smtp-mail.outlook.com")
30
+ smtp_port = cfg.get("smtp_port", 587)
31
+
32
+ msg = MIMEMultipart("alternative")
33
+ msg["From"] = from_addr
34
+ msg["To"] = to_addr
35
+ msg["Subject"] = subject
36
+
37
+ if cc:
38
+ msg["Cc"] = cc
39
+ if in_reply_to:
40
+ msg["In-Reply-To"] = in_reply_to
41
+ if references:
42
+ msg["References"] = references
43
+
44
+ if body_text:
45
+ msg.attach(MIMEText(body_text, "plain", "utf-8"))
46
+ if body_html:
47
+ msg.attach(MIMEText(body_html, "html", "utf-8"))
48
+ elif body_text:
49
+ # If no HTML provided, send text as the only part
50
+ pass
51
+ else:
52
+ msg.attach(MIMEText("", "plain", "utf-8"))
53
+
54
+ try:
55
+ with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as server:
56
+ server.ehlo()
57
+ server.starttls()
58
+ server.ehlo()
59
+ server.login(from_addr, password)
60
+ recipients = [to_addr]
61
+ if cc:
62
+ recipients.extend(a.strip() for a in cc.split(",") if a.strip())
63
+ server.sendmail(from_addr, recipients, msg.as_string())
64
+ except smtplib.SMTPAuthenticationError as e:
65
+ raise RuntimeError(f"SMTP authentication failed: {e}") from e
66
+ except smtplib.SMTPException as e:
67
+ raise RuntimeError(f"SMTP error: {e}") from e
68
+ except Exception as e:
69
+ raise RuntimeError(f"Failed to send email: {e}") from e
70
+
71
+ return {"status": "sent", "from": from_addr, "to": to_addr, "subject": subject}
outlook2api/static/admin.html CHANGED
@@ -9,788 +9,865 @@
9
  <style>
10
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
  :root{
12
- --bg:#faf9f7;--surface:#fff;--border:#e8e5e0;
13
- --text:#1a1a1a;--text-sec:#6b6560;--accent:#c96442;
14
- --accent-hover:#b5573a;--accent-light:rgba(201,100,66,.08);
15
- --success:#3a8a5c;--danger:#c44040;--radius:8px;
16
- --shadow:0 1px 3px rgba(0,0,0,.06);
 
 
 
17
  }
18
  html{font-family:'DM Sans',system-ui,-apple-system,sans-serif;font-size:15px;color:var(--text);background:var(--bg)}
19
  body{min-height:100vh}
20
- a{color:var(--accent);text-decoration:none}
21
- button{font-family:inherit;cursor:pointer;border:none;background:none;font-size:.9rem}
22
- input,textarea,select{font-family:inherit;font-size:.9rem;border:1px solid var(--border);border-radius:var(--radius);padding:.55rem .75rem;background:var(--surface);color:var(--text);outline:none;transition:border .2s}
23
- input:focus,textarea:focus,select:focus{border-color:var(--accent)}
24
  textarea{resize:vertical}
 
25
 
26
  /* ---- Buttons ---- */
27
- .btn{display:inline-flex;align-items:center;gap:.4rem;padding:.55rem 1.1rem;border-radius:var(--radius);font-weight:500;transition:all .15s}
28
- .btn-primary{background:var(--accent);color:#fff}.btn-primary:hover{background:var(--accent-hover)}
29
- .btn-outline{border:1px solid var(--border);color:var(--text)}.btn-outline:hover{border-color:var(--accent);color:var(--accent)}
30
- .btn-danger{background:var(--danger);color:#fff}.btn-danger:hover{background:#a83535}
31
- .btn-sm{padding:.35rem .7rem;font-size:.82rem}
32
- .btn:disabled{opacity:.5;cursor:not-allowed}
33
-
34
- /* ---- Badge ---- */
35
- .badge{display:inline-block;padding:.15rem .55rem;border-radius:99px;font-size:.75rem;font-weight:600}
36
- .badge-active{background:rgba(58,138,92,.1);color:var(--success)}
37
- .badge-inactive{background:rgba(196,64,64,.08);color:var(--danger)}
38
-
39
- /* ---- Method badge ---- */
40
- .method{display:inline-block;padding:.1rem .45rem;border-radius:4px;font-size:.7rem;font-weight:700;font-family:monospace;min-width:52px;text-align:center}
41
- .method-get{background:#e8f5e9;color:#2e7d32}
42
- .method-post{background:#e3f2fd;color:#1565c0}
43
- .method-patch{background:#fff3e0;color:#e65100}
44
- .method-delete{background:#fce4ec;color:#c62828}
45
 
46
  /* ---- Toast ---- */
47
- #toast-container{position:fixed;top:1rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem}
48
- .toast{padding:.7rem 1.1rem;border-radius:var(--radius);background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow);font-size:.85rem;animation:slideIn .25s ease;max-width:360px}
49
- .toast-error{border-left:3px solid var(--danger)}.toast-success{border-left:3px solid var(--success)}
50
- @keyframes slideIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}
51
 
52
  /* ---- Login ---- */
53
- #login-screen{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
54
- .login-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:380px;box-shadow:var(--shadow)}
55
- .login-card h1{font-size:1.4rem;margin-bottom:.3rem}
56
- .login-card p{color:var(--text-sec);font-size:.88rem;margin-bottom:1.5rem}
57
- .login-card label{display:block;font-weight:500;margin-bottom:.35rem;font-size:.85rem}
58
- .login-card input{width:100%;margin-bottom:1rem}
59
- .login-card .btn{width:100%;justify-content:center}
60
 
61
  /* ---- Layout ---- */
62
  #app{display:none;min-height:100vh}
63
- .layout{display:flex;min-height:100vh}
64
- .sidebar{width:230px;background:var(--surface);border-right:1px solid var(--border);padding:1.2rem 0;display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;transition:transform .25s}
65
- .sidebar-brand{padding:0 1.2rem .8rem;font-size:1.1rem;font-weight:700;color:var(--accent);border-bottom:1px solid var(--border);margin-bottom:.5rem;padding-bottom:1rem}
66
- .sidebar-brand span{color:var(--text-sec);font-weight:400;font-size:.78rem;display:block;margin-top:.15rem}
67
- .nav-item{display:flex;align-items:center;gap:.6rem;padding:.6rem 1.2rem;color:var(--text-sec);font-weight:500;font-size:.9rem;transition:all .15s;cursor:pointer;border:none;width:100%;text-align:left;background:none}
68
- .nav-item:hover{color:var(--text);background:var(--accent-light)}
69
- .nav-item.active{color:var(--accent);background:var(--accent-light)}
70
- .nav-item svg{width:18px;height:18px;flex-shrink:0}
71
- .sidebar-footer{margin-top:auto;padding-top:.5rem;border-top:1px solid var(--border)}
72
- .main{margin-left:230px;flex:1;padding:2rem;max-width:960px;width:100%}
73
  .main h2{font-size:1.3rem;margin-bottom:1.2rem}
74
 
75
  /* ---- Mobile ---- */
76
- .menu-toggle{display:none;position:fixed;top:.8rem;left:.8rem;z-index:200;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:.45rem .6rem}
77
- .overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:90}
78
  @media(max-width:768px){
79
- .sidebar{transform:translateX(-100%)}
80
- .sidebar.open{transform:translateX(0)}
81
- .overlay.open{display:block}
82
- .menu-toggle{display:block}
83
  .main{margin-left:0;padding:1rem;padding-top:3.5rem}
84
  }
85
 
86
- /* ---- Cards ---- */
87
- .stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
88
- .stat-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.2rem;box-shadow:var(--shadow)}
89
- .stat-card .label{font-size:.78rem;color:var(--text-sec);text-transform:uppercase;letter-spacing:.04em;font-weight:600}
90
- .stat-card .value{font-size:1.8rem;font-weight:700;margin-top:.3rem}
91
 
92
  /* ---- Table ---- */
93
- .table-wrap{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}
94
  table{width:100%;border-collapse:collapse;font-size:.88rem}
95
- th{text-align:left;padding:.7rem .9rem;font-weight:600;color:var(--text-sec);font-size:.78rem;text-transform:uppercase;letter-spacing:.03em;border-bottom:1px solid var(--border);background:var(--bg)}
96
- td{padding:.65rem .9rem;border-bottom:1px solid var(--border)}
97
  tr:last-child td{border-bottom:none}
98
- .actions{display:flex;gap:.35rem;flex-wrap:wrap}
 
99
 
100
  /* ---- Toolbar ---- */
101
- .toolbar{display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
102
- .toolbar input[type=text]{flex:1;min-width:180px}
103
- .toolbar select{min-width:120px}
104
 
105
  /* ---- Pagination ---- */
106
- .pagination{display:flex;align-items:center;gap:.8rem;justify-content:center;margin-top:1rem;font-size:.88rem;color:var(--text-sec)}
107
 
108
  /* ---- Modal ---- */
109
- .modal-overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;align-items:center;justify-content:center;padding:1rem}
110
- .modal-overlay.open{display:flex}
111
- .modal{background:var(--surface);border-radius:12px;padding:1.8rem;width:100%;max-width:440px;box-shadow:0 8px 30px rgba(0,0,0,.12)}
112
  .modal h3{margin-bottom:1rem;font-size:1.1rem}
113
  .modal label{display:block;font-weight:500;margin-bottom:.3rem;font-size:.85rem;margin-top:.8rem}
114
  .modal label:first-of-type{margin-top:0}
115
  .modal input{width:100%}
116
- .modal-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1.2rem}
117
 
118
  /* ---- Import ---- */
119
- .import-section{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1.4rem;margin-bottom:1.2rem;box-shadow:var(--shadow)}
120
- .import-section h3{font-size:1rem;margin-bottom:.6rem}
121
- .import-section p{color:var(--text-sec);font-size:.85rem;margin-bottom:.8rem}
122
- .import-section textarea{width:100%;min-height:120px;margin-bottom:.8rem}
123
-
124
- /* ---- API Docs ---- */
125
- .endpoint-group{margin-bottom:2rem}
126
- .endpoint-group h3{font-size:1rem;margin-bottom:.8rem;padding-bottom:.4rem;border-bottom:1px solid var(--border)}
127
- .endpoint{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:1rem;margin-bottom:.6rem}
128
- .endpoint .path{font-family:monospace;font-size:.88rem;margin-left:.5rem}
129
- .endpoint .desc{color:var(--text-sec);font-size:.84rem;margin-top:.4rem}
130
- .endpoint pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.7rem;margin-top:.6rem;font-size:.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
  /* ---- Misc ---- */
133
- .loading{text-align:center;padding:2rem;color:var(--text-sec)}
134
- .empty{text-align:center;padding:2rem;color:var(--text-sec);font-size:.9rem}
135
- .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}
136
  </style>
137
  </head>
138
  <body>
139
 
140
- <div id="toast-container" aria-live="polite"></div>
141
 
142
- <!-- LOGIN -->
143
- <div id="login-screen">
144
- <div class="login-card">
145
  <h1>Outlook2API</h1>
146
  <p>Sign in to the admin panel</p>
147
- <form id="login-form" autocomplete="on">
148
- <label for="login-pw">Password</label>
149
- <input id="login-pw" type="password" placeholder="Admin password" required aria-label="Admin password">
150
- <button type="submit" class="btn btn-primary" id="login-btn">Sign in</button>
151
  </form>
152
  </div>
153
  </div>
154
 
155
- <!-- APP -->
156
  <div id="app">
157
- <button class="menu-toggle" id="menu-toggle" aria-label="Toggle menu">
158
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>
159
- </button>
160
- <div class="overlay" id="overlay"></div>
161
- <div class="layout">
162
- <nav class="sidebar" id="sidebar" aria-label="Main navigation">
163
- <div class="sidebar-brand">Outlook2API<span>Admin Panel</span></div>
164
- <button class="nav-item active" data-tab="dashboard" aria-label="Dashboard">
165
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
166
- Dashboard
167
- </button>
168
- <button class="nav-item" data-tab="accounts" aria-label="Accounts">
169
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>
170
- Accounts
171
- </button>
172
- <button class="nav-item" data-tab="import" aria-label="Import">
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
183
- </button>
184
- <div class="sidebar-footer">
185
- <button class="nav-item" id="logout-btn" aria-label="Logout">
186
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
187
- Logout
188
- </button>
189
  </div>
190
  </nav>
191
 
192
  <main class="main">
193
- <!-- Dashboard -->
194
- <section id="tab-dashboard" class="tab-content">
 
195
  <h2>Dashboard</h2>
196
- <div class="stat-grid">
197
- <div class="stat-card"><div class="label">Total Accounts</div><div class="value" id="stat-total">--</div></div>
198
- <div class="stat-card"><div class="label">Active</div><div class="value" id="stat-active">--</div></div>
199
- <div class="stat-card"><div class="label">Inactive</div><div class="value" id="stat-inactive">--</div></div>
200
- <div class="stat-card"><div class="label">Last 7 Days</div><div class="value" id="stat-recent">--</div></div>
201
  </div>
202
- <button class="btn btn-outline" id="export-btn" aria-label="Export accounts">
203
- <svg width="16" height="16" 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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
204
- Export Accounts
205
- </button>
206
  </section>
207
 
208
- <!-- Accounts -->
209
- <section id="tab-accounts" class="tab-content" style="display:none">
210
  <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1.2rem">
211
  <h2 style="margin-bottom:0">Accounts</h2>
212
  <div style="display:flex;gap:.5rem">
213
- <button class="btn btn-primary btn-sm" id="add-account-btn" aria-label="Add account">+ Add</button>
214
- <button class="btn btn-danger btn-sm" id="delete-all-btn" aria-label="Delete all accounts">Delete All</button>
215
  </div>
216
  </div>
217
- <div class="toolbar">
218
- <input type="text" id="search-input" placeholder="Search by email..." aria-label="Search accounts">
219
- <select id="active-filter" aria-label="Filter by status">
220
- <option value="">All</option>
221
- <option value="true">Active</option>
222
- <option value="false">Inactive</option>
223
- </select>
224
  </div>
225
- <div class="table-wrap">
226
- <table>
227
- <thead><tr><th>Email</th><th>Status</th><th>Source</th><th>Created</th><th>Actions</th></tr></thead>
228
- <tbody id="accounts-tbody"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
229
- </table>
230
  </div>
231
- <div class="pagination">
232
- <button class="btn btn-outline btn-sm" id="prev-btn" disabled aria-label="Previous page">Prev</button>
233
- <span id="page-info">Page 1</span>
234
- <button class="btn btn-outline btn-sm" id="next-btn" disabled aria-label="Next page">Next</button>
235
  </div>
236
  </section>
237
 
238
- <!-- Import -->
239
- <section id="tab-import" class="tab-content" style="display:none">
240
  <h2>Import Accounts</h2>
241
- <div class="import-section">
242
  <h3>Text Import</h3>
243
  <p>Enter accounts, one per line in <code>email:password</code> format.</p>
244
- <textarea id="bulk-text" placeholder="user1@outlook.com:password1&#10;user2@outlook.com:password2" aria-label="Bulk account text"></textarea>
245
- <button class="btn btn-primary" id="bulk-submit-btn">Import Accounts</button>
246
  </div>
247
- <div class="import-section">
248
  <h3>File Upload</h3>
249
  <p>Upload a <code>.txt</code> file with one <code>email:password</code> per line.</p>
250
- <input type="file" id="file-upload" accept=".txt" aria-label="Upload account file">
251
- <div style="margin-top:.6rem"><button class="btn btn-primary" id="file-submit-btn">Upload File</button></div>
252
  </div>
253
- <div class="import-section">
254
  <h3>CI Import</h3>
255
- <p>Use this endpoint in your GitHub Actions workflow to auto-import accounts.</p>
256
- <pre>POST /admin/api/accounts/bulk
257
- Content-Type: application/json
258
- Authorization: Bearer ADMIN_PASSWORD
259
-
260
- {
261
- "accounts": ["user@outlook.com:pass"],
262
- "source": "ci"
263
- }</pre>
264
- <p style="margin-top:.6rem;font-weight:500">curl example:</p>
265
  <pre>curl -X POST https://your-domain/admin/api/accounts/bulk \
266
  -H "Authorization: Bearer ADMIN_PASSWORD" \
267
  -H "Content-Type: application/json" \
268
- -d '{"accounts": ["user@outlook.com:pass"], "source": "ci"}'</pre>
269
  </div>
270
  </section>
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>
305
- <div class="endpoint">
306
- <span class="method method-get">GET</span><span class="path">/domains</span>
307
- <div class="desc">List supported email domains</div>
308
- <pre>curl https://your-domain/domains</pre>
309
- </div>
310
- <div class="endpoint">
311
- <span class="method method-post">POST</span><span class="path">/accounts</span>
312
- <div class="desc">Register account (validates via IMAP)</div>
313
- <pre>curl -X POST https://your-domain/accounts \
314
- -H "Content-Type: application/json" \
315
- -d '{"address": "user@outlook.com", "password": "pass"}'</pre>
316
- </div>
317
- <div class="endpoint">
318
- <span class="method method-post">POST</span><span class="path">/token</span>
319
- <div class="desc">Get JWT token</div>
320
- <pre>curl -X POST https://your-domain/token \
321
- -H "Content-Type: application/json" \
322
- -d '{"address": "user@outlook.com", "password": "pass"}'</pre>
323
- </div>
324
- <div class="endpoint">
325
- <span class="method method-get">GET</span><span class="path">/me</span>
326
- <div class="desc">Get current user info (requires Bearer token)</div>
327
- <pre>curl https://your-domain/me \
328
- -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
329
- </div>
330
- <div class="endpoint">
331
- <span class="method method-get">GET</span><span class="path">/messages</span>
332
- <div class="desc">List messages (requires Bearer token, ?page=1)</div>
333
- <pre>curl "https://your-domain/messages?page=1" \
334
- -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
335
- </div>
336
- <div class="endpoint">
337
- <span class="method method-get">GET</span><span class="path">/messages/{id}</span>
338
- <div class="desc">Get a specific message (requires Bearer token)</div>
339
- <pre>curl https://your-domain/messages/123 \
340
- -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
341
- </div>
342
- <div class="endpoint">
343
- <span class="method method-get">GET</span><span class="path">/messages/{id}/code</span>
344
- <div class="desc">Extract verification code from message (requires Bearer token)</div>
345
- <pre>curl https://your-domain/messages/123/code \
346
- -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
347
- </div>
348
- <div class="endpoint">
349
- <span class="method method-delete">DELETE</span><span class="path">/accounts/me</span>
350
- <div class="desc">Delete current account (requires Bearer token)</div>
351
- <pre>curl -X DELETE https://your-domain/accounts/me \
352
- -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
353
- </div>
354
- </div>
355
- <div class="endpoint-group">
356
- <h3>Admin API</h3>
357
- <p style="color:var(--text-sec);font-size:.84rem;margin-bottom:.8rem;margin-top:-.4rem">All endpoints require Bearer token or admin_token cookie.</p>
358
- <div class="endpoint">
359
- <span class="method method-post">POST</span><span class="path">/admin/api/login</span>
360
- <div class="desc">Authenticate and receive admin token</div>
361
- <pre>curl -X POST https://your-domain/admin/api/login \
362
- -H "Content-Type: application/json" \
363
- -d '{"password": "your_password"}'</pre>
364
- </div>
365
- <div class="endpoint">
366
- <span class="method method-get">GET</span><span class="path">/admin/api/stats</span>
367
- <div class="desc">Get account statistics</div>
368
- <pre>curl https://your-domain/admin/api/stats \
369
- -H "Authorization: Bearer ADMIN_TOKEN"</pre>
370
- </div>
371
- <div class="endpoint">
372
- <span class="method method-get">GET</span><span class="path">/admin/api/accounts</span>
373
- <div class="desc">List accounts with pagination and filters (?page=&amp;search=&amp;active=)</div>
374
- <pre>curl "https://your-domain/admin/api/accounts?page=1&amp;search=user&amp;active=true" \
375
- -H "Authorization: Bearer ADMIN_TOKEN"</pre>
376
- </div>
377
- <div class="endpoint">
378
- <span class="method method-post">POST</span><span class="path">/admin/api/accounts</span>
379
- <div class="desc">Add a single account</div>
380
- <pre>curl -X POST https://your-domain/admin/api/accounts \
381
- -H "Authorization: Bearer ADMIN_TOKEN" \
382
- -H "Content-Type: application/json" \
383
- -d '{"email": "user@outlook.com", "password": "pass"}'</pre>
384
- </div>
385
- <div class="endpoint">
386
- <span class="method method-post">POST</span><span class="path">/admin/api/accounts/bulk</span>
387
- <div class="desc">Bulk import accounts</div>
388
- <pre>curl -X POST https://your-domain/admin/api/accounts/bulk \
389
- -H "Authorization: Bearer ADMIN_TOKEN" \
390
- -H "Content-Type: application/json" \
391
- -d '{"accounts": ["user@outlook.com:pass"], "source": "manual"}'</pre>
392
- </div>
393
- <div class="endpoint">
394
- <span class="method method-post">POST</span><span class="path">/admin/api/accounts/upload</span>
395
- <div class="desc">Upload accounts file (multipart form)</div>
396
- <pre>curl -X POST https://your-domain/admin/api/accounts/upload \
397
- -H "Authorization: Bearer ADMIN_TOKEN" \
398
- -F "file=@accounts.txt"</pre>
399
- </div>
400
- <div class="endpoint">
401
- <span class="method method-patch">PATCH</span><span class="path">/admin/api/accounts/{id}</span>
402
- <div class="desc">Update account (toggle active status)</div>
403
- <pre>curl -X PATCH https://your-domain/admin/api/accounts/123 \
404
- -H "Authorization: Bearer ADMIN_TOKEN" \
405
- -H "Content-Type: application/json" \
406
- -d '{"is_active": true}'</pre>
407
- </div>
408
- <div class="endpoint">
409
- <span class="method method-delete">DELETE</span><span class="path">/admin/api/accounts/{id}</span>
410
- <div class="desc">Delete a specific account</div>
411
- <pre>curl -X DELETE https://your-domain/admin/api/accounts/123 \
412
- -H "Authorization: Bearer ADMIN_TOKEN"</pre>
413
- </div>
414
- <div class="endpoint">
415
- <span class="method method-delete">DELETE</span><span class="path">/admin/api/accounts</span>
416
- <div class="desc">Delete all accounts</div>
417
- <pre>curl -X DELETE https://your-domain/admin/api/accounts \
418
- -H "Authorization: Bearer ADMIN_TOKEN"</pre>
419
- </div>
420
- <div class="endpoint">
421
- <span class="method method-get">GET</span><span class="path">/admin/api/accounts/{id}/password</span>
422
- <div class="desc">Retrieve account password</div>
423
- <pre>curl https://your-domain/admin/api/accounts/123/password \
424
- -H "Authorization: Bearer ADMIN_TOKEN"</pre>
425
- </div>
426
- <div class="endpoint">
427
- <span class="method method-get">GET</span><span class="path">/admin/api/export</span>
428
- <div class="desc">Export all accounts as text file</div>
429
- <pre>curl https://your-domain/admin/api/export \
430
- -H "Authorization: Bearer ADMIN_TOKEN" -o accounts.txt</pre>
431
- </div>
432
- </div>
433
  </section>
 
434
  </main>
435
  </div>
436
  </div>
437
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
  <script>
439
  (function(){
440
  'use strict';
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
-
448
- function getToken(){return getCookie('admin_token')||''}
449
-
450
- function toast(msg,type){
451
- const c=document.getElementById('toast-container');
452
- const d=document.createElement('div');
453
- d.className='toast toast-'+(type||'success');
454
- d.textContent=msg;
455
- c.appendChild(d);
456
- setTimeout(()=>d.remove(),4000);
 
 
 
457
  }
458
 
459
- async function api(path,opts={}){
460
- const headers=opts.headers||{};
461
- const tk=getToken();
462
- if(tk)headers['Authorization']='Bearer '+tk;
 
463
  if(opts.body&&typeof opts.body==='object'&&!(opts.body instanceof FormData)){
464
  headers['Content-Type']='application/json';
465
  opts.body=JSON.stringify(opts.body);
466
  }
467
  opts.headers=headers;
468
- const r=await fetch(path,opts);
469
- if(!r.ok){
470
- let msg='Request failed ('+r.status+')';
471
- try{const j=await r.json();msg=j.detail||j.message||msg}catch(e){}
472
- throw new Error(msg);
473
- }
474
- const ct=r.headers.get('content-type')||'';
475
- if(ct.includes('json'))return r.json();
476
- return r;
 
477
  }
478
 
479
- // ---- State ----
480
- let currentTab='dashboard';
481
- let accountsPage=1;
482
- let accountsTotal=0;
483
- const PAGE_SIZE=20;
484
-
485
- // ---- DOM refs ----
486
- const $=id=>document.getElementById(id);
487
 
488
- // ---- Auth check ----
489
  function checkAuth(){
490
- if(getToken()){
491
- $('login-screen').style.display='none';
492
- $('app').style.display='block';
493
- loadTab(currentTab);
494
- } else {
495
- $('login-screen').style.display='flex';
496
- $('app').style.display='none';
497
- }
498
  }
499
 
500
- // ---- Login ----
501
- $('login-form').addEventListener('submit',async e=>{
502
  e.preventDefault();
503
- const btn=$('login-btn');
504
- btn.disabled=true;btn.textContent='Signing in...';
505
- try{
506
- const data=await api('/admin/api/login',{method:'POST',body:{password:$('login-pw').value}});
507
- setCookie('admin_token',data.token||data.access_token,7);
508
- toast('Signed in');
509
- checkAuth();
510
- }catch(err){toast(err.message,'error')}
511
- finally{btn.disabled=false;btn.textContent='Sign in'}
512
- });
513
 
514
- // ---- Logout ----
515
- $('logout-btn').addEventListener('click',()=>{delCookie('admin_token');checkAuth()});
516
-
517
- // ---- Sidebar nav ----
518
- document.querySelectorAll('.nav-item[data-tab]').forEach(btn=>{
519
- btn.addEventListener('click',()=>{
520
- document.querySelectorAll('.nav-item[data-tab]').forEach(b=>b.classList.remove('active'));
521
- btn.classList.add('active');
522
- currentTab=btn.dataset.tab;
523
- loadTab(currentTab);
524
- // close mobile sidebar
525
- $('sidebar').classList.remove('open');
526
- $('overlay').classList.remove('open');
527
- });
528
  });
529
 
530
- // ---- Mobile menu ----
531
- $('menu-toggle').addEventListener('click',()=>{$('sidebar').classList.toggle('open');$('overlay').classList.toggle('open')});
532
- $('overlay').addEventListener('click',()=>{$('sidebar').classList.remove('open');$('overlay').classList.remove('open')});
533
-
534
- // ---- Tab loading ----
535
- function loadTab(tab){
536
- document.querySelectorAll('.tab-content').forEach(s=>s.style.display='none');
537
- const el=$('tab-'+tab);
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 ----
545
- async function loadDashboard(){
546
- try{
547
- const s=await api('/admin/api/stats');
548
- $('stat-total').textContent=s.total??'--';
549
- $('stat-active').textContent=s.active??'--';
550
- $('stat-inactive').textContent=s.inactive??'--';
551
- $('stat-recent').textContent=s.recent_7d??s.recent??'--';
552
- }catch(err){toast(err.message,'error')}
553
  }
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');
563
- a.href=url;a.download='accounts_export.txt';a.click();
564
  URL.revokeObjectURL(url);
565
  toast('Export downloaded');
566
- }catch(err){toast(err.message,'error')}
567
- });
568
 
569
- // ---- Accounts ----
570
- let searchTimer=null;
571
- $('search-input').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{accountsPage=1;loadAccounts()},350)});
572
- $('active-filter').addEventListener('change',()=>{accountsPage=1;loadAccounts()});
573
- $('prev-btn').addEventListener('click',()=>{if(accountsPage>1){accountsPage--;loadAccounts()}});
574
- $('next-btn').addEventListener('click',()=>{accountsPage++;loadAccounts()});
575
-
576
- async function loadAccounts(){
577
- const tbody=$('accounts-tbody');
578
- tbody.innerHTML='<tr><td colspan="5" class="loading">Loading...</td></tr>';
579
- try{
580
- const search=$('search-input').value;
581
- const active=$('active-filter').value;
582
- let url='/admin/api/accounts?page='+accountsPage;
583
- if(search)url+='&search='+encodeURIComponent(search);
584
- if(active)url+='&active='+active;
585
- const data=await api(url);
586
- const accounts=data.accounts||data.items||data||[];
587
- accountsTotal=data.total||accounts.length;
588
- const totalPages=Math.max(1,Math.ceil(accountsTotal/PAGE_SIZE));
589
- $('page-info').textContent='Page '+accountsPage+' of '+totalPages;
590
- $('prev-btn').disabled=accountsPage<=1;
591
- $('next-btn').disabled=accountsPage>=totalPages;
592
- if(!accounts.length){
593
- tbody.innerHTML='<tr><td colspan="5" class="empty">No accounts found</td></tr>';
594
- return;
595
- }
596
- tbody.innerHTML=accounts.map(a=>`<tr>
597
- <td>${esc(a.email||a.address||'')}</td>
598
- <td><span class="badge ${a.is_active!==false?'badge-active':'badge-inactive'}">${a.is_active!==false?'Active':'Inactive'}</span></td>
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){
609
- tbody.innerHTML='<tr><td colspan="5" class="empty">Error: '+esc(err.message)+'</td></tr>';
610
- }
611
  }
612
 
613
- function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
614
- function fmtDate(s){if(!s)return'--';try{return new Date(s).toLocaleDateString()}catch(e){return s}}
615
-
616
- window._toggleAccount=async(id,active)=>{
617
- try{await api('/admin/api/accounts/'+id,{method:'PATCH',body:{is_active:active}});toast('Account updated');loadAccounts()}
618
- catch(err){toast(err.message,'error')}
619
  };
620
- window._showPassword=async(id)=>{
621
- $('pw-display').textContent='Loading...';
622
- $('pw-modal').classList.add('open');
623
- try{const d=await api('/admin/api/accounts/'+id+'/password');$('pw-display').textContent=d.password||d.pw||JSON.stringify(d)}
624
- catch(err){$('pw-display').textContent='Error: '+err.message}
 
625
  };
626
- window._deleteAccount=async(id)=>{
627
  if(!confirm('Delete this account?'))return;
628
- try{await api('/admin/api/accounts/'+id,{method:'DELETE'});toast('Account deleted');loadAccounts()}
629
- catch(err){toast(err.message,'error')}
630
  };
631
 
632
- $('pw-close-btn').addEventListener('click',()=>$('pw-modal').classList.remove('open'));
633
- $('pw-modal').addEventListener('click',e=>{if(e.target===$('pw-modal'))$('pw-modal').classList.remove('open')});
634
-
635
- // ---- Add account ----
636
- $('add-account-btn').addEventListener('click',()=>{$('add-email').value='';$('add-password').value='';$('add-modal').classList.add('open');$('add-email').focus()});
637
- $('add-cancel-btn').addEventListener('click',()=>$('add-modal').classList.remove('open'));
638
- $('add-modal').addEventListener('click',e=>{if(e.target===$('add-modal'))$('add-modal').classList.remove('open')});
639
- $('add-confirm-btn').addEventListener('click',async()=>{
640
- const email=$('add-email').value.trim();
641
- const pw=$('add-password').value;
642
- if(!email||!pw){toast('Fill in all fields','error');return}
643
- const btn=$('add-confirm-btn');btn.disabled=true;btn.textContent='Adding...';
644
- try{
645
- await api('/admin/api/accounts',{method:'POST',body:{email,password:pw}});
646
- toast('Account added');$('add-modal').classList.remove('open');loadAccounts();
647
- }catch(err){toast(err.message,'error')}
648
- finally{btn.disabled=false;btn.textContent='Add Account'}
649
- });
650
 
651
- // ---- Delete all ----
652
- $('delete-all-btn').addEventListener('click',async()=>{
653
- if(!confirm('Delete ALL accounts? This cannot be undone.'))return;
654
  if(!confirm('Are you really sure?'))return;
655
- try{await api('/admin/api/accounts',{method:'DELETE'});toast('All accounts deleted');loadAccounts()}
656
- catch(err){toast(err.message,'error')}
657
- });
658
 
659
- // ---- Import: bulk text ----
660
- $('bulk-submit-btn').addEventListener('click',async()=>{
661
- const lines=$('bulk-text').value.trim().split('\n').map(l=>l.trim()).filter(Boolean);
662
- if(!lines.length){toast('Enter at least one account','error');return}
663
- const btn=$('bulk-submit-btn');btn.disabled=true;btn.textContent='Importing...';
664
- try{
665
- const d=await api('/admin/api/accounts/bulk',{method:'POST',body:{accounts:lines,source:'manual'}});
666
- toast('Imported '+(d.imported||d.count||lines.length)+' accounts');
667
- $('bulk-text').value='';
668
- }catch(err){toast(err.message,'error')}
669
- finally{btn.disabled=false;btn.textContent='Import Accounts'}
670
- });
671
 
672
- // ---- Import: file upload ----
673
- $('file-submit-btn').addEventListener('click',async()=>{
674
- const file=$('file-upload').files[0];
675
- if(!file){toast('Select a file first','error');return}
676
- const btn=$('file-submit-btn');btn.disabled=true;btn.textContent='Uploading...';
677
- try{
678
- const fd=new FormData();fd.append('file',file);
679
- const d=await api('/admin/api/accounts/upload',{method:'POST',body:fd});
680
- toast('Uploaded '+(d.imported||d.count||'')+ ' accounts');
681
- $('file-upload').value='';
682
- }catch(err){toast(err.message,'error')}
683
- finally{btn.disabled=false;btn.textContent='Upload File'}
684
- });
685
 
686
- // ---- Keyboard: Escape closes modals ----
687
- document.addEventListener('keydown',e=>{
688
- if(e.key==='Escape'){
689
- $('add-modal').classList.remove('open');
690
- $('pw-modal').classList.remove('open');
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
  })();
770
  </script>
771
-
772
- <!-- Add Account Modal -->
773
- <div class="modal-overlay" id="add-modal" aria-modal="true" role="dialog" aria-label="Add account">
774
- <div class="modal">
775
- <h3>Add Account</h3>
776
- <label for="add-email">Email</label>
777
- <input id="add-email" type="email" placeholder="user@outlook.com" required>
778
- <label for="add-password">Password</label>
779
- <input id="add-password" type="text" placeholder="Password" required>
780
- <div class="modal-actions">
781
- <button class="btn btn-outline" id="add-cancel-btn">Cancel</button>
782
- <button class="btn btn-primary" id="add-confirm-btn">Add Account</button>
783
- </div>
784
- </div>
785
- </div>
786
-
787
- <!-- Password Modal -->
788
- <div class="modal-overlay" id="pw-modal" aria-modal="true" role="dialog" aria-label="Account password">
789
- <div class="modal">
790
- <h3>Account Password</h3>
791
- <p id="pw-display" style="font-family:monospace;background:var(--bg);padding:.7rem;border-radius:6px;margin-top:.5rem;word-break:break-all">Loading...</p>
792
- <div class="modal-actions"><button class="btn btn-outline" id="pw-close-btn">Close</button></div>
793
- </div>
794
- </div>
795
  </body>
796
  </html>
 
9
  <style>
10
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
  :root{
12
+ --bg:#f8f7f4;--surface:#fff;--border:#e8e5e0;
13
+ --text:#1a1a1a;--text2:#6b6560;--text3:#9b958e;
14
+ --brand:#c96442;--brand-h:#b5573a;--brand-bg:rgba(201,100,66,.07);
15
+ --ok:#2d8a4e;--ok-bg:rgba(45,138,78,.08);
16
+ --err:#dc3545;--err-bg:rgba(220,53,69,.06);
17
+ --info:#2563eb;--info-bg:rgba(37,99,235,.07);
18
+ --warn:#e65100;--warn-bg:rgba(230,81,0,.07);
19
+ --r:8px;--shadow:0 1px 3px rgba(0,0,0,.05);--shadow2:0 4px 12px rgba(0,0,0,.07);
20
  }
21
  html{font-family:'DM Sans',system-ui,-apple-system,sans-serif;font-size:15px;color:var(--text);background:var(--bg)}
22
  body{min-height:100vh}
23
+ a{color:var(--brand);text-decoration:none}
24
+ input,textarea,select{font-family:inherit;font-size:.9rem;border:1px solid var(--border);border-radius:var(--r);padding:.55rem .75rem;background:var(--surface);color:var(--text);outline:none;transition:border .2s}
25
+ input:focus,textarea:focus,select:focus{border-color:var(--brand)}
 
26
  textarea{resize:vertical}
27
+ button{font-family:inherit;cursor:pointer;border:none;background:none;font-size:.9rem}
28
 
29
  /* ---- Buttons ---- */
30
+ .btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;border-radius:var(--r);font-weight:500;transition:all .15s;font-size:.85rem}
31
+ .btn-p{background:var(--brand);color:#fff}.btn-p:hover{background:var(--brand-h)}
32
+ .btn-o{border:1px solid var(--border);color:var(--text)}.btn-o:hover{border-color:var(--brand);color:var(--brand)}
33
+ .btn-d{background:var(--err);color:#fff}.btn-d:hover{background:#c82333}
34
+ .btn-s{padding:.3rem .6rem;font-size:.78rem}
35
+ .btn:disabled{opacity:.45;cursor:not-allowed}
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  /* ---- Toast ---- */
38
+ #toast-box{position:fixed;top:1rem;right:1rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem}
39
+ .toast{padding:.65rem 1rem;border-radius:var(--r);background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow2);font-size:.84rem;animation:tIn .25s ease;max-width:340px;border-left:3px solid var(--ok)}
40
+ .toast-err{border-left-color:var(--err)}
41
+ @keyframes tIn{from{opacity:0;transform:translateX(30px)}to{opacity:1;transform:translateX(0)}}
42
 
43
  /* ---- Login ---- */
44
+ #login{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1rem}
45
+ .login-box{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:2.5rem;width:100%;max-width:380px;box-shadow:var(--shadow2)}
46
+ .login-box h1{font-size:1.4rem;margin-bottom:.3rem}
47
+ .login-box p{color:var(--text2);font-size:.88rem;margin-bottom:1.5rem}
48
+ .login-box label{display:block;font-weight:500;margin-bottom:.35rem;font-size:.85rem}
49
+ .login-box input{width:100%;margin-bottom:1rem}
50
+ .login-box .btn{width:100%;justify-content:center}
51
 
52
  /* ---- Layout ---- */
53
  #app{display:none;min-height:100vh}
54
+ .lay{display:flex;min-height:100vh}
55
+ .side{width:220px;background:var(--surface);border-right:1px solid var(--border);padding:1.2rem 0;display:flex;flex-direction:column;position:fixed;top:0;left:0;bottom:0;z-index:100;transition:transform .25s}
56
+ .side-brand{padding:0 1.2rem;font-size:1.1rem;font-weight:700;color:var(--brand);border-bottom:1px solid var(--border);margin-bottom:.5rem;padding-bottom:1rem}
57
+ .side-brand span{color:var(--text2);font-weight:400;font-size:.78rem;display:block;margin-top:.15rem}
58
+ .nav{display:flex;align-items:center;gap:.6rem;padding:.6rem 1.2rem;color:var(--text2);font-weight:500;font-size:.9rem;transition:all .15s;cursor:pointer;border:none;width:100%;text-align:left;background:none}
59
+ .nav:hover{color:var(--text);background:var(--brand-bg)}
60
+ .nav.on{color:var(--brand);background:var(--brand-bg)}
61
+ .nav svg{width:18px;height:18px;flex-shrink:0}
62
+ .side-foot{margin-top:auto;padding-top:.5rem;border-top:1px solid var(--border)}
63
+ .main{margin-left:220px;flex:1;padding:2rem;max-width:960px;width:100%}
64
  .main h2{font-size:1.3rem;margin-bottom:1.2rem}
65
 
66
  /* ---- Mobile ---- */
67
+ .menu-btn{display:none;position:fixed;top:.8rem;left:.8rem;z-index:200;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:.45rem .6rem}
68
+ .ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.25);z-index:90}
69
  @media(max-width:768px){
70
+ .side{transform:translateX(-100%)}
71
+ .side.open{transform:translateX(0)}
72
+ .ov.open{display:block}
73
+ .menu-btn{display:block}
74
  .main{margin-left:0;padding:1rem;padding-top:3.5rem}
75
  }
76
 
77
+ /* ---- Cards/Stats ---- */
78
+ .stat-g{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:1rem;margin-bottom:1.5rem}
79
+ .stat-c{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:1.2rem;box-shadow:var(--shadow)}
80
+ .stat-c .l{font-size:.76rem;color:var(--text2);text-transform:uppercase;letter-spacing:.04em;font-weight:600}
81
+ .stat-c .v{font-size:1.8rem;font-weight:700;margin-top:.3rem}
82
 
83
  /* ---- Table ---- */
84
+ .tbl-w{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow)}
85
  table{width:100%;border-collapse:collapse;font-size:.88rem}
86
+ th{text-align:left;padding:.7rem .9rem;font-weight:600;color:var(--text2);font-size:.76rem;text-transform:uppercase;letter-spacing:.03em;border-bottom:1px solid var(--border);background:var(--bg)}
87
+ td{padding:.6rem .9rem;border-bottom:1px solid var(--border)}
88
  tr:last-child td{border-bottom:none}
89
+ tr:hover td{background:rgba(0,0,0,.015)}
90
+ .acts{display:flex;gap:.3rem;flex-wrap:wrap}
91
 
92
  /* ---- Toolbar ---- */
93
+ .bar{display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
94
+ .bar input[type=text]{flex:1;min-width:180px}
95
+ .bar select{min-width:120px}
96
 
97
  /* ---- Pagination ---- */
98
+ .pag{display:flex;align-items:center;gap:.8rem;justify-content:center;margin-top:1rem;font-size:.88rem;color:var(--text2)}
99
 
100
  /* ---- Modal ---- */
101
+ .modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:500;align-items:center;justify-content:center;padding:1rem}
102
+ .modal{background:var(--surface);border-radius:12px;padding:1.8rem;width:100%;max-width:440px;box-shadow:var(--shadow2)}
 
103
  .modal h3{margin-bottom:1rem;font-size:1.1rem}
104
  .modal label{display:block;font-weight:500;margin-bottom:.3rem;font-size:.85rem;margin-top:.8rem}
105
  .modal label:first-of-type{margin-top:0}
106
  .modal input{width:100%}
107
+ .modal-foot{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1.2rem}
108
 
109
  /* ---- Import ---- */
110
+ .imp{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:1.4rem;margin-bottom:1.2rem;box-shadow:var(--shadow)}
111
+ .imp h3{font-size:1rem;margin-bottom:.6rem}
112
+ .imp p{color:var(--text2);font-size:.85rem;margin-bottom:.8rem}
113
+ .imp textarea{width:100%;min-height:120px;margin-bottom:.8rem}
114
+ .imp code{background:var(--bg);padding:.1rem .4rem;border-radius:4px;font-size:.82rem}
115
+
116
+ /* ---- Badges ---- */
117
+ .badge{display:inline-block;padding:.12rem .5rem;border-radius:99px;font-size:.73rem;font-weight:600}
118
+ .b-ok{background:var(--ok-bg);color:var(--ok)}
119
+ .b-err{background:var(--err-bg);color:var(--err)}
120
+ .meth{display:inline-block;padding:.1rem .4rem;border-radius:4px;font-size:.68rem;font-weight:700;font-family:monospace;min-width:48px;text-align:center}
121
+ .m-get{background:#e8f5e9;color:#2e7d32}
122
+ .m-post{background:#e3f2fd;color:#1565c0}
123
+ .m-patch{background:var(--warn-bg);color:var(--warn)}
124
+ .m-del{background:#fce4ec;color:#c62828}
125
+
126
+ /* ---- Mailbox 3-col ---- */
127
+ .mb-wrap{display:flex;height:calc(100vh - 0px)}
128
+ .mb-col1{width:240px;border-right:1px solid var(--border);background:var(--surface);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
129
+ .mb-col2{width:300px;border-right:1px solid var(--border);background:var(--surface);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
130
+ .mb-col3{flex:1;background:var(--bg);display:flex;flex-direction:column;min-width:0;overflow:hidden}
131
+ .mb-hdr{padding:.75rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:.5rem;flex-shrink:0}
132
+ .mb-list{flex:1;overflow-y:auto}
133
+ .mb-item{padding:.7rem .75rem;border-bottom:1px solid var(--border);cursor:pointer;transition:background .1s;font-size:.82rem}
134
+ .mb-item:hover{background:var(--brand-bg)}
135
+ .mb-item.on{background:var(--brand-bg)}
136
+ .mb-empty{text-align:center;padding:2rem;color:var(--text3);font-size:.84rem}
137
+
138
+ /* ---- Docs ---- */
139
+ .doc-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);margin-bottom:.6rem;overflow:hidden;box-shadow:var(--shadow)}
140
+ .doc-head{padding:.7rem 1rem;cursor:pointer;display:flex;align-items:center;gap:.7rem;transition:background .1s}
141
+ .doc-head:hover{background:var(--bg)}
142
+ .doc-body{border-top:1px solid var(--border);padding:1rem;display:none}
143
+ .doc-desc{color:var(--text2);font-size:.82rem;margin-bottom:.6rem}
144
+ .doc-tabs{display:flex;gap:.3rem;margin-bottom:.5rem}
145
+ .doc-tab{padding:.25rem .6rem;border-radius:4px;font-size:.78rem;font-weight:500;color:var(--text3);cursor:pointer;transition:all .1s}
146
+ .doc-tab.on{background:var(--bg);color:var(--text)}
147
+ .doc-pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.7rem;font-size:.76rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-family:monospace}
148
 
149
  /* ---- Misc ---- */
150
+ .loading{text-align:center;padding:2rem;color:var(--text3)}
151
+ .empty{text-align:center;padding:2rem;color:var(--text3);font-size:.9rem}
152
+ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:.7rem;margin-top:.6rem;font-size:.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-family:monospace}
153
  </style>
154
  </head>
155
  <body>
156
 
157
+ <div id="toast-box"></div>
158
 
159
+ <!-- ===== LOGIN ===== -->
160
+ <div id="login">
161
+ <div class="login-box">
162
  <h1>Outlook2API</h1>
163
  <p>Sign in to the admin panel</p>
164
+ <form id="loginForm">
165
+ <label for="loginPw">Password</label>
166
+ <input id="loginPw" type="password" placeholder="Admin password" required>
167
+ <button type="submit" class="btn btn-p" id="loginBtn">Sign in</button>
168
  </form>
169
  </div>
170
  </div>
171
 
172
+ <!-- ===== APP ===== -->
173
  <div id="app">
174
+ <button class="menu-btn" id="menuBtn"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg></button>
175
+ <div class="ov" id="ov"></div>
176
+ <div class="lay">
177
+ <nav class="side" id="side">
178
+ <div class="side-brand">Outlook2API<span>Admin Panel</span></div>
179
+ <button class="nav on" data-t="dash"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>Dashboard</button>
180
+ <button class="nav" data-t="acct"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg>Accounts</button>
181
+ <button class="nav" data-t="imp"><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>Import</button>
182
+ <button class="nav" data-t="mail"><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>Mailbox</button>
183
+ <button class="nav" data-t="docs"><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"/></svg>API Docs</button>
184
+ <div class="side-foot">
185
+ <button class="nav" id="logoutBtn"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Logout</button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
  </div>
187
  </nav>
188
 
189
  <main class="main">
190
+
191
+ <!-- Tab: Dashboard -->
192
+ <section id="t-dash">
193
  <h2>Dashboard</h2>
194
+ <div class="stat-g">
195
+ <div class="stat-c"><div class="l">Total Accounts</div><div class="v" id="sTotal">--</div></div>
196
+ <div class="stat-c"><div class="l">Active</div><div class="v" id="sActive">--</div></div>
197
+ <div class="stat-c"><div class="l">Inactive</div><div class="v" id="sInactive">--</div></div>
198
+ <div class="stat-c"><div class="l">Last 7 Days</div><div class="v" id="sRecent">--</div></div>
199
  </div>
200
+ <button class="btn btn-o" id="exportBtn"><svg width="16" height="16" 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="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Export Accounts</button>
 
 
 
201
  </section>
202
 
203
+ <!-- Tab: Accounts -->
204
+ <section id="t-acct" style="display:none">
205
  <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1.2rem">
206
  <h2 style="margin-bottom:0">Accounts</h2>
207
  <div style="display:flex;gap:.5rem">
208
+ <button class="btn btn-p btn-s" id="addBtn">+ Add</button>
209
+ <button class="btn btn-d btn-s" id="delAllBtn">Delete All</button>
210
  </div>
211
  </div>
212
+ <div class="bar">
213
+ <input type="text" id="searchIn" placeholder="Search by email...">
214
+ <select id="filterSel"><option value="">All</option><option value="true">Active</option><option value="false">Inactive</option></select>
 
 
 
 
215
  </div>
216
+ <div class="tbl-w">
217
+ <table><thead><tr><th>Email</th><th>Status</th><th>Source</th><th>Created</th><th>Actions</th></tr></thead>
218
+ <tbody id="acctBody"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody></table>
 
 
219
  </div>
220
+ <div class="pag">
221
+ <button class="btn btn-o btn-s" id="prevBtn" disabled>Prev</button>
222
+ <span id="pageInfo">Page 1</span>
223
+ <button class="btn btn-o btn-s" id="nextBtn" disabled>Next</button>
224
  </div>
225
  </section>
226
 
227
+ <!-- Tab: Import -->
228
+ <section id="t-imp" style="display:none">
229
  <h2>Import Accounts</h2>
230
+ <div class="imp">
231
  <h3>Text Import</h3>
232
  <p>Enter accounts, one per line in <code>email:password</code> format.</p>
233
+ <textarea id="bulkText" placeholder="user1@outlook.com:password1&#10;user2@outlook.com:password2"></textarea>
234
+ <button class="btn btn-p" id="bulkBtn">Import Accounts</button>
235
  </div>
236
+ <div class="imp">
237
  <h3>File Upload</h3>
238
  <p>Upload a <code>.txt</code> file with one <code>email:password</code> per line.</p>
239
+ <input type="file" id="fileIn" accept=".txt" style="margin-bottom:.6rem">
240
+ <div><button class="btn btn-p" id="fileBtn">Upload File</button></div>
241
  </div>
242
+ <div class="imp">
243
  <h3>CI Import</h3>
244
+ <p>Use this endpoint in GitHub Actions to auto-import:</p>
 
 
 
 
 
 
 
 
 
245
  <pre>curl -X POST https://your-domain/admin/api/accounts/bulk \
246
  -H "Authorization: Bearer ADMIN_PASSWORD" \
247
  -H "Content-Type: application/json" \
248
+ -d '{"accounts":["user@outlook.com:pass"],"source":"ci"}'</pre>
249
  </div>
250
  </section>
251
 
252
+ <!-- Tab: Mailbox (three-column) -->
253
+ <section id="t-mail" style="display:none">
254
+ <div class="mb-wrap">
255
+ <!-- Col 1: Accounts -->
256
+ <div class="mb-col1">
257
+ <div class="mb-hdr">
258
+ <strong style="font-size:.85rem">Accounts</strong>
259
+ <button class="btn btn-p btn-s" id="composeBtn">Compose</button>
260
+ </div>
261
+ <div style="padding:.5rem;border-bottom:1px solid var(--border)">
262
+ <input type="text" id="mbSearch" placeholder="Search..." style="width:100%;padding:.4rem .6rem;font-size:.8rem">
263
+ </div>
264
+ <div class="mb-list" id="mbAcctList"><div class="mb-empty">Loading...</div></div>
 
 
 
 
 
 
 
265
  </div>
266
+ <!-- Col 2: Messages -->
267
+ <div class="mb-col2">
268
+ <div class="mb-hdr">
269
+ <strong id="mbInboxTitle" style="font-size:.85rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">Inbox</strong>
270
+ <button class="btn btn-o btn-s" id="mbRefresh" title="Refresh"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 11-2.12-9.36L23 10"/></svg></button>
271
+ </div>
272
+ <div class="mb-list" id="mbMsgList"><div class="mb-empty">Select an account</div></div>
273
  </div>
274
+ <!-- Col 3: Detail/Compose -->
275
+ <div class="mb-col3">
276
+ <!-- Empty state -->
277
+ <div id="mbEmpty" style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--text3)">Select a message to read</div>
278
+ <!-- Detail view -->
279
+ <div id="mbDetail" style="display:none;flex:1;display:flex;flex-direction:column">
280
+ <div style="padding:1rem;border-bottom:1px solid var(--border);background:var(--surface)">
281
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem">
282
+ <h3 id="mbSubj" style="font-size:1rem;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:1rem">Subject</h3>
283
+ <button class="btn btn-o btn-s" id="mbReplyBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 00-4-4H4"/></svg> Reply</button>
284
+ </div>
285
+ <div style="font-size:.82rem;color:var(--text2)">
286
+ <span id="mbFrom"></span>
287
+ <span id="mbCode" style="margin-left:.8rem"></span>
288
+ </div>
289
+ </div>
290
+ <div style="flex:1;padding:1rem;overflow:auto">
291
+ <iframe id="mbBody" style="width:100%;height:100%;min-height:400px;border:1px solid var(--border);border-radius:var(--r);background:var(--surface)" sandbox="allow-same-origin"></iframe>
292
+ </div>
293
+ </div>
294
+ <!-- Compose view -->
295
+ <div id="mbCompose" style="display:none;flex:1;display:flex;flex-direction:column">
296
+ <div style="padding:1rem;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;justify-content:space-between">
297
+ <h3 style="font-size:1rem;margin:0">Compose Email</h3>
298
+ <button class="btn btn-o btn-s" id="composeCancel">Cancel</button>
299
+ </div>
300
+ <div style="flex:1;padding:1rem;overflow:auto">
301
+ <div style="max-width:600px">
302
+ <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">From</label>
303
+ <select id="cFrom" style="width:100%;margin-bottom:.8rem"></select>
304
+ <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">To</label>
305
+ <input id="cTo" type="email" placeholder="recipient@example.com" style="width:100%;margin-bottom:.8rem">
306
+ <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">CC</label>
307
+ <input id="cCc" placeholder="cc@example.com (comma separated)" style="width:100%;margin-bottom:.8rem">
308
+ <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">Subject</label>
309
+ <input id="cSubj" placeholder="Subject" style="width:100%;margin-bottom:.8rem">
310
+ <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">Body</label>
311
+ <textarea id="cBody" rows="10" placeholder="Write your message..." style="width:100%;margin-bottom:1rem"></textarea>
312
+ <input type="hidden" id="cReplyTo"><input type="hidden" id="cRefs">
313
+ <div style="text-align:right">
314
+ <button class="btn btn-p" id="sendBtn"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> Send Email</button>
315
+ </div>
316
+ </div>
317
+ </div>
318
+ </div>
319
  </div>
320
  </div>
321
  </section>
322
+
323
+ <!-- Tab: API Docs -->
324
+ <section id="t-docs" style="display:none">
325
  <h2>API Documentation</h2>
326
+ <div id="docsMailBox"><h3 style="font-size:1rem;margin-bottom:.8rem;padding-bottom:.4rem;border-bottom:1px solid var(--border)">Mail API</h3></div>
327
+ <div id="docsAdminBox" style="margin-top:1.5rem"><h3 style="font-size:1rem;margin-bottom:.8rem;padding-bottom:.4rem;border-bottom:1px solid var(--border)">Admin API</h3><p style="color:var(--text2);font-size:.82rem;margin-bottom:.8rem">All endpoints require Bearer token or admin_token cookie.</p></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  </section>
329
+
330
  </main>
331
  </div>
332
  </div>
333
 
334
+ <!-- ===== MODALS (BEFORE script!) ===== -->
335
+ <div class="modal-bg" id="addModal">
336
+ <div class="modal">
337
+ <h3>Add Account</h3>
338
+ <label>Email</label><input id="addEmail" type="email" placeholder="user@outlook.com">
339
+ <label>Password</label><input id="addPw" type="text" placeholder="Password">
340
+ <div class="modal-foot">
341
+ <button class="btn btn-o" id="addCancel">Cancel</button>
342
+ <button class="btn btn-p" id="addOk">Add Account</button>
343
+ </div>
344
+ </div>
345
+ </div>
346
+
347
+ <div class="modal-bg" id="pwModal">
348
+ <div class="modal">
349
+ <h3>Account Password</h3>
350
+ <p id="pwText" style="font-family:monospace;background:var(--bg);padding:.7rem;border-radius:6px;margin-top:.5rem;word-break:break-all">Loading...</p>
351
+ <div class="modal-foot"><button class="btn btn-o" id="pwClose">Close</button></div>
352
+ </div>
353
+ </div>
354
+
355
+ <!-- ===== SCRIPT ===== -->
356
  <script>
357
  (function(){
358
  'use strict';
359
 
360
+ /* ========== HELPERS ========== */
361
+ var $=function(id){return document.getElementById(id)};
362
+ function getCk(n){var m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null}
363
+ function setCk(n,v,d){var s=n+'='+encodeURIComponent(v)+';path=/;SameSite=Lax';if(d)s+=';max-age='+d*86400;document.cookie=s}
364
+ function delCk(n){document.cookie=n+'=;path=/;max-age=0'}
365
+ function tk(){return getCk('admin_token')||''}
366
+ function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
367
+ function fmtD(s){if(!s)return'--';try{return new Date(s).toLocaleDateString()}catch(e){return s}}
368
+
369
+ // Show/hide — the ONLY way to toggle visibility. No CSS classes.
370
+ function show(id,disp){var el=typeof id==='string'?$(id):id;if(el)el.style.display=disp||''}
371
+ function hide(id){var el=typeof id==='string'?$(id):id;if(el)el.style.display='none'}
372
+
373
+ function toast(msg,err){
374
+ var c=$('toast-box'),d=document.createElement('div');
375
+ d.className='toast'+(err?' toast-err':'');
376
+ d.textContent=msg;c.appendChild(d);
377
+ setTimeout(function(){d.remove()},4000);
378
  }
379
 
380
+ function api(path,opts){
381
+ opts=opts||{};
382
+ var headers=opts.headers||{};
383
+ var t=tk();
384
+ if(t)headers['Authorization']='Bearer '+t;
385
  if(opts.body&&typeof opts.body==='object'&&!(opts.body instanceof FormData)){
386
  headers['Content-Type']='application/json';
387
  opts.body=JSON.stringify(opts.body);
388
  }
389
  opts.headers=headers;
390
+ return fetch(path,opts).then(function(r){
391
+ if(!r.ok){
392
+ return r.json().catch(function(){return{}}).then(function(j){
393
+ throw new Error(j.detail||j.message||'Request failed ('+r.status+')');
394
+ });
395
+ }
396
+ var ct=r.headers.get('content-type')||'';
397
+ if(ct.indexOf('json')>=0)return r.json();
398
+ return r;
399
+ });
400
  }
401
 
402
+ /* ========== STATE ========== */
403
+ var curTab='dash',page=1,total=0,PS=20;
404
+ var mbMsgs=[],mbAccts=[],selAcctId=null,selAcctEmail='',curMsgIdx=-1;
 
 
 
 
 
405
 
406
+ /* ========== AUTH ========== */
407
  function checkAuth(){
408
+ if(tk()){hide('login');show('app');loadTab(curTab)}
409
+ else{show('login','flex');hide('app')}
 
 
 
 
 
 
410
  }
411
 
412
+ $('loginForm').onsubmit=function(e){
 
413
  e.preventDefault();
414
+ var btn=$('loginBtn');btn.disabled=true;btn.textContent='Signing in...';
415
+ api('/admin/api/login',{method:'POST',body:{password:$('loginPw').value}})
416
+ .then(function(d){setCk('admin_token',d.token||d.access_token,7);toast('Signed in');checkAuth()})
417
+ .catch(function(e){toast(e.message,1)})
418
+ .finally(function(){btn.disabled=false;btn.textContent='Sign in'});
419
+ };
 
 
 
 
420
 
421
+ $('logoutBtn').onclick=function(){delCk('admin_token');checkAuth()};
422
+
423
+ /* ========== SIDEBAR NAV ========== */
424
+ var navBtns=document.querySelectorAll('.nav[data-t]');
425
+ navBtns.forEach(function(b){
426
+ b.onclick=function(){
427
+ navBtns.forEach(function(x){x.classList.remove('on')});
428
+ b.classList.add('on');
429
+ curTab=b.getAttribute('data-t');
430
+ loadTab(curTab);
431
+ // close mobile
432
+ $('side').classList.remove('open');
433
+ $('ov').classList.remove('open');
434
+ };
435
  });
436
 
437
+ $('menuBtn').onclick=function(){$('side').classList.toggle('open');$('ov').classList.toggle('open')};
438
+ $('ov').onclick=function(){$('side').classList.remove('open');$('ov').classList.remove('open')};
439
+
440
+ /* ========== TAB LOADER ========== */
441
+ var tabs=['dash','acct','imp','mail','docs'];
442
+ function loadTab(t){
443
+ tabs.forEach(function(id){hide('t-'+id)});
444
+ show('t-'+t);
445
+ if(t==='dash')loadDash();
446
+ if(t==='acct'){page=1;loadAccts()}
447
+ if(t==='mail')loadMbAccts();
448
+ if(t==='docs')renderDocs();
449
  }
450
 
451
+ /* ========== DASHBOARD ========== */
452
+ function loadDash(){
453
+ api('/admin/api/stats').then(function(s){
454
+ $('sTotal').textContent=s.total!=null?s.total:'--';
455
+ $('sActive').textContent=s.active!=null?s.active:'--';
456
+ $('sInactive').textContent=s.inactive!=null?s.inactive:'--';
457
+ $('sRecent').textContent=s.recent_7d!=null?s.recent_7d:(s.recent!=null?s.recent:'--');
458
+ }).catch(function(e){toast(e.message,1)});
 
459
  }
460
 
461
+ $('exportBtn').onclick=function(){
462
+ var t=tk();
463
+ fetch('/admin/api/export',{headers:{'Authorization':'Bearer '+t}}).then(function(r){
464
+ if(!r.ok)throw new Error('Export failed');
465
+ return r.text();
466
+ }).then(function(text){
467
+ var blob=new Blob([text],{type:'text/plain'});
468
+ var url=URL.createObjectURL(blob);
469
+ var a=document.createElement('a');a.href=url;a.download='accounts_export.txt';a.click();
470
  URL.revokeObjectURL(url);
471
  toast('Export downloaded');
472
+ }).catch(function(e){toast(e.message,1)});
473
+ };
474
 
475
+ /* ========== ACCOUNTS ========== */
476
+ var sTimer=null;
477
+ $('searchIn').oninput=function(){clearTimeout(sTimer);sTimer=setTimeout(function(){page=1;loadAccts()},350)};
478
+ $('filterSel').onchange=function(){page=1;loadAccts()};
479
+ $('prevBtn').onclick=function(){if(page>1){page--;loadAccts()}};
480
+ $('nextBtn').onclick=function(){page++;loadAccts()};
481
+
482
+ function loadAccts(){
483
+ var tb=$('acctBody');
484
+ tb.innerHTML='<tr><td colspan="5" class="loading">Loading...</td></tr>';
485
+ var q='/admin/api/accounts?page='+page+'&limit='+PS;
486
+ var s=$('searchIn').value;if(s)q+='&search='+encodeURIComponent(s);
487
+ var f=$('filterSel').value;if(f)q+='&active='+f;
488
+ api(q).then(function(d){
489
+ var a=d.accounts||[];total=d.total||a.length;
490
+ var tp=Math.max(1,Math.ceil(total/PS));
491
+ $('pageInfo').textContent='Page '+page+' of '+tp;
492
+ $('prevBtn').disabled=page<=1;$('nextBtn').disabled=page>=tp;
493
+ if(!a.length){tb.innerHTML='<tr><td colspan="5" class="empty">No accounts found</td></tr>';return}
494
+ tb.innerHTML=a.map(function(x){
495
+ return '<tr><td>'+esc(x.email||x.address||'')+'</td>'
496
+ +'<td><span class="badge '+(x.is_active!==false?'b-ok':'b-err')+'">'+(x.is_active!==false?'Active':'Inactive')+'</span></td>'
497
+ +'<td>'+esc(x.source||'--')+'</td>'
498
+ +'<td>'+fmtD(x.created_at||x.created||'')+'</td>'
499
+ +'<td><div class="acts">'
500
+ +'<button class="btn btn-o btn-s" onclick="W._tog(\''+esc(x.id)+'\','+(!x.is_active)+')">'+(x.is_active!==false?'Deactivate':'Activate')+'</button>'
501
+ +'<button class="btn btn-o btn-s" onclick="W._pw(\''+esc(x.id)+'\')">Password</button>'
502
+ +'<button class="btn btn-o btn-s" onclick="W._mb(\''+esc(x.id)+'\',\''+esc(x.email||'')+'\')">Mailbox</button>'
503
+ +'<button class="btn btn-d btn-s" onclick="W._del(\''+esc(x.id)+'\')">Delete</button>'
504
+ +'</div></td></tr>';
505
+ }).join('');
506
+ }).catch(function(e){
507
+ tb.innerHTML='<tr><td colspan="5" class="empty">Error: '+esc(e.message)+'</td></tr>';
508
+ });
 
 
 
 
 
 
 
 
509
  }
510
 
511
+ // Global action handlers
512
+ var W=window;
513
+ W._tog=function(id,v){
514
+ api('/admin/api/accounts/'+id,{method:'PATCH',body:{is_active:v}})
515
+ .then(function(){toast('Account updated');loadAccts()}).catch(function(e){toast(e.message,1)});
 
516
  };
517
+ W._pw=function(id){
518
+ $('pwText').textContent='Loading...';
519
+ show('pwModal','flex');
520
+ api('/admin/api/accounts/'+id+'/password')
521
+ .then(function(d){$('pwText').textContent=d.password||d.pw||JSON.stringify(d)})
522
+ .catch(function(e){$('pwText').textContent='Error: '+e.message});
523
  };
524
+ W._del=function(id){
525
  if(!confirm('Delete this account?'))return;
526
+ api('/admin/api/accounts/'+id,{method:'DELETE'})
527
+ .then(function(){toast('Account deleted');loadAccts()}).catch(function(e){toast(e.message,1)});
528
  };
529
 
530
+ // Password modal close
531
+ $('pwClose').onclick=function(){hide('pwModal')};
532
+ $('pwModal').onclick=function(e){if(e.target===$('pwModal'))hide('pwModal')};
533
+
534
+ // Add account modal
535
+ $('addBtn').onclick=function(){$('addEmail').value='';$('addPw').value='';show('addModal','flex');$('addEmail').focus()};
536
+ $('addCancel').onclick=function(){hide('addModal')};
537
+ $('addModal').onclick=function(e){if(e.target===$('addModal'))hide('addModal')};
538
+ $('addOk').onclick=function(){
539
+ var em=$('addEmail').value.trim(),pw=$('addPw').value;
540
+ if(!em||!pw){toast('Fill in all fields',1);return}
541
+ var btn=$('addOk');btn.disabled=true;btn.textContent='Adding...';
542
+ api('/admin/api/accounts',{method:'POST',body:{email:em,password:pw}})
543
+ .then(function(){toast('Account added');hide('addModal');loadAccts()})
544
+ .catch(function(e){toast(e.message,1)})
545
+ .finally(function(){btn.disabled=false;btn.textContent='Add Account'});
546
+ };
 
547
 
548
+ // Delete all
549
+ $('delAllBtn').onclick=function(){
550
+ if(!confirm('Delete ALL accounts? Cannot be undone.'))return;
551
  if(!confirm('Are you really sure?'))return;
552
+ api('/admin/api/accounts',{method:'DELETE'})
553
+ .then(function(){toast('All accounts deleted');loadAccts()}).catch(function(e){toast(e.message,1)});
554
+ };
555
 
556
+ /* ========== IMPORT ========== */
557
+ $('bulkBtn').onclick=function(){
558
+ var lines=$('bulkText').value.trim().split('\n').map(function(l){return l.trim()}).filter(Boolean);
559
+ if(!lines.length){toast('Enter at least one account',1);return}
560
+ var btn=$('bulkBtn');btn.disabled=true;btn.textContent='Importing...';
561
+ api('/admin/api/accounts/bulk',{method:'POST',body:{accounts:lines,source:'manual'}})
562
+ .then(function(d){toast('Imported '+(d.imported||0)+' accounts'+(d.skipped?' ('+d.skipped+' skipped)':''));$('bulkText').value=''})
563
+ .catch(function(e){toast(e.message,1)})
564
+ .finally(function(){btn.disabled=false;btn.textContent='Import Accounts'});
565
+ };
 
 
566
 
567
+ $('fileBtn').onclick=function(){
568
+ var f=$('fileIn').files[0];
569
+ if(!f){toast('Select a file first',1);return}
570
+ var btn=$('fileBtn');btn.disabled=true;btn.textContent='Uploading...';
571
+ var fd=new FormData();fd.append('file',f);
572
+ api('/admin/api/accounts/upload',{method:'POST',body:fd})
573
+ .then(function(d){toast('Uploaded '+(d.imported||0)+' accounts'+(d.skipped?' ('+d.skipped+' skipped)':''));$('fileIn').value=''})
574
+ .catch(function(e){toast(e.message,1)})
575
+ .finally(function(){btn.disabled=false;btn.textContent='Upload File'});
576
+ };
 
 
 
577
 
578
+ /* ========== KEYBOARD ========== */
579
+ document.onkeydown=function(e){
580
+ if(e.key==='Escape'){hide('addModal');hide('pwModal')}
581
+ };
582
+
583
+ /* ========== MAILBOX ========== */
584
+ var mbSTimer=null;
585
+ $('mbSearch').oninput=function(){
586
+ clearTimeout(mbSTimer);
587
+ mbSTimer=setTimeout(filterMbAccts,250);
588
+ };
589
+
590
+ function filterMbAccts(){
591
+ var q=$('mbSearch').value.toLowerCase();
592
+ var items=$('mbAcctList').querySelectorAll('.mb-item');
593
+ items.forEach(function(el){el.style.display=el.getAttribute('data-em').indexOf(q)>=0?'':'none'});
594
+ }
595
 
596
+ function loadMbAccts(){
597
+ var list=$('mbAcctList');
598
+ list.innerHTML='<div class="mb-empty">Loading...</div>';
599
+ api('/admin/api/accounts?limit=500').then(function(d){
600
+ mbAccts=d.accounts||[];
601
+ if(!mbAccts.length){list.innerHTML='<div class="mb-empty">No accounts</div>';return}
602
+ list.innerHTML=mbAccts.map(function(a){
603
+ return '<div class="mb-item'+(a.id===selAcctId?' on':'')+'" data-id="'+esc(a.id)+'" data-em="'+esc((a.email||'').toLowerCase())+'">'
604
+ +'<div style="font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(a.email||'')+'</div>'
605
+ +'<div style="color:var(--text3);font-size:.75rem;margin-top:.15rem">'+(a.is_active!==false?'Active':'Inactive')+' &middot; '+esc(a.source||'')+'</div>'
606
+ +'</div>';
607
+ }).join('');
608
+ // Click handlers
609
+ list.querySelectorAll('.mb-item').forEach(function(el){
610
+ el.onclick=function(){selectMbAcct(el.getAttribute('data-id'),el.getAttribute('data-em'))};
611
  });
612
+ populateCFrom();
613
+ }).catch(function(e){list.innerHTML='<div class="mb-empty">Error loading</div>'});
614
  }
615
 
616
+ function populateCFrom(){
617
+ var s=$('cFrom');
618
+ s.innerHTML=mbAccts.map(function(a){return '<option value="'+esc(a.id)+'" data-em="'+esc(a.email||'')+'">'+esc(a.email||'')+'</option>'}).join('');
619
+ if(selAcctId)s.value=selAcctId;
620
+ }
621
+
622
+ function selectMbAcct(id,email){
623
+ selAcctId=id;selAcctEmail=email||'';
624
+ // highlight
625
+ $('mbAcctList').querySelectorAll('.mb-item').forEach(function(el){
626
+ el.classList.toggle('on',el.getAttribute('data-id')===id);
627
+ });
628
+ $('mbInboxTitle').textContent=selAcctEmail||'Inbox';
629
+ loadMbMsgs();
630
+ }
631
+
632
+ function loadMbMsgs(){
633
+ if(!selAcctId)return;
634
+ var list=$('mbMsgList');
635
+ list.innerHTML='<div class="mb-empty">Loading via IMAP...</div>';
636
+ showMbView('empty');
637
+ api('/admin/api/accounts/'+selAcctId+'/messages').then(function(d){
638
+ mbMsgs=d.messages||[];
639
+ if(!mbMsgs.length){list.innerHTML='<div class="mb-empty">No messages</div>';return}
640
+ list.innerHTML=mbMsgs.map(function(m,i){
641
+ var from=(m.from&&m.from.address)||m.from||'';
642
+ return '<div class="mb-item" data-i="'+i+'">'
643
+ +'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.2rem">'
644
+ +'<span style="font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:.5rem">'+esc(from)+'</span>'
645
+ +(m.verification_code?'<span class="badge b-ok" style="flex-shrink:0;font-size:.68rem">'+esc(m.verification_code)+'</span>':'')
646
+ +'</div>'
647
+ +'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc(m.subject||'(no subject)')+'</div>'
648
+ +'<div style="color:var(--text3);font-size:.75rem;margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc((m.intro||'').substring(0,80))+'</div>'
649
+ +'</div>';
650
+ }).join('');
651
+ list.querySelectorAll('.mb-item').forEach(function(el){
652
+ el.onclick=function(){showMbMsg(parseInt(el.getAttribute('data-i')))};
653
+ });
654
+ }).catch(function(e){
655
+ list.innerHTML='<div class="mb-empty" style="color:var(--err)">Error: '+esc(e.message)+'</div>';
656
+ });
657
+ }
658
+
659
+ function showMbView(v){
660
+ hide('mbEmpty');hide('mbDetail');hide('mbCompose');
661
+ if(v==='empty')show('mbEmpty','flex');
662
+ else if(v==='detail'){show('mbDetail');$('mbDetail').style.display='flex';$('mbDetail').style.flexDirection='column'}
663
+ else if(v==='compose'){show('mbCompose');$('mbCompose').style.display='flex';$('mbCompose').style.flexDirection='column'}
664
+ }
665
+
666
+ function showMbMsg(idx){
667
+ var m=mbMsgs[idx];if(!m)return;
668
+ curMsgIdx=idx;
669
+ // highlight
670
+ $('mbMsgList').querySelectorAll('.mb-item').forEach(function(el){
671
+ el.classList.toggle('on',parseInt(el.getAttribute('data-i'))===idx);
672
+ });
673
+ $('mbSubj').textContent=m.subject||'(no subject)';
674
+ $('mbFrom').textContent='From: '+((m.from&&m.from.address)||m.from||'');
675
+ if(m.verification_code){
676
+ $('mbCode').innerHTML='<span class="badge b-ok">Code: '+esc(m.verification_code)+'</span>';
677
+ }else{$('mbCode').innerHTML=''}
678
+ $('mbBody').srcdoc=(m.html&&m.html[0])||m.text||'(empty)';
679
+ showMbView('detail');
680
  }
681
 
682
+ $('mbRefresh').onclick=function(){
683
+ if(selAcctId)loadMbMsgs();else toast('Select an account first',1);
 
 
 
 
 
 
 
 
 
684
  };
685
 
686
+ // Reply
687
+ $('mbReplyBtn').onclick=function(){
688
+ if(curMsgIdx<0)return;var m=mbMsgs[curMsgIdx];if(!m)return;
689
+ showMbView('compose');
690
+ if(selAcctId)$('cFrom').value=selAcctId;
691
+ $('cTo').value=(m.from&&m.from.address)||m.from||'';
692
+ $('cCc').value='';
693
+ $('cSubj').value=(m.subject||'').indexOf('Re: ')===0?m.subject:'Re: '+(m.subject||'');
694
+ $('cBody').value='\n\n--- Original Message ---\n'+(m.text||'');
695
+ $('cReplyTo').value=m.id||'';$('cRefs').value=m.id||'';
696
+ };
697
+
698
+ // Compose
699
+ $('composeBtn').onclick=function(){
700
+ showMbView('compose');
701
+ if(selAcctId)$('cFrom').value=selAcctId;
702
+ $('cTo').value='';$('cCc').value='';$('cSubj').value='';$('cBody').value='';
703
+ $('cReplyTo').value='';$('cRefs').value='';
704
+ };
705
+
706
+ $('composeCancel').onclick=function(){
707
+ if(curMsgIdx>=0)showMbView('detail');else showMbView('empty');
708
+ };
709
+
710
+ // Send
711
+ $('sendBtn').onclick=function(){
712
+ var fromId=$('cFrom').value,to=$('cTo').value.trim(),subj=$('cSubj').value.trim(),body=$('cBody').value;
713
+ if(!fromId){toast('Select a sender',1);return}
714
+ if(!to){toast('Enter a recipient',1);return}
715
+ if(!subj){toast('Enter a subject',1);return}
716
+ var btn=$('sendBtn');btn.disabled=true;btn.textContent='Sending...';
717
+ api('/admin/api/accounts/'+fromId+'/send',{method:'POST',body:{
718
+ to:to,subject:subj,body_text:body,body_html:'',cc:$('cCc').value.trim(),
719
+ in_reply_to:$('cReplyTo').value,references:$('cRefs').value
720
+ }}).then(function(){toast('Email sent!');showMbView('empty')})
721
+ .catch(function(e){toast(e.message,1)})
722
+ .finally(function(){btn.disabled=false;btn.textContent='Send Email'});
723
+ };
724
 
725
+ // Open mailbox from accounts tab
726
+ W._mb=function(id,email){
727
+ curTab='mail';
728
+ navBtns.forEach(function(b){b.classList.remove('on');if(b.getAttribute('data-t')==='mail')b.classList.add('on')});
729
+ loadTab('mail');
730
+ setTimeout(function(){selectMbAcct(id,email)},300);
 
 
731
  };
732
 
733
+ /* ========== API DOCS ========== */
734
+ var DOCS={
735
+ mail:[
736
+ {m:'GET',p:'/domains',d:'List supported domains',
737
+ curl:'curl https://your-domain/domains',
738
+ py:'import requests\nr = requests.get("https://your-domain/domains")\nprint(r.json())',
739
+ js:'const res = await fetch("/domains");\nconst data = await res.json();'},
740
+ {m:'POST',p:'/accounts',d:'Register account (IMAP validation)',
741
+ curl:'curl -X POST https://your-domain/accounts \\\n -H "Content-Type: application/json" \\\n -d \'{"address":"user@outlook.com","password":"pass"}\'',
742
+ py:'r = requests.post("/accounts",\n json={"address":"user@outlook.com","password":"pass"})',
743
+ js:'const res = await fetch("/accounts", {\n method: "POST",\n headers: {"Content-Type": "application/json"},\n body: JSON.stringify({address:"user@outlook.com",password:"pass"})\n});'},
744
+ {m:'POST',p:'/token',d:'Get JWT token',
745
+ curl:'curl -X POST https://your-domain/token \\\n -H "Content-Type: application/json" \\\n -d \'{"address":"user@outlook.com","password":"pass"}\'',
746
+ py:'r = requests.post("/token",\n json={"address":"user@outlook.com","password":"pass"})\ntoken = r.json()["token"]',
747
+ js:'const res = await fetch("/token", {\n method: "POST",\n headers: {"Content-Type": "application/json"},\n body: JSON.stringify({address:"user@outlook.com",password:"pass"})\n});'},
748
+ {m:'GET',p:'/me',d:'Current user info (Bearer)',
749
+ curl:'curl /me -H "Authorization: Bearer TOKEN"',
750
+ py:'r = requests.get("/me", headers={"Authorization":"Bearer TOKEN"})',
751
+ js:'const res = await fetch("/me", {headers:{"Authorization":"Bearer TOKEN"}});'},
752
+ {m:'GET',p:'/messages',d:'List inbox messages (Bearer)',
753
+ curl:'curl "/messages?page=1" -H "Authorization: Bearer TOKEN"',
754
+ py:'r = requests.get("/messages", params={"page":1},\n headers={"Authorization":"Bearer TOKEN"})',
755
+ js:'const res = await fetch("/messages?page=1", {headers:{"Authorization":"Bearer TOKEN"}});'},
756
+ {m:'GET',p:'/messages/{id}',d:'Get specific message (Bearer)',
757
+ curl:'curl /messages/123 -H "Authorization: Bearer TOKEN"',
758
+ py:'r = requests.get("/messages/123", headers={"Authorization":"Bearer TOKEN"})',
759
+ js:'const res = await fetch("/messages/123", {headers:{"Authorization":"Bearer TOKEN"}});'},
760
+ {m:'GET',p:'/messages/{id}/code',d:'Extract verification code',
761
+ curl:'curl /messages/123/code -H "Authorization: Bearer TOKEN"',
762
+ py:'r = requests.get("/messages/123/code",\n headers={"Authorization":"Bearer TOKEN"})\ncode = r.json()["verification_code"]',
763
+ js:'const {verification_code} = await (await fetch("/messages/123/code",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
764
+ {m:'DELETE',p:'/accounts/me',d:'Delete account (Bearer)',
765
+ curl:'curl -X DELETE /accounts/me -H "Authorization: Bearer TOKEN"',
766
+ py:'r = requests.delete("/accounts/me", headers={"Authorization":"Bearer TOKEN"})',
767
+ js:'await fetch("/accounts/me", {method:"DELETE", headers:{"Authorization":"Bearer TOKEN"}});'}
768
+ ],
769
+ admin:[
770
+ {m:'POST',p:'/admin/api/login',d:'Authenticate',
771
+ curl:'curl -X POST /admin/api/login \\\n -H "Content-Type: application/json" -d \'{"password":"pw"}\'',
772
+ py:'r = requests.post("/admin/api/login", json={"password":"pw"})\ntoken = r.json()["token"]',
773
+ js:'const {token} = await (await fetch("/admin/api/login",{method:"POST",\n headers:{"Content-Type":"application/json"},body:JSON.stringify({password:"pw"})})).json();'},
774
+ {m:'GET',p:'/admin/api/stats',d:'Account statistics',
775
+ curl:'curl /admin/api/stats -H "Authorization: Bearer TOKEN"',py:'r = requests.get("/admin/api/stats", headers={"Authorization":"Bearer TOKEN"})',
776
+ js:'const stats = await (await fetch("/admin/api/stats", {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
777
+ {m:'GET',p:'/admin/api/accounts',d:'List accounts (?page&search&active)',
778
+ curl:'curl "/admin/api/accounts?page=1" -H "Authorization: Bearer TOKEN"',
779
+ py:'r = requests.get("/admin/api/accounts", params={"page":1},\n headers={"Authorization":"Bearer TOKEN"})',
780
+ js:'const data = await (await fetch("/admin/api/accounts?page=1",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
781
+ {m:'POST',p:'/admin/api/accounts',d:'Add single account',
782
+ curl:'curl -X POST /admin/api/accounts \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"email":"user@outlook.com","password":"pass"}\'',
783
+ py:'r = requests.post("/admin/api/accounts",\n json={"email":"user@outlook.com","password":"pass"},\n headers={"Authorization":"Bearer TOKEN"})',
784
+ js:'await fetch("/admin/api/accounts",{method:"POST",\n headers:{"Content-Type":"application/json","Authorization":"Bearer TOKEN"},\n body:JSON.stringify({email:"user@outlook.com",password:"pass"})});'},
785
+ {m:'POST',p:'/admin/api/accounts/bulk',d:'Bulk import',
786
+ curl:'curl -X POST /admin/api/accounts/bulk \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"accounts":["user@outlook.com:pass"]}\'',
787
+ py:'r = requests.post("/admin/api/accounts/bulk",\n json={"accounts":["user@outlook.com:pass"]},\n headers={"Authorization":"Bearer TOKEN"})',
788
+ js:'await fetch("/admin/api/accounts/bulk",{method:"POST",\n headers:{"Content-Type":"application/json","Authorization":"Bearer TOKEN"},\n body:JSON.stringify({accounts:["user@outlook.com:pass"]})});'},
789
+ {m:'PATCH',p:'/admin/api/accounts/{id}',d:'Toggle active/update',
790
+ curl:'curl -X PATCH /admin/api/accounts/123 \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" -d \'{"is_active":true}\'',
791
+ py:'r = requests.patch("/admin/api/accounts/123",\n json={"is_active":True}, headers={"Authorization":"Bearer TOKEN"})',
792
+ js:'await fetch("/admin/api/accounts/123",{method:"PATCH",\n headers:{"Content-Type":"application/json","Authorization":"Bearer TOKEN"},\n body:JSON.stringify({is_active:true})});'},
793
+ {m:'DELETE',p:'/admin/api/accounts/{id}',d:'Delete account',
794
+ curl:'curl -X DELETE /admin/api/accounts/123 -H "Authorization: Bearer TOKEN"',
795
+ py:'r = requests.delete("/admin/api/accounts/123", headers={"Authorization":"Bearer TOKEN"})',
796
+ js:'await fetch("/admin/api/accounts/123",{method:"DELETE",headers:{"Authorization":"Bearer TOKEN"}});'},
797
+ {m:'GET',p:'/admin/api/accounts/{id}/password',d:'Reveal password',
798
+ curl:'curl /admin/api/accounts/123/password -H "Authorization: Bearer TOKEN"',
799
+ py:'r = requests.get("/admin/api/accounts/123/password",\n headers={"Authorization":"Bearer TOKEN"})\npw = r.json()["password"]',
800
+ js:'const {password} = await (await fetch("/admin/api/accounts/123/password",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
801
+ {m:'GET',p:'/admin/api/accounts/{id}/messages',d:'Fetch mailbox via IMAP',
802
+ curl:'curl /admin/api/accounts/123/messages -H "Authorization: Bearer TOKEN"',
803
+ py:'r = requests.get("/admin/api/accounts/123/messages",\n headers={"Authorization":"Bearer TOKEN"})',
804
+ js:'const {messages} = await (await fetch("/admin/api/accounts/123/messages",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
805
+ {m:'POST',p:'/admin/api/accounts/{id}/send',d:'Send email via SMTP',
806
+ curl:'curl -X POST /admin/api/accounts/123/send \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"to":"r@example.com","subject":"Hi","body_text":"Hello!"}\'',
807
+ py:'r = requests.post("/admin/api/accounts/123/send",\n json={"to":"r@example.com","subject":"Hi","body_text":"Hello!"},\n headers={"Authorization":"Bearer TOKEN"})',
808
+ js:'await fetch("/admin/api/accounts/123/send",{method:"POST",\n headers:{"Content-Type":"application/json","Authorization":"Bearer TOKEN"},\n body:JSON.stringify({to:"r@example.com",subject:"Hi",body_text:"Hello!"})});'},
809
+ {m:'GET',p:'/admin/api/export',d:'Export accounts as .txt',
810
+ curl:'curl /admin/api/export -H "Authorization: Bearer TOKEN" -o accounts.txt',
811
+ py:'r = requests.get("/admin/api/export", headers={"Authorization":"Bearer TOKEN"})\nwith open("accounts.txt","w") as f: f.write(r.text)',
812
+ js:'const text = await (await fetch("/admin/api/export",{headers:{"Authorization":"Bearer TOKEN"}})).text();'}
813
+ ]};
814
+
815
+ var MC={'GET':'m-get','POST':'m-post','PATCH':'m-patch','DELETE':'m-del'};
816
+ var docsOk=false;
817
+
818
+ function renderDocs(){
819
+ if(docsOk)return;docsOk=true;
820
+ renderDocGrp('docsMailBox',DOCS.mail,'dM');
821
+ renderDocGrp('docsAdminBox',DOCS.admin,'dA');
822
+ }
823
+
824
+ function renderDocGrp(cid,eps,pfx){
825
+ var c=$(cid);
826
+ eps.forEach(function(ep,i){
827
+ var id=pfx+i;
828
+ var card=document.createElement('div');card.className='doc-card';
829
+ card.innerHTML='<div class="doc-head" onclick="W._docTog(\''+id+'\')">'
830
+ +'<span class="meth '+(MC[ep.m]||'')+'">'+ep.m+'</span>'
831
+ +'<span style="font-family:monospace;font-size:.86rem">'+esc(ep.p)+'</span>'
832
+ +'<span style="margin-left:auto;color:var(--text3);font-size:.78rem">'+esc(ep.d)+'</span>'
833
+ +'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex-shrink:0;transition:transform .2s" id="'+id+'A"><polyline points="6 9 12 15 18 9"/></svg>'
834
+ +'</div>'
835
+ +'<div class="doc-body" id="'+id+'B">'
836
+ +'<div class="doc-tabs">'
837
+ +'<span class="doc-tab on" onclick="W._docLang(\''+id+'\',\'curl\',this)">curl</span>'
838
+ +'<span class="doc-tab" onclick="W._docLang(\''+id+'\',\'py\',this)">Python</span>'
839
+ +'<span class="doc-tab" onclick="W._docLang(\''+id+'\',\'js\',this)">JavaScript</span>'
840
+ +'<span style="margin-left:auto;font-size:.76rem;color:var(--text3);cursor:pointer" onclick="W._docCp(\''+id+'\')">Copy</span>'
841
+ +'</div>'
842
+ +'<pre class="doc-pre" id="'+id+'C">'+esc(ep.curl)+'</pre>'
843
+ +'<span style="display:none" id="'+id+'_curl">'+esc(ep.curl)+'</span>'
844
+ +'<span style="display:none" id="'+id+'_py">'+esc(ep.py)+'</span>'
845
+ +'<span style="display:none" id="'+id+'_js">'+esc(ep.js)+'</span>'
846
+ +'</div>';
847
+ c.appendChild(card);
848
+ });
849
+ }
850
+
851
+ W._docTog=function(id){
852
+ var b=$(id+'B'),a=$(id+'A');
853
+ if(b.style.display==='block'){b.style.display='none';a.style.transform=''}
854
+ else{b.style.display='block';a.style.transform='rotate(180deg)'}
855
+ };
856
+ W._docLang=function(id,lang,el){
857
+ var src=$(id+'_'+lang),code=$(id+'C');
858
+ if(src&&code)code.textContent=src.textContent;
859
+ el.parentNode.querySelectorAll('.doc-tab').forEach(function(t){t.classList.remove('on')});
860
+ el.classList.add('on');
861
+ };
862
+ W._docCp=function(id){
863
+ var code=$(id+'C');
864
+ navigator.clipboard.writeText(code.textContent).then(function(){toast('Copied!')});
865
+ };
866
+
867
+ /* ========== INIT ========== */
868
  checkAuth();
869
+
870
  })();
871
  </script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
872
  </body>
873
  </html>
outlook2api/static/index.html CHANGED
@@ -4,61 +4,169 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Outlook2API</title>
 
 
7
  <style>
8
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
9
- :root{--bg:#faf9f7;--surface:#fff;--border:#e8e5e0;--text:#1a1a1a;--text-secondary:#6b6560;--accent:#c96442;--accent-hover:#b5573a;--green:#2d8a4e;--red:#d03e3e;--radius:8px;--shadow:0 1px 3px rgba(0,0,0,.06)}
10
- body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);line-height:1.5;min-height:100vh}
11
- a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
12
- .container{max-width:720px;margin:0 auto;padding:48px 24px}
13
- .hero{text-align:center;margin-bottom:48px}
14
- .hero h1{font-size:28px;font-weight:600;margin-bottom:8px;letter-spacing:-.02em}
15
- .hero p{color:var(--text-secondary);font-size:15px}
16
- .cards{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
17
- .card{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:20px;transition:box-shadow .15s}
18
- .card:hover{box-shadow:var(--shadow)}
19
- .card h3{font-size:14px;font-weight:500;color:var(--text-secondary);margin-bottom:4px}
20
- .card p{font-size:22px;font-weight:600}
21
- .links{display:flex;flex-direction:column;gap:12px}
22
- .link-card{display:flex;align-items:center;justify-content:space-between;background:var(--surface);border:1px solid var(--border);border-radius:var(--radius);padding:16px 20px;transition:box-shadow .15s}
23
- .link-card:hover{box-shadow:var(--shadow);text-decoration:none}
24
- .link-card .label{font-size:15px;font-weight:500;color:var(--text)}
25
- .link-card .desc{font-size:13px;color:var(--text-secondary)}
26
- .link-card .arrow{color:var(--text-secondary);font-size:18px}
27
- .footer{margin-top:48px;text-align:center;color:var(--text-secondary);font-size:13px}
28
- @media(max-width:480px){.cards{grid-template-columns:1fr}.container{padding:32px 16px}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  </style>
30
  </head>
31
  <body>
32
- <div class="container">
33
  <div class="hero">
 
 
 
 
34
  <h1>Outlook2API</h1>
35
- <p>Mail.tm-compatible REST API for Outlook accounts</p>
36
  </div>
37
- <div class="cards" id="stats">
38
- <div class="card"><h3>Total Accounts</h3><p id="stat-total">-</p></div>
39
- <div class="card"><h3>Active</h3><p id="stat-active">-</p></div>
 
 
 
 
 
 
 
40
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  <div class="links">
42
- <a href="/admin" class="link-card">
43
- <div><div class="label">Admin Panel</div><div class="desc">Manage accounts, import, export</div></div>
44
- <span class="arrow">&rarr;</span>
45
- </a>
46
- <a href="/docs" class="link-card">
47
- <div><div class="label">API Documentation</div><div class="desc">Interactive Swagger UI</div></div>
48
- <span class="arrow">&rarr;</span>
49
  </a>
50
- <a href="/redoc" class="link-card">
51
- <div><div class="label">ReDoc</div><div class="desc">Alternative API reference</div></div>
52
- <span class="arrow">&rarr;</span>
 
53
  </a>
54
  </div>
55
- <div class="footer">Outlook2API &middot; <a href="https://github.com/shenhao-stu/outlook2api">GitHub</a></div>
 
 
 
 
 
 
 
 
 
 
 
56
  </div>
57
  <script>
58
- fetch('/admin/api/stats').then(r=>{if(r.ok)return r.json();throw r}).then(d=>{
59
- document.getElementById('stat-total').textContent=d.total;
60
- document.getElementById('stat-active').textContent=d.active;
61
- }).catch(()=>{});
 
 
 
62
  </script>
63
  </body>
64
  </html>
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
  <title>Outlook2API</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
9
  <style>
10
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
+ body{font-family:'DM Sans',system-ui,-apple-system,sans-serif;min-height:100vh;color:#1a1a1a;line-height:1.6;
12
+ background:linear-gradient(135deg,#fef7f0 0%,#f8f7f4 40%,#f0f4f8 100%)}
13
+ a{text-decoration:none;transition:color .15s}
14
+
15
+ .page{max-width:820px;margin:0 auto;padding:64px 24px 48px}
16
+
17
+ /* Hero */
18
+ .hero{text-align:center;margin-bottom:52px}
19
+ .pill{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;
20
+ background:linear-gradient(135deg,rgba(201,100,66,.1),rgba(201,100,66,.05));
21
+ border:1px solid rgba(201,100,66,.15);border-radius:99px;font-size:12px;font-weight:600;
22
+ color:#c96442;margin-bottom:24px;letter-spacing:.02em}
23
+ .pill svg{width:14px;height:14px}
24
+ h1{font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:10px;
25
+ background:linear-gradient(135deg,#c96442,#8b5cf6);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
26
+ .hero p{color:#6b6560;font-size:16px;max-width:500px;margin:0 auto}
27
+
28
+ /* Stats */
29
+ .stats{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
30
+ .stat{background:#fff;border:1px solid #e8e5e0;border-radius:12px;padding:22px 26px;position:relative;overflow:hidden;transition:transform .2s,box-shadow .2s}
31
+ .stat:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.06)}
32
+ .stat::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
33
+ .stat:first-child::before{background:linear-gradient(90deg,#c96442,#e8956d)}
34
+ .stat:last-child::before{background:linear-gradient(90deg,#2d8a4e,#5cb85c)}
35
+ .stat-l{font-size:11px;font-weight:600;color:#9b958e;text-transform:uppercase;letter-spacing:.08em}
36
+ .stat-v{font-size:32px;font-weight:700;margin-top:6px;letter-spacing:-.02em}
37
+ .stat:last-child .stat-v{color:#2d8a4e}
38
+
39
+ /* Features */
40
+ .features{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
41
+ .feat{background:#fff;border:1px solid #e8e5e0;border-radius:12px;padding:22px;transition:transform .2s,box-shadow .2s}
42
+ .feat:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.06)}
43
+ .feat-ico{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:14px}
44
+ .feat-ico svg{width:20px;height:20px}
45
+ .fi-mail{background:linear-gradient(135deg,#dbeafe,#eff6ff);color:#2563eb}
46
+ .fi-admin{background:linear-gradient(135deg,#fce7d6,#fef3ec);color:#c96442}
47
+ .fi-code{background:linear-gradient(135deg,#d1fae5,#ecfdf5);color:#059669}
48
+ .fi-ci{background:linear-gradient(135deg,#ede9fe,#f5f3ff);color:#7c3aed}
49
+ .feat h3{font-size:14px;font-weight:600;margin-bottom:4px}
50
+ .feat p{font-size:13px;color:#6b6560;line-height:1.5}
51
+ .feat code{background:#f3f4f6;padding:1px 5px;border-radius:3px;font-size:12px}
52
+
53
+ /* Links */
54
+ .links{display:flex;flex-direction:column;gap:12px;margin-bottom:40px}
55
+ .link{display:flex;align-items:center;gap:16px;background:#fff;border:1px solid #e8e5e0;border-radius:12px;padding:18px 22px;transition:all .2s;color:inherit}
56
+ .link:hover{box-shadow:0 8px 24px rgba(0,0,0,.06);border-color:#c96442;transform:translateY(-1px)}
57
+ .link-ico{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
58
+ .link-ico svg{width:22px;height:22px}
59
+ .li-admin{background:linear-gradient(135deg,#fce7d6,#fef3ec);color:#c96442}
60
+ .li-docs{background:linear-gradient(135deg,#dbeafe,#eff6ff);color:#2563eb}
61
+ .link-txt{flex:1}
62
+ .link-t{font-size:15px;font-weight:600}
63
+ .link-d{font-size:13px;color:#6b6560;margin-top:1px}
64
+ .link-arr{color:#9b958e;font-size:20px;transition:transform .2s}
65
+ .link:hover .link-arr{transform:translateX(4px);color:#c96442}
66
+
67
+ /* API Quick Ref */
68
+ .api-ref{background:#fff;border:1px solid #e8e5e0;border-radius:12px;overflow:hidden;margin-bottom:40px}
69
+ .api-hdr{padding:16px 22px;border-bottom:1px solid #e8e5e0;display:flex;align-items:center;justify-content:space-between}
70
+ .api-hdr h3{font-size:14px;font-weight:600}.api-hdr span{font-size:12px;color:#9b958e}
71
+ .ep{display:flex;align-items:center;gap:14px;padding:10px 22px;border-bottom:1px solid #f3f0ec;font-size:13px}
72
+ .ep:last-child{border-bottom:none}
73
+ .ep:hover{background:#faf9f7}
74
+ .m{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;font-family:monospace;min-width:48px;text-align:center}
75
+ .m-g{background:#dcfce7;color:#16a34a}.m-p{background:#dbeafe;color:#2563eb}.m-d{background:#fee2e2;color:#dc2626}
76
+ .ep-p{font-family:monospace;color:#1a1a1a}
77
+ .ep-d{color:#9b958e;margin-left:auto;font-size:12px}
78
+
79
+ /* Footer */
80
+ .foot{text-align:center;padding-top:16px;border-top:1px solid #e8e5e0;color:#9b958e;font-size:13px}
81
+ .foot a{color:#6b6560;font-weight:500}
82
+ .foot a:hover{color:#c96442}
83
+
84
+ @media(max-width:560px){
85
+ .page{padding:40px 16px 32px}
86
+ h1{font-size:28px}
87
+ .features{grid-template-columns:1fr}
88
+ .ep-d{display:none}
89
+ }
90
  </style>
91
  </head>
92
  <body>
93
+ <div class="page">
94
  <div class="hero">
95
+ <div class="pill">
96
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
97
+ Mail.tm-compatible API
98
+ </div>
99
  <h1>Outlook2API</h1>
100
+ <p>REST API for Outlook/Hotmail/Live accounts with admin panel, webmail, and batch registration</p>
101
  </div>
102
+
103
+ <div class="stats">
104
+ <div class="stat">
105
+ <div class="stat-l">Total Accounts</div>
106
+ <div class="stat-v" id="st">&mdash;</div>
107
+ </div>
108
+ <div class="stat">
109
+ <div class="stat-l">Active</div>
110
+ <div class="stat-v" id="sa">&mdash;</div>
111
+ </div>
112
  </div>
113
+
114
+ <div class="features">
115
+ <div class="feat">
116
+ <div class="feat-ico fi-mail"><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></div>
117
+ <h3>Mail API</h3>
118
+ <p>Mail.tm-compatible Hydra API for domains, accounts, tokens, and messages</p>
119
+ </div>
120
+ <div class="feat">
121
+ <div class="feat-ico fi-admin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/></svg></div>
122
+ <h3>Admin Panel</h3>
123
+ <p>Web UI for account management, webmail with compose &amp; reply, bulk import/export</p>
124
+ </div>
125
+ <div class="feat">
126
+ <div class="feat-ico fi-code"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg></div>
127
+ <h3>Verification Codes</h3>
128
+ <p>Auto-extract 6-digit OTP from emails via <code>/messages/{id}/code</code></p>
129
+ </div>
130
+ <div class="feat">
131
+ <div class="feat-ico fi-ci"><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></div>
132
+ <h3>CI Auto-Import</h3>
133
+ <p>GitHub Actions for automated Outlook registration &amp; account import</p>
134
+ </div>
135
+ </div>
136
+
137
  <div class="links">
138
+ <a href="/admin" class="link">
139
+ <div class="link-ico li-admin"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg></div>
140
+ <div class="link-txt"><div class="link-t">Admin Panel</div><div class="link-d">Manage accounts, webmail, import/export</div></div>
141
+ <span class="link-arr">&rarr;</span>
 
 
 
142
  </a>
143
+ <a href="/docs" class="link">
144
+ <div class="link-ico li-docs"><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"/></svg></div>
145
+ <div class="link-txt"><div class="link-t">API Documentation</div><div class="link-d">Interactive Swagger UI with all endpoints</div></div>
146
+ <span class="link-arr">&rarr;</span>
147
  </a>
148
  </div>
149
+
150
+ <div class="api-ref">
151
+ <div class="api-hdr"><h3>Quick API Reference</h3><span>Mail.tm-compatible</span></div>
152
+ <div class="ep"><span class="m m-g">GET</span><span class="ep-p">/domains</span><span class="ep-d">List email domains</span></div>
153
+ <div class="ep"><span class="m m-p">POST</span><span class="ep-p">/accounts</span><span class="ep-d">Register account</span></div>
154
+ <div class="ep"><span class="m m-p">POST</span><span class="ep-p">/token</span><span class="ep-d">Get JWT token</span></div>
155
+ <div class="ep"><span class="m m-g">GET</span><span class="ep-p">/messages</span><span class="ep-d">List inbox messages</span></div>
156
+ <div class="ep"><span class="m m-g">GET</span><span class="ep-p">/messages/{id}/code</span><span class="ep-d">Extract verification code</span></div>
157
+ <div class="ep"><span class="m m-d">DELETE</span><span class="ep-p">/accounts/me</span><span class="ep-d">Delete account</span></div>
158
+ </div>
159
+
160
+ <div class="foot">Outlook2API &middot; <a href="https://github.com/shenhao-stu/outlook2api">GitHub</a></div>
161
  </div>
162
  <script>
163
+ fetch('/admin/api/public-stats').then(function(r){if(r.ok)return r.json();throw r}).then(function(d){
164
+ document.getElementById('st').textContent=d.total!=null?d.total:0;
165
+ document.getElementById('sa').textContent=d.active!=null?d.active:0;
166
+ }).catch(function(){
167
+ document.getElementById('st').textContent='0';
168
+ document.getElementById('sa').textContent='0';
169
+ });
170
  </script>
171
  </body>
172
  </html>