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

feat: add admin panel with PostgreSQL, bulk import, and CI auto-import

Browse files

- Admin panel at /admin with dashboard, account management, import, API docs
- SQLAlchemy async backend (SQLite default, PostgreSQL supported)
- Admin API: CRUD, bulk import, file upload, export
- CI workflow auto-imports registered accounts to admin panel
- Homepage at / with stats and navigation
- Claude UI design: warm neutral palette, DM Sans typography

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

.env.example CHANGED
@@ -4,6 +4,10 @@ OUTLOOK2API_HOST=0.0.0.0
4
  OUTLOOK2API_PORT=8001
5
  OUTLOOK2API_ACCOUNTS_FILE=data/outlook_accounts.json
6
 
 
 
 
 
7
  # === Registration (captcha) ===
8
  CAPTCHA_CLIENT_KEY= # YesCaptcha / CapSolver API key
9
  CAPTCHA_CLOUD_URL=https://api.yescaptcha.com
 
4
  OUTLOOK2API_PORT=8001
5
  OUTLOOK2API_ACCOUNTS_FILE=data/outlook_accounts.json
6
 
7
+ # === Admin ===
8
+ ADMIN_PASSWORD=admin
9
+ DATABASE_URL=sqlite+aiosqlite:///./data/outlook2api.db
10
+
11
  # === Registration (captcha) ===
12
  CAPTCHA_CLIENT_KEY= # YesCaptcha / CapSolver API key
13
  CAPTCHA_CLOUD_URL=https://api.yescaptcha.com
.github/workflows/register-outlook.yml CHANGED
@@ -17,6 +17,8 @@ on:
17
  env:
18
  CAPTCHA_CLIENT_KEY: ${{ secrets.CAPTCHA_CLIENT_KEY }}
19
  PROXY_URL: ${{ secrets.PROXY_URL }}
 
 
20
 
21
  jobs:
22
  register:
@@ -65,3 +67,36 @@ jobs:
65
  path: output/*Outlook.zip
66
  retention-days: 30
67
  if-no-files-found: warn
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
  env:
18
  CAPTCHA_CLIENT_KEY: ${{ secrets.CAPTCHA_CLIENT_KEY }}
19
  PROXY_URL: ${{ secrets.PROXY_URL }}
20
+ OUTLOOK2API_URL: ${{ secrets.OUTLOOK2API_URL }}
21
+ ADMIN_PASSWORD: ${{ secrets.ADMIN_PASSWORD }}
22
 
23
  jobs:
24
  register:
 
67
  path: output/*Outlook.zip
68
  retention-days: 30
69
  if-no-files-found: warn
70
+
71
+ - name: Auto-import to admin panel
72
+ if: success()
73
+ run: |
74
+ if [ -z "$OUTLOOK2API_URL" ] || [ -z "$ADMIN_PASSWORD" ]; then
75
+ echo "OUTLOOK2API_URL or ADMIN_PASSWORD not set, skipping import"
76
+ exit 0
77
+ fi
78
+ # Extract accounts from staging files
79
+ ACCOUNTS="[]"
80
+ if [ -d "output/.staging_outlook" ]; then
81
+ ACCOUNTS=$(python -c "
82
+ import json, glob
83
+ accs = []
84
+ for f in glob.glob('output/.staging_outlook/outlook_*.json'):
85
+ try:
86
+ d = json.load(open(f))
87
+ accs.append(f\"{d['email']}:{d['password']}\")
88
+ except: pass
89
+ print(json.dumps(accs))
90
+ ")
91
+ fi
92
+ if [ "$ACCOUNTS" = "[]" ]; then
93
+ echo "No accounts to import"
94
+ exit 0
95
+ fi
96
+ echo "Importing accounts to $OUTLOOK2API_URL..."
97
+ curl -sf -X POST "${OUTLOOK2API_URL}/admin/api/accounts/bulk" \
98
+ -H "Authorization: Bearer ${ADMIN_PASSWORD}" \
99
+ -H "Content-Type: application/json" \
100
+ -d "{\"accounts\": $ACCOUNTS, \"source\": \"ci\"}" \
101
+ && echo "Import successful" \
102
+ || echo "Import failed (non-fatal)"
README.md CHANGED
@@ -1,168 +1,111 @@
1
  # Outlook2API
2
 
3
- Mail.tm-compatible REST API for Outlook/Hotmail/Live accounts + batch account registration with cloud FunCaptcha solver.
4
 
5
- ## Architecture
6
 
7
- ```
8
- ┌─────────────────────────────────────────────────────────┐
9
- │ outlook2api (FastAPI) — always-on mail API │
10
- │ Port 8001 (local) / 7860 (HuggingFace Space) │
11
- └─────────────────────────────────────────────────────────┘
12
- ┌─────────────────────────────────────────────────────────┐
13
- │ register (DrissionPage + YesCaptcha) │
14
- │ Batch Outlook account creation via signup.live.com │
15
- │ Runs on-demand: CLI / Docker / GitHub Actions │
16
- └─────────────────────────────────────────────────────────┘
17
- ```
18
 
19
  ## Quick Start
20
 
21
- ### Docker (recommended)
22
-
23
- ```bash
24
- cp .env.example .env
25
- # Edit .env with your JWT secret
26
-
27
- # Start the mail API
28
- docker compose up -d outlook2api
29
-
30
- # Run batch registration (on-demand)
31
- docker compose run --rm register --count 5
32
- ```
33
-
34
- ### Local
35
-
36
  ```bash
37
- # Mail API
38
  pip install -r requirements-api.txt
39
  python -m outlook2api.app
40
-
41
- # Registration
42
- pip install -r requirements-register.txt
43
- CAPTCHA_CLIENT_KEY=your-key python -m register.outlook_register --count 5
44
  ```
45
 
 
 
 
 
 
 
 
 
46
  ## API Endpoints
47
 
48
- Base URL: `http://localhost:8001` (local) or `https://ohmyapi-outlook2api.hf.space` (HuggingFace)
49
 
50
  | Method | Path | Auth | Description |
51
  |--------|------|------|-------------|
52
- | GET | `/domains` | No | List supported email domains |
53
- | POST | `/accounts` | No | Register account (validates IMAP login) |
54
- | POST | `/token` | No | Get JWT bearer token |
55
  | GET | `/me` | Bearer | Current user info |
56
- | GET | `/messages` | Bearer | List inbox messages |
57
- | GET | `/messages/{id}` | Bearer | Get single message |
58
- | GET | `/messages/{id}/code` | Bearer | Extract verification code from message |
59
- | DELETE | `/accounts/me` | Bearer | Delete account from store |
60
- | GET | `/docs` | No | Swagger UI |
61
 
62
- ### Usage Example
63
 
64
- ```bash
65
- # 1. Register an account
66
- curl -X POST http://localhost:8001/accounts \
67
- -H 'Content-Type: application/json' \
68
- -d '{"address": "user@outlook.com", "password": "YourPassword123"}'
69
-
70
- # 2. Get token
71
- TOKEN=$(curl -s -X POST http://localhost:8001/token \
72
- -H 'Content-Type: application/json' \
73
- -d '{"address": "user@outlook.com", "password": "YourPassword123"}' | jq -r .token)
74
-
75
- # 3. List messages
76
- curl -H "Authorization: Bearer $TOKEN" http://localhost:8001/messages
77
-
78
- # 4. Extract verification code from a message
79
- curl -H "Authorization: Bearer $TOKEN" http://localhost:8001/messages/42/code
80
- # Response: {"code": "123456", "message_id": "42", "subject": "Your verification code"}
81
- ```
82
 
83
- ## Batch Registration
84
 
85
- Automates Outlook account creation via `signup.live.com` using DrissionPage (Chrome automation) and cloud FunCaptcha solving (YesCaptcha/CapSolver).
86
-
87
- ### Flow
88
-
89
- ```
90
- signup.live.com → enter email/password/name/birthdate
91
- → FunCaptcha loads in iframe
92
- → detect iframe, extract pk= parameter
93
- → solve via cloud API (FunCaptchaTaskProxyless)
94
- → inject token, complete registration
95
- → save email:password to output/
96
- ```
97
-
98
- ### CLI Options
99
-
100
- ```bash
101
- python -m register.outlook_register \
102
- --count 10 \ # Number of accounts
103
- --threads 2 \ # Concurrent threads
104
- --proxy "http://user:pass@host:port" # Optional proxy
105
- ```
106
-
107
- ### GitHub Actions
108
-
109
- The workflow runs on schedule (`0 4 * * *` UTC) or manually via `workflow_dispatch`.
110
 
111
  Required secrets:
112
- - `CAPTCHA_CLIENT_KEY` — YesCaptcha/CapSolver API key
 
 
 
113
 
114
- Optional secrets:
115
- - `PROXY_URL` — HTTP/SOCKS5 proxy
116
-
117
- Trigger manually:
118
  ```bash
119
- gh workflow run register-outlook.yml \
120
- --repo shenhao-stu/outlook2api \
121
- -f count=5 -f threads=1
122
  ```
123
 
124
  ## Environment Variables
125
 
126
  | Name | Default | Description |
127
  |------|---------|-------------|
128
- | `OUTLOOK2API_JWT_SECRET` | `change-me-in-production` | JWT signing secret |
129
- | `OUTLOOK2API_HOST` | `0.0.0.0` | API bind host |
130
- | `OUTLOOK2API_PORT` | `8001` | API bind port |
131
- | `OUTLOOK2API_ACCOUNTS_FILE` | `data/outlook_accounts.json` | Account store path |
132
- | `CAPTCHA_CLIENT_KEY` | — | YesCaptcha/CapSolver API key |
133
- | `CAPTCHA_CLOUD_URL` | `https://api.yescaptcha.com` | Cloud solver endpoint |
134
- | `FUNCAPTCHA_PUBLIC_KEY` | `B7D8911C-5CC8-A9A3-35B0-554ACEE604DA` | Microsoft FunCaptcha public key |
135
- | `PROXY_URL` | — | HTTP/SOCKS5 proxy for registration |
136
-
137
- ## HuggingFace Deployment
138
-
139
- The API is deployed at: **https://ohmyapi-outlook2api.hf.space**
140
 
141
- The HF Space uses a Docker SDK that clones this repo at build time and runs `uvicorn outlook2api.app:app` on port 7860.
142
 
143
- Space secrets:
144
- - `OUTLOOK2API_JWT_SECRET` — set in Space Settings → Variables and secrets
145
 
146
  ## Project Structure
147
 
148
  ```
149
  outlook2api/
150
- ├── outlook2api/ # FastAPI mail API
151
- │ ├── app.py # Application entry point
152
- │ ├── auth.py # JWT auth helpers + FastAPI dependency
153
- │ ├── config.py # Environment-based configuration
154
- │ ├── routes.py # All API routes
155
- │ ├── outlook_imap.py # IMAP client + code extraction
156
- ── store.py # JSON file account store
157
- ├── register/ # Batch registration module
158
- │ ├── outlook_register.py # DrissionPage-based registrar
159
- │ └── captcha.py # FunCaptchaService (cloud solver)
 
 
 
160
  ├── .github/workflows/
161
- │ └── register-outlook.yml # CI workflow
162
- ├── Dockerfile.api # API container
163
- ├── Dockerfile.register # Registration container (with Chrome + Xvfb)
164
  ├── docker-compose.yml
165
  ├── requirements-api.txt
166
- ── requirements-register.txt
167
- └── pyproject.toml
168
  ```
 
1
  # Outlook2API
2
 
3
+ Mail.tm-compatible REST API for Outlook/Hotmail/Live accounts with admin panel, batch registration, and CI auto-import.
4
 
5
+ ## Features
6
 
7
+ - Mail.tm-compatible Hydra API (drop-in replacement for Outlook accounts)
8
+ - Admin panel with account management, bulk import, and API docs
9
+ - Batch account registration via GitHub Actions (DrissionPage + YesCaptcha)
10
+ - CI auto-import: registered accounts automatically pushed to admin database
11
+ - SQLite/PostgreSQL backend
12
+ - HuggingFace Spaces deployment
 
 
 
 
 
13
 
14
  ## Quick Start
15
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  ```bash
 
17
  pip install -r requirements-api.txt
18
  python -m outlook2api.app
19
+ # Open http://localhost:8001 (homepage) or http://localhost:8001/admin (admin panel)
20
+ # Default admin password: admin
 
 
21
  ```
22
 
23
+ ## Admin Panel
24
+
25
+ Access at `/admin`. Features:
26
+ - Dashboard with account stats
27
+ - Account management (search, filter, toggle, delete)
28
+ - Bulk import (text, file upload, CI API)
29
+ - Full API documentation with curl examples
30
+
31
  ## API Endpoints
32
 
33
+ ### Mail API (mail.tm-compatible)
34
 
35
  | Method | Path | Auth | Description |
36
  |--------|------|------|-------------|
37
+ | GET | `/domains` | No | List supported domains |
38
+ | POST | `/accounts` | No | Register account (IMAP validation) |
39
+ | POST | `/token` | No | Get JWT token |
40
  | GET | `/me` | Bearer | Current user info |
41
+ | GET | `/messages` | Bearer | List messages |
42
+ | GET | `/messages/{id}` | Bearer | Get message |
43
+ | GET | `/messages/{id}/code` | Bearer | Extract verification code |
44
+ | DELETE | `/accounts/me` | Bearer | Delete account |
 
45
 
46
+ ### Admin API
47
 
48
+ | Method | Path | Description |
49
+ |--------|------|-------------|
50
+ | POST | `/admin/api/login` | Login (returns token) |
51
+ | GET | `/admin/api/stats` | Dashboard stats |
52
+ | GET | `/admin/api/accounts` | List accounts (paginated) |
53
+ | POST | `/admin/api/accounts` | Add single account |
54
+ | POST | `/admin/api/accounts/bulk` | Bulk import |
55
+ | POST | `/admin/api/accounts/upload` | File upload import |
56
+ | PATCH | `/admin/api/accounts/{id}` | Update account |
57
+ | DELETE | `/admin/api/accounts/{id}` | Delete account |
58
+ | GET | `/admin/api/export` | Export all accounts |
 
 
 
 
 
 
 
59
 
60
+ ## CI Auto-Import
61
 
62
+ GitHub Actions workflow registers accounts and auto-imports to admin panel.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
63
 
64
  Required secrets:
65
+ - `CAPTCHA_CLIENT_KEY` — YesCaptcha API key
66
+ - `PROXY_URL` — Residential proxy
67
+ - `OUTLOOK2API_URL` — Admin panel URL (e.g. `https://ohmyapi-outlook2api.hf.space`)
68
+ - `ADMIN_PASSWORD` — Admin password
69
 
 
 
 
 
70
  ```bash
71
+ gh workflow run register-outlook.yml --repo shenhao-stu/outlook2api -f count=5
 
 
72
  ```
73
 
74
  ## Environment Variables
75
 
76
  | Name | Default | Description |
77
  |------|---------|-------------|
78
+ | `ADMIN_PASSWORD` | `admin` | Admin panel password |
79
+ | `DATABASE_URL` | `sqlite+aiosqlite:///./data/outlook2api.db` | Database URL |
80
+ | `OUTLOOK2API_JWT_SECRET` | `change-me-in-production` | JWT secret |
81
+ | `OUTLOOK2API_PORT` | `8001` | API port |
 
 
 
 
 
 
 
 
82
 
83
+ ## Deployment
84
 
85
+ Live at: **https://ohmyapi-outlook2api.hf.space**
 
86
 
87
  ## Project Structure
88
 
89
  ```
90
  outlook2api/
91
+ ├── outlook2api/
92
+ │ ├── app.py # FastAPI entry point
93
+ │ ├── database.py # SQLAlchemy models + async DB
94
+ │ ├── admin_routes.py # Admin API (CRUD, import, export)
95
+ │ ├── routes.py # Mail.tm-compatible API
96
+ │ ├── auth.py # JWT authentication
97
+ ── config.py # Configuration
98
+ ├── outlook_imap.py # IMAP client
99
+ │ ├── store.py # Legacy JSON store
100
+ │ └── static/ # Frontend (index.html, admin.html)
101
+ ├── register/
102
+ │ ├── outlook_register.py # Batch registrar
103
+ │ └── captcha.py # FunCaptcha solver
104
  ├── .github/workflows/
105
+ │ └── register-outlook.yml # CI with auto-import
106
+ ├── Dockerfile.api
107
+ ├── Dockerfile.register
108
  ├── docker-compose.yml
109
  ├── requirements-api.txt
110
+ ── requirements-register.txt
 
111
  ```
outlook2api/admin_routes.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin API routes — account management, bulk import, stats."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import io
6
+ import json
7
+ from datetime import datetime, timezone
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
10
+ from pydantic import BaseModel
11
+ from sqlalchemy import select, func, delete
12
+ from sqlalchemy.ext.asyncio import AsyncSession
13
+
14
+ from outlook2api.config import get_config
15
+ from outlook2api.database import Account, get_db, get_stats
16
+
17
+ admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
18
+
19
+
20
+ def _verify_admin(request: Request) -> None:
21
+ """Check admin password from cookie or Authorization header."""
22
+ cfg = get_config()
23
+ expected = cfg["admin_password"]
24
+ # Cookie auth
25
+ token = request.cookies.get("admin_token", "")
26
+ if token and token == hashlib.sha256(expected.encode()).hexdigest():
27
+ return
28
+ # Header auth
29
+ auth = request.headers.get("Authorization", "")
30
+ if auth.startswith("Bearer ") and auth[7:].strip() == expected:
31
+ return
32
+ raise HTTPException(status_code=401, detail="Unauthorized")
33
+
34
+
35
+ class LoginRequest(BaseModel):
36
+ password: str
37
+
38
+
39
+ class AccountUpdate(BaseModel):
40
+ is_active: bool | None = None
41
+ notes: str | None = None
42
+
43
+
44
+ class BulkImportRequest(BaseModel):
45
+ accounts: list[dict]
46
+ source: str = "import"
47
+
48
+
49
+ @admin_router.post("/login")
50
+ async def admin_login(body: LoginRequest):
51
+ cfg = get_config()
52
+ if body.password != cfg["admin_password"]:
53
+ raise HTTPException(status_code=401, detail="Invalid password")
54
+ token = hashlib.sha256(cfg["admin_password"].encode()).hexdigest()
55
+ return {"token": token}
56
+
57
+
58
+ @admin_router.get("/stats")
59
+ async def admin_stats(
60
+ request: Request,
61
+ db: AsyncSession = Depends(get_db),
62
+ ):
63
+ _verify_admin(request)
64
+ stats = await get_stats(db)
65
+ # Recent accounts (last 7 days)
66
+ week_ago = datetime.now(timezone.utc).replace(hour=0, minute=0, second=0)
67
+ from datetime import timedelta
68
+ week_ago = week_ago - timedelta(days=7)
69
+ recent = (await db.execute(
70
+ select(func.count(Account.id)).where(Account.created_at >= week_ago)
71
+ )).scalar() or 0
72
+ stats["recent_7d"] = recent
73
+ return stats
74
+
75
+
76
+ @admin_router.get("/accounts")
77
+ async def list_accounts(
78
+ request: Request,
79
+ page: int = 1,
80
+ limit: int = 50,
81
+ search: str = "",
82
+ active: str = "",
83
+ db: AsyncSession = Depends(get_db),
84
+ ):
85
+ _verify_admin(request)
86
+ q = select(Account)
87
+ count_q = select(func.count(Account.id))
88
+ if search:
89
+ q = q.where(Account.email.ilike(f"%{search}%"))
90
+ count_q = count_q.where(Account.email.ilike(f"%{search}%"))
91
+ if active == "true":
92
+ q = q.where(Account.is_active == True)
93
+ count_q = count_q.where(Account.is_active == True)
94
+ elif active == "false":
95
+ q = q.where(Account.is_active == False)
96
+ count_q = count_q.where(Account.is_active == False)
97
+ total = (await db.execute(count_q)).scalar() or 0
98
+ q = q.order_by(Account.created_at.desc()).offset((page - 1) * limit).limit(limit)
99
+ rows = (await db.execute(q)).scalars().all()
100
+ return {
101
+ "accounts": [a.to_dict(hide_password=True) for a in rows],
102
+ "total": total,
103
+ "page": page,
104
+ "pages": max(1, (total + limit - 1) // limit),
105
+ }
106
+
107
+
108
+ @admin_router.post("/accounts")
109
+ async def create_account(
110
+ request: Request,
111
+ db: AsyncSession = Depends(get_db),
112
+ ):
113
+ _verify_admin(request)
114
+ body = await request.json()
115
+ email = body.get("email", "").strip().lower()
116
+ password = body.get("password", "").strip()
117
+ if not email or not password:
118
+ raise HTTPException(status_code=400, detail="Email and password required")
119
+ existing = (await db.execute(select(Account).where(Account.email == email))).scalar_one_or_none()
120
+ if existing:
121
+ raise HTTPException(status_code=409, detail="Account already exists")
122
+ account = Account(email=email, password=password, source="manual")
123
+ db.add(account)
124
+ await db.commit()
125
+ await db.refresh(account)
126
+ return account.to_dict()
127
+
128
+
129
+ @admin_router.post("/accounts/bulk")
130
+ async def bulk_import(
131
+ request: Request,
132
+ db: AsyncSession = Depends(get_db),
133
+ ):
134
+ _verify_admin(request)
135
+ body = await request.json()
136
+ accounts_data = body.get("accounts", [])
137
+ source = body.get("source", "import")
138
+ imported = 0
139
+ skipped = 0
140
+ for item in accounts_data:
141
+ email = ""
142
+ password = ""
143
+ if isinstance(item, dict):
144
+ email = item.get("email", "").strip().lower()
145
+ password = item.get("password", "").strip()
146
+ elif isinstance(item, str) and ":" in item:
147
+ parts = item.split(":", 1)
148
+ email = parts[0].strip().lower()
149
+ password = parts[1].strip()
150
+ if not email or not password:
151
+ skipped += 1
152
+ continue
153
+ existing = (await db.execute(select(Account).where(Account.email == email))).scalar_one_or_none()
154
+ if existing:
155
+ skipped += 1
156
+ continue
157
+ db.add(Account(email=email, password=password, source=source))
158
+ imported += 1
159
+ await db.commit()
160
+ return {"imported": imported, "skipped": skipped, "total": len(accounts_data)}
161
+
162
+
163
+ @admin_router.post("/accounts/upload")
164
+ async def upload_accounts(
165
+ request: Request,
166
+ file: UploadFile = File(...),
167
+ db: AsyncSession = Depends(get_db),
168
+ ):
169
+ _verify_admin(request)
170
+ content = (await file.read()).decode("utf-8", errors="replace")
171
+ accounts_data = []
172
+ for line in content.strip().splitlines():
173
+ line = line.strip()
174
+ if not line or line.startswith("#"):
175
+ continue
176
+ if ":" in line:
177
+ accounts_data.append(line)
178
+ # Reuse bulk import logic
179
+ imported = 0
180
+ skipped = 0
181
+ for item in accounts_data:
182
+ parts = item.split(":", 1)
183
+ email = parts[0].strip().lower()
184
+ password = parts[1].strip()
185
+ if not email or not password:
186
+ skipped += 1
187
+ continue
188
+ existing = (await db.execute(select(Account).where(Account.email == email))).scalar_one_or_none()
189
+ if existing:
190
+ skipped += 1
191
+ continue
192
+ db.add(Account(email=email, password=password, source="upload"))
193
+ imported += 1
194
+ await db.commit()
195
+ return {"imported": imported, "skipped": skipped, "total": len(accounts_data)}
196
+
197
+
198
+ @admin_router.patch("/accounts/{account_id}")
199
+ async def update_account(
200
+ account_id: str,
201
+ body: AccountUpdate,
202
+ request: Request,
203
+ db: AsyncSession = Depends(get_db),
204
+ ):
205
+ _verify_admin(request)
206
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
207
+ if not account:
208
+ raise HTTPException(status_code=404, detail="Account not found")
209
+ if body.is_active is not None:
210
+ account.is_active = body.is_active
211
+ if body.notes is not None:
212
+ account.notes = body.notes
213
+ await db.commit()
214
+ return account.to_dict()
215
+
216
+
217
+ @admin_router.delete("/accounts/{account_id}")
218
+ async def delete_account(
219
+ account_id: str,
220
+ request: Request,
221
+ db: AsyncSession = Depends(get_db),
222
+ ):
223
+ _verify_admin(request)
224
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
225
+ if not account:
226
+ raise HTTPException(status_code=404, detail="Account not found")
227
+ await db.delete(account)
228
+ await db.commit()
229
+ return {"status": "deleted"}
230
+
231
+
232
+ @admin_router.delete("/accounts")
233
+ async def delete_all_accounts(
234
+ request: Request,
235
+ db: AsyncSession = Depends(get_db),
236
+ ):
237
+ _verify_admin(request)
238
+ await db.execute(delete(Account))
239
+ await db.commit()
240
+ return {"status": "all deleted"}
241
+
242
+
243
+ @admin_router.get("/accounts/{account_id}/password")
244
+ async def get_account_password(
245
+ account_id: str,
246
+ request: Request,
247
+ db: AsyncSession = Depends(get_db),
248
+ ):
249
+ _verify_admin(request)
250
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
251
+ if not account:
252
+ raise HTTPException(status_code=404, detail="Account not found")
253
+ return {"password": account.password}
254
+
255
+
256
+ @admin_router.get("/export")
257
+ async def export_accounts(
258
+ request: Request,
259
+ db: AsyncSession = Depends(get_db),
260
+ ):
261
+ """Export all active accounts as email:password text."""
262
+ _verify_admin(request)
263
+ rows = (await db.execute(
264
+ select(Account).where(Account.is_active == True).order_by(Account.created_at.desc())
265
+ )).scalars().all()
266
+ lines = [f"{a.email}:{a.password}" for a in rows]
267
+ return {"count": len(lines), "data": "\n".join(lines)}
outlook2api/app.py CHANGED
@@ -3,21 +3,49 @@
3
  from __future__ import annotations
4
 
5
  from contextlib import asynccontextmanager
 
6
 
7
- from fastapi import FastAPI
 
 
8
 
9
  from outlook2api.config import get_config
 
10
  from outlook2api.routes import router
 
 
 
11
 
12
 
13
  @asynccontextmanager
14
  async def lifespan(app: FastAPI):
 
15
  yield
16
 
17
 
18
  def create_app() -> FastAPI:
19
  app = FastAPI(title="Outlook2API", description="Mail.tm-compatible API for Outlook accounts", lifespan=lifespan)
20
  app.include_router(router, tags=["mail"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  return app
22
 
23
 
 
3
  from __future__ import annotations
4
 
5
  from contextlib import asynccontextmanager
6
+ from pathlib import Path
7
 
8
+ from fastapi import FastAPI, Request
9
+ from fastapi.responses import HTMLResponse
10
+ from fastapi.staticfiles import StaticFiles
11
 
12
  from outlook2api.config import get_config
13
+ from outlook2api.database import init_db
14
  from outlook2api.routes import router
15
+ from outlook2api.admin_routes import admin_router
16
+
17
+ STATIC_DIR = Path(__file__).parent / "static"
18
 
19
 
20
  @asynccontextmanager
21
  async def lifespan(app: FastAPI):
22
+ await init_db()
23
  yield
24
 
25
 
26
  def create_app() -> FastAPI:
27
  app = FastAPI(title="Outlook2API", description="Mail.tm-compatible API for Outlook accounts", lifespan=lifespan)
28
  app.include_router(router, tags=["mail"])
29
+ app.include_router(admin_router)
30
+
31
+ if STATIC_DIR.is_dir():
32
+ app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
33
+
34
+ @app.get("/", response_class=HTMLResponse)
35
+ async def index():
36
+ html_file = STATIC_DIR / "index.html"
37
+ if html_file.exists():
38
+ return HTMLResponse(html_file.read_text(encoding="utf-8"))
39
+ return HTMLResponse("<h1>Outlook2API</h1><p><a href='/docs'>API Docs</a> | <a href='/admin'>Admin</a></p>")
40
+
41
+ @app.get("/admin", response_class=HTMLResponse)
42
+ @app.get("/admin/{path:path}", response_class=HTMLResponse)
43
+ async def admin_page(request: Request, path: str = ""):
44
+ html_file = STATIC_DIR / "admin.html"
45
+ if html_file.exists():
46
+ return HTMLResponse(html_file.read_text(encoding="utf-8"))
47
+ return HTMLResponse("<h1>Admin panel not found</h1>")
48
+
49
  return app
50
 
51
 
outlook2api/config.py CHANGED
@@ -13,4 +13,9 @@ def get_config() -> dict:
13
  os.path.join(os.path.dirname(__file__), "..", "data", "outlook_accounts.json"),
14
  ),
15
  "jwt_secret": os.environ.get("OUTLOOK2API_JWT_SECRET", "change-me-in-production"),
 
 
 
 
 
16
  }
 
13
  os.path.join(os.path.dirname(__file__), "..", "data", "outlook_accounts.json"),
14
  ),
15
  "jwt_secret": os.environ.get("OUTLOOK2API_JWT_SECRET", "change-me-in-production"),
16
+ "admin_password": os.environ.get("ADMIN_PASSWORD", "admin"),
17
+ "database_url": os.environ.get(
18
+ "DATABASE_URL",
19
+ "sqlite+aiosqlite:///./data/outlook2api.db",
20
+ ),
21
  }
outlook2api/database.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLAlchemy async database setup and models."""
2
+ from __future__ import annotations
3
+
4
+ import uuid
5
+ from datetime import datetime, timezone
6
+
7
+ from sqlalchemy import Column, String, Boolean, DateTime, Integer, Text, select, func
8
+ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
9
+ from sqlalchemy.orm import DeclarativeBase
10
+
11
+ from outlook2api.config import get_config
12
+
13
+
14
+ class Base(DeclarativeBase):
15
+ pass
16
+
17
+
18
+ class Account(Base):
19
+ __tablename__ = "accounts"
20
+
21
+ id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex[:16])
22
+ email = Column(String, unique=True, nullable=False, index=True)
23
+ password = Column(String, nullable=False)
24
+ is_active = Column(Boolean, default=True)
25
+ created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
26
+ last_used = Column(DateTime, nullable=True)
27
+ usage_count = Column(Integer, default=0)
28
+ source = Column(String, default="manual") # manual, ci, import
29
+ notes = Column(Text, default="")
30
+
31
+ def to_dict(self, hide_password: bool = False) -> dict:
32
+ return {
33
+ "id": self.id,
34
+ "email": self.email,
35
+ "password": "••••••••" if hide_password else self.password,
36
+ "is_active": self.is_active,
37
+ "created_at": self.created_at.isoformat() if self.created_at else None,
38
+ "last_used": self.last_used.isoformat() if self.last_used else None,
39
+ "usage_count": self.usage_count,
40
+ "source": self.source,
41
+ "notes": self.notes,
42
+ }
43
+
44
+
45
+ _engine = None
46
+ _session_factory = None
47
+
48
+
49
+ def _get_db_url() -> str:
50
+ url = get_config()["database_url"]
51
+ # Convert postgres:// to postgresql+asyncpg://
52
+ if url.startswith("postgres://"):
53
+ url = url.replace("postgres://", "postgresql+asyncpg://", 1)
54
+ elif url.startswith("postgresql://"):
55
+ url = url.replace("postgresql://", "postgresql+asyncpg://", 1)
56
+ return url
57
+
58
+
59
+ async def init_db() -> None:
60
+ global _engine, _session_factory
61
+ url = _get_db_url()
62
+ connect_args = {"check_same_thread": False} if "sqlite" in url else {}
63
+ _engine = create_async_engine(url, echo=False, connect_args=connect_args)
64
+ _session_factory = async_sessionmaker(_engine, expire_on_commit=False)
65
+ async with _engine.begin() as conn:
66
+ await conn.run_sync(Base.metadata.create_all)
67
+
68
+
69
+ async def get_db() -> AsyncSession:
70
+ async with _session_factory() as session:
71
+ yield session
72
+
73
+
74
+ async def get_stats(db: AsyncSession) -> dict:
75
+ total = (await db.execute(select(func.count(Account.id)))).scalar() or 0
76
+ active = (await db.execute(
77
+ select(func.count(Account.id)).where(Account.is_active == True)
78
+ )).scalar() or 0
79
+ return {"total": total, "active": active, "inactive": total - active}
outlook2api/static/admin.html ADDED
@@ -0,0 +1,686 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Outlook2API Admin</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
+ :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="docs" aria-label="API Docs">
177
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
178
+ API Docs
179
+ </button>
180
+ <div class="sidebar-footer">
181
+ <button class="nav-item" id="logout-btn" aria-label="Logout">
182
+ <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>
183
+ Logout
184
+ </button>
185
+ </div>
186
+ </nav>
187
+
188
+ <main class="main">
189
+ <!-- Dashboard -->
190
+ <section id="tab-dashboard" class="tab-content">
191
+ <h2>Dashboard</h2>
192
+ <div class="stat-grid">
193
+ <div class="stat-card"><div class="label">Total Accounts</div><div class="value" id="stat-total">--</div></div>
194
+ <div class="stat-card"><div class="label">Active</div><div class="value" id="stat-active">--</div></div>
195
+ <div class="stat-card"><div class="label">Inactive</div><div class="value" id="stat-inactive">--</div></div>
196
+ <div class="stat-card"><div class="label">Last 7 Days</div><div class="value" id="stat-recent">--</div></div>
197
+ </div>
198
+ <button class="btn btn-outline" id="export-btn" aria-label="Export accounts">
199
+ <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>
200
+ Export Accounts
201
+ </button>
202
+ </section>
203
+
204
+ <!-- Accounts -->
205
+ <section id="tab-accounts" class="tab-content" style="display:none">
206
+ <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1.2rem">
207
+ <h2 style="margin-bottom:0">Accounts</h2>
208
+ <div style="display:flex;gap:.5rem">
209
+ <button class="btn btn-primary btn-sm" id="add-account-btn" aria-label="Add account">+ Add</button>
210
+ <button class="btn btn-danger btn-sm" id="delete-all-btn" aria-label="Delete all accounts">Delete All</button>
211
+ </div>
212
+ </div>
213
+ <div class="toolbar">
214
+ <input type="text" id="search-input" placeholder="Search by email..." aria-label="Search accounts">
215
+ <select id="active-filter" aria-label="Filter by status">
216
+ <option value="">All</option>
217
+ <option value="true">Active</option>
218
+ <option value="false">Inactive</option>
219
+ </select>
220
+ </div>
221
+ <div class="table-wrap">
222
+ <table>
223
+ <thead><tr><th>Email</th><th>Status</th><th>Source</th><th>Created</th><th>Actions</th></tr></thead>
224
+ <tbody id="accounts-tbody"><tr><td colspan="5" class="loading">Loading...</td></tr></tbody>
225
+ </table>
226
+ </div>
227
+ <div class="pagination">
228
+ <button class="btn btn-outline btn-sm" id="prev-btn" disabled aria-label="Previous page">Prev</button>
229
+ <span id="page-info">Page 1</span>
230
+ <button class="btn btn-outline btn-sm" id="next-btn" disabled aria-label="Next page">Next</button>
231
+ </div>
232
+ </section>
233
+
234
+ <!-- Import -->
235
+ <section id="tab-import" class="tab-content" style="display:none">
236
+ <h2>Import Accounts</h2>
237
+ <div class="import-section">
238
+ <h3>Text Import</h3>
239
+ <p>Enter accounts, one per line in <code>email:password</code> format.</p>
240
+ <textarea id="bulk-text" placeholder="user1@outlook.com:password1&#10;user2@outlook.com:password2" aria-label="Bulk account text"></textarea>
241
+ <button class="btn btn-primary" id="bulk-submit-btn">Import Accounts</button>
242
+ </div>
243
+ <div class="import-section">
244
+ <h3>File Upload</h3>
245
+ <p>Upload a <code>.txt</code> file with one <code>email:password</code> per line.</p>
246
+ <input type="file" id="file-upload" accept=".txt" aria-label="Upload account file">
247
+ <div style="margin-top:.6rem"><button class="btn btn-primary" id="file-submit-btn">Upload File</button></div>
248
+ </div>
249
+ <div class="import-section">
250
+ <h3>CI Import</h3>
251
+ <p>Use this endpoint in your GitHub Actions workflow to auto-import accounts.</p>
252
+ <pre>POST /admin/api/accounts/bulk
253
+ Content-Type: application/json
254
+ Authorization: Bearer ADMIN_PASSWORD
255
+
256
+ {
257
+ "accounts": ["user@outlook.com:pass"],
258
+ "source": "ci"
259
+ }</pre>
260
+ <p style="margin-top:.6rem;font-weight:500">curl example:</p>
261
+ <pre>curl -X POST https://your-domain/admin/api/accounts/bulk \
262
+ -H "Authorization: Bearer ADMIN_PASSWORD" \
263
+ -H "Content-Type: application/json" \
264
+ -d '{"accounts": ["user@outlook.com:pass"], "source": "ci"}'</pre>
265
+ </div>
266
+ </section>
267
+
268
+ <!-- API Docs -->
269
+ <section id="tab-docs" class="tab-content" style="display:none">
270
+ <h2>API Documentation</h2>
271
+ <div class="endpoint-group">
272
+ <h3>Mail API</h3>
273
+ <div class="endpoint">
274
+ <span class="method method-get">GET</span><span class="path">/domains</span>
275
+ <div class="desc">List supported email domains</div>
276
+ <pre>curl https://your-domain/domains</pre>
277
+ </div>
278
+ <div class="endpoint">
279
+ <span class="method method-post">POST</span><span class="path">/accounts</span>
280
+ <div class="desc">Register account (validates via IMAP)</div>
281
+ <pre>curl -X POST https://your-domain/accounts \
282
+ -H "Content-Type: application/json" \
283
+ -d '{"address": "user@outlook.com", "password": "pass"}'</pre>
284
+ </div>
285
+ <div class="endpoint">
286
+ <span class="method method-post">POST</span><span class="path">/token</span>
287
+ <div class="desc">Get JWT token</div>
288
+ <pre>curl -X POST https://your-domain/token \
289
+ -H "Content-Type: application/json" \
290
+ -d '{"address": "user@outlook.com", "password": "pass"}'</pre>
291
+ </div>
292
+ <div class="endpoint">
293
+ <span class="method method-get">GET</span><span class="path">/me</span>
294
+ <div class="desc">Get current user info (requires Bearer token)</div>
295
+ <pre>curl https://your-domain/me \
296
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
297
+ </div>
298
+ <div class="endpoint">
299
+ <span class="method method-get">GET</span><span class="path">/messages</span>
300
+ <div class="desc">List messages (requires Bearer token, ?page=1)</div>
301
+ <pre>curl "https://your-domain/messages?page=1" \
302
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
303
+ </div>
304
+ <div class="endpoint">
305
+ <span class="method method-get">GET</span><span class="path">/messages/{id}</span>
306
+ <div class="desc">Get a specific message (requires Bearer token)</div>
307
+ <pre>curl https://your-domain/messages/123 \
308
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
309
+ </div>
310
+ <div class="endpoint">
311
+ <span class="method method-get">GET</span><span class="path">/messages/{id}/code</span>
312
+ <div class="desc">Extract verification code from message (requires Bearer token)</div>
313
+ <pre>curl https://your-domain/messages/123/code \
314
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
315
+ </div>
316
+ <div class="endpoint">
317
+ <span class="method method-delete">DELETE</span><span class="path">/accounts/me</span>
318
+ <div class="desc">Delete current account (requires Bearer token)</div>
319
+ <pre>curl -X DELETE https://your-domain/accounts/me \
320
+ -H "Authorization: Bearer YOUR_JWT_TOKEN"</pre>
321
+ </div>
322
+ </div>
323
+ <div class="endpoint-group">
324
+ <h3>Admin API</h3>
325
+ <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>
326
+ <div class="endpoint">
327
+ <span class="method method-post">POST</span><span class="path">/admin/api/login</span>
328
+ <div class="desc">Authenticate and receive admin token</div>
329
+ <pre>curl -X POST https://your-domain/admin/api/login \
330
+ -H "Content-Type: application/json" \
331
+ -d '{"password": "your_password"}'</pre>
332
+ </div>
333
+ <div class="endpoint">
334
+ <span class="method method-get">GET</span><span class="path">/admin/api/stats</span>
335
+ <div class="desc">Get account statistics</div>
336
+ <pre>curl https://your-domain/admin/api/stats \
337
+ -H "Authorization: Bearer ADMIN_TOKEN"</pre>
338
+ </div>
339
+ <div class="endpoint">
340
+ <span class="method method-get">GET</span><span class="path">/admin/api/accounts</span>
341
+ <div class="desc">List accounts with pagination and filters (?page=&amp;search=&amp;active=)</div>
342
+ <pre>curl "https://your-domain/admin/api/accounts?page=1&amp;search=user&amp;active=true" \
343
+ -H "Authorization: Bearer ADMIN_TOKEN"</pre>
344
+ </div>
345
+ <div class="endpoint">
346
+ <span class="method method-post">POST</span><span class="path">/admin/api/accounts</span>
347
+ <div class="desc">Add a single account</div>
348
+ <pre>curl -X POST https://your-domain/admin/api/accounts \
349
+ -H "Authorization: Bearer ADMIN_TOKEN" \
350
+ -H "Content-Type: application/json" \
351
+ -d '{"email": "user@outlook.com", "password": "pass"}'</pre>
352
+ </div>
353
+ <div class="endpoint">
354
+ <span class="method method-post">POST</span><span class="path">/admin/api/accounts/bulk</span>
355
+ <div class="desc">Bulk import accounts</div>
356
+ <pre>curl -X POST https://your-domain/admin/api/accounts/bulk \
357
+ -H "Authorization: Bearer ADMIN_TOKEN" \
358
+ -H "Content-Type: application/json" \
359
+ -d '{"accounts": ["user@outlook.com:pass"], "source": "manual"}'</pre>
360
+ </div>
361
+ <div class="endpoint">
362
+ <span class="method method-post">POST</span><span class="path">/admin/api/accounts/upload</span>
363
+ <div class="desc">Upload accounts file (multipart form)</div>
364
+ <pre>curl -X POST https://your-domain/admin/api/accounts/upload \
365
+ -H "Authorization: Bearer ADMIN_TOKEN" \
366
+ -F "file=@accounts.txt"</pre>
367
+ </div>
368
+ <div class="endpoint">
369
+ <span class="method method-patch">PATCH</span><span class="path">/admin/api/accounts/{id}</span>
370
+ <div class="desc">Update account (toggle active status)</div>
371
+ <pre>curl -X PATCH https://your-domain/admin/api/accounts/123 \
372
+ -H "Authorization: Bearer ADMIN_TOKEN" \
373
+ -H "Content-Type: application/json" \
374
+ -d '{"is_active": true}'</pre>
375
+ </div>
376
+ <div class="endpoint">
377
+ <span class="method method-delete">DELETE</span><span class="path">/admin/api/accounts/{id}</span>
378
+ <div class="desc">Delete a specific account</div>
379
+ <pre>curl -X DELETE https://your-domain/admin/api/accounts/123 \
380
+ -H "Authorization: Bearer ADMIN_TOKEN"</pre>
381
+ </div>
382
+ <div class="endpoint">
383
+ <span class="method method-delete">DELETE</span><span class="path">/admin/api/accounts</span>
384
+ <div class="desc">Delete all accounts</div>
385
+ <pre>curl -X DELETE https://your-domain/admin/api/accounts \
386
+ -H "Authorization: Bearer ADMIN_TOKEN"</pre>
387
+ </div>
388
+ <div class="endpoint">
389
+ <span class="method method-get">GET</span><span class="path">/admin/api/accounts/{id}/password</span>
390
+ <div class="desc">Retrieve account password</div>
391
+ <pre>curl https://your-domain/admin/api/accounts/123/password \
392
+ -H "Authorization: Bearer ADMIN_TOKEN"</pre>
393
+ </div>
394
+ <div class="endpoint">
395
+ <span class="method method-get">GET</span><span class="path">/admin/api/export</span>
396
+ <div class="desc">Export all accounts as text file</div>
397
+ <pre>curl https://your-domain/admin/api/export \
398
+ -H "Authorization: Bearer ADMIN_TOKEN" -o accounts.txt</pre>
399
+ </div>
400
+ </div>
401
+ </section>
402
+ </main>
403
+ </div>
404
+ </div>
405
+
406
+ <script>
407
+ (function(){
408
+ 'use strict';
409
+
410
+ // ---- Helpers ----
411
+ function getCookie(n){const m=document.cookie.match(new RegExp('(?:^|; )'+n+'=([^;]*)'));return m?decodeURIComponent(m[1]):null}
412
+ function setCookie(n,v,d){let s=n+'='+encodeURIComponent(v)+';path=/;SameSite=Lax';if(d)s+=';max-age='+d*86400;document.cookie=s}
413
+ function delCookie(n){document.cookie=n+'=;path=/;max-age=0'}
414
+
415
+ function getToken(){return getCookie('admin_token')||''}
416
+
417
+ function toast(msg,type){
418
+ const c=document.getElementById('toast-container');
419
+ const d=document.createElement('div');
420
+ d.className='toast toast-'+(type||'success');
421
+ d.textContent=msg;
422
+ c.appendChild(d);
423
+ setTimeout(()=>d.remove(),4000);
424
+ }
425
+
426
+ async function api(path,opts={}){
427
+ const headers=opts.headers||{};
428
+ const tk=getToken();
429
+ if(tk)headers['Authorization']='Bearer '+tk;
430
+ if(opts.body&&typeof opts.body==='object'&&!(opts.body instanceof FormData)){
431
+ headers['Content-Type']='application/json';
432
+ opts.body=JSON.stringify(opts.body);
433
+ }
434
+ opts.headers=headers;
435
+ const r=await fetch(path,opts);
436
+ if(!r.ok){
437
+ let msg='Request failed ('+r.status+')';
438
+ try{const j=await r.json();msg=j.detail||j.message||msg}catch(e){}
439
+ throw new Error(msg);
440
+ }
441
+ const ct=r.headers.get('content-type')||'';
442
+ if(ct.includes('json'))return r.json();
443
+ return r;
444
+ }
445
+
446
+ // ---- State ----
447
+ let currentTab='dashboard';
448
+ let accountsPage=1;
449
+ let accountsTotal=0;
450
+ const PAGE_SIZE=20;
451
+
452
+ // ---- DOM refs ----
453
+ const $=id=>document.getElementById(id);
454
+
455
+ // ---- Auth check ----
456
+ function checkAuth(){
457
+ if(getToken()){
458
+ $('login-screen').style.display='none';
459
+ $('app').style.display='block';
460
+ loadTab(currentTab);
461
+ } else {
462
+ $('login-screen').style.display='flex';
463
+ $('app').style.display='none';
464
+ }
465
+ }
466
+
467
+ // ---- Login ----
468
+ $('login-form').addEventListener('submit',async e=>{
469
+ e.preventDefault();
470
+ const btn=$('login-btn');
471
+ btn.disabled=true;btn.textContent='Signing in...';
472
+ try{
473
+ const data=await api('/admin/api/login',{method:'POST',body:{password:$('login-pw').value}});
474
+ setCookie('admin_token',data.token||data.access_token,7);
475
+ toast('Signed in');
476
+ checkAuth();
477
+ }catch(err){toast(err.message,'error')}
478
+ finally{btn.disabled=false;btn.textContent='Sign in'}
479
+ });
480
+
481
+ // ---- Logout ----
482
+ $('logout-btn').addEventListener('click',()=>{delCookie('admin_token');checkAuth()});
483
+
484
+ // ---- Sidebar nav ----
485
+ document.querySelectorAll('.nav-item[data-tab]').forEach(btn=>{
486
+ btn.addEventListener('click',()=>{
487
+ document.querySelectorAll('.nav-item[data-tab]').forEach(b=>b.classList.remove('active'));
488
+ btn.classList.add('active');
489
+ currentTab=btn.dataset.tab;
490
+ loadTab(currentTab);
491
+ // close mobile sidebar
492
+ $('sidebar').classList.remove('open');
493
+ $('overlay').classList.remove('open');
494
+ });
495
+ });
496
+
497
+ // ---- Mobile menu ----
498
+ $('menu-toggle').addEventListener('click',()=>{$('sidebar').classList.toggle('open');$('overlay').classList.toggle('open')});
499
+ $('overlay').addEventListener('click',()=>{$('sidebar').classList.remove('open');$('overlay').classList.remove('open')});
500
+
501
+ // ---- Tab loading ----
502
+ function loadTab(tab){
503
+ document.querySelectorAll('.tab-content').forEach(s=>s.style.display='none');
504
+ const el=$('tab-'+tab);
505
+ if(el)el.style.display='block';
506
+ if(tab==='dashboard')loadDashboard();
507
+ if(tab==='accounts'){accountsPage=1;loadAccounts()}
508
+ }
509
+
510
+ // ---- Dashboard ----
511
+ async function loadDashboard(){
512
+ try{
513
+ const s=await api('/admin/api/stats');
514
+ $('stat-total').textContent=s.total??'--';
515
+ $('stat-active').textContent=s.active??'--';
516
+ $('stat-inactive').textContent=s.inactive??'--';
517
+ $('stat-recent').textContent=s.recent_7d??s.recent??'--';
518
+ }catch(err){toast(err.message,'error')}
519
+ }
520
+
521
+ $('export-btn').addEventListener('click',async()=>{
522
+ try{
523
+ const r=await api('/admin/api/export');
524
+ const blob=await r.blob();
525
+ const url=URL.createObjectURL(blob);
526
+ const a=document.createElement('a');
527
+ a.href=url;a.download='accounts_export.txt';a.click();
528
+ URL.revokeObjectURL(url);
529
+ toast('Export downloaded');
530
+ }catch(err){toast(err.message,'error')}
531
+ });
532
+
533
+ // ---- Accounts ----
534
+ let searchTimer=null;
535
+ $('search-input').addEventListener('input',()=>{clearTimeout(searchTimer);searchTimer=setTimeout(()=>{accountsPage=1;loadAccounts()},350)});
536
+ $('active-filter').addEventListener('change',()=>{accountsPage=1;loadAccounts()});
537
+ $('prev-btn').addEventListener('click',()=>{if(accountsPage>1){accountsPage--;loadAccounts()}});
538
+ $('next-btn').addEventListener('click',()=>{accountsPage++;loadAccounts()});
539
+
540
+ async function loadAccounts(){
541
+ const tbody=$('accounts-tbody');
542
+ tbody.innerHTML='<tr><td colspan="5" class="loading">Loading...</td></tr>';
543
+ try{
544
+ const search=$('search-input').value;
545
+ const active=$('active-filter').value;
546
+ let url='/admin/api/accounts?page='+accountsPage;
547
+ if(search)url+='&search='+encodeURIComponent(search);
548
+ if(active)url+='&active='+active;
549
+ const data=await api(url);
550
+ const accounts=data.accounts||data.items||data||[];
551
+ accountsTotal=data.total||accounts.length;
552
+ const totalPages=Math.max(1,Math.ceil(accountsTotal/PAGE_SIZE));
553
+ $('page-info').textContent='Page '+accountsPage+' of '+totalPages;
554
+ $('prev-btn').disabled=accountsPage<=1;
555
+ $('next-btn').disabled=accountsPage>=totalPages;
556
+ if(!accounts.length){
557
+ tbody.innerHTML='<tr><td colspan="5" class="empty">No accounts found</td></tr>';
558
+ return;
559
+ }
560
+ tbody.innerHTML=accounts.map(a=>`<tr>
561
+ <td>${esc(a.email||a.address||'')}</td>
562
+ <td><span class="badge ${a.is_active!==false?'badge-active':'badge-inactive'}">${a.is_active!==false?'Active':'Inactive'}</span></td>
563
+ <td>${esc(a.source||'--')}</td>
564
+ <td>${fmtDate(a.created_at||a.created||'')}</td>
565
+ <td class="actions">
566
+ <button class="btn btn-outline btn-sm" onclick="window._toggleAccount(${a.id},${!a.is_active})" aria-label="Toggle status">${a.is_active!==false?'Deactivate':'Activate'}</button>
567
+ <button class="btn btn-outline btn-sm" onclick="window._showPassword(${a.id})" aria-label="Show password">Password</button>
568
+ <button class="btn btn-danger btn-sm" onclick="window._deleteAccount(${a.id})" aria-label="Delete account">Delete</button>
569
+ </td>
570
+ </tr>`).join('');
571
+ }catch(err){
572
+ tbody.innerHTML='<tr><td colspan="5" class="empty">Error: '+esc(err.message)+'</td></tr>';
573
+ }
574
+ }
575
+
576
+ function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML}
577
+ function fmtDate(s){if(!s)return'--';try{return new Date(s).toLocaleDateString()}catch(e){return s}}
578
+
579
+ window._toggleAccount=async(id,active)=>{
580
+ try{await api('/admin/api/accounts/'+id,{method:'PATCH',body:{is_active:active}});toast('Account updated');loadAccounts()}
581
+ catch(err){toast(err.message,'error')}
582
+ };
583
+ window._showPassword=async(id)=>{
584
+ $('pw-display').textContent='Loading...';
585
+ $('pw-modal').classList.add('open');
586
+ try{const d=await api('/admin/api/accounts/'+id+'/password');$('pw-display').textContent=d.password||d.pw||JSON.stringify(d)}
587
+ catch(err){$('pw-display').textContent='Error: '+err.message}
588
+ };
589
+ window._deleteAccount=async(id)=>{
590
+ if(!confirm('Delete this account?'))return;
591
+ try{await api('/admin/api/accounts/'+id,{method:'DELETE'});toast('Account deleted');loadAccounts()}
592
+ catch(err){toast(err.message,'error')}
593
+ };
594
+
595
+ $('pw-close-btn').addEventListener('click',()=>$('pw-modal').classList.remove('open'));
596
+ $('pw-modal').addEventListener('click',e=>{if(e.target===$('pw-modal'))$('pw-modal').classList.remove('open')});
597
+
598
+ // ---- Add account ----
599
+ $('add-account-btn').addEventListener('click',()=>{$('add-email').value='';$('add-password').value='';$('add-modal').classList.add('open');$('add-email').focus()});
600
+ $('add-cancel-btn').addEventListener('click',()=>$('add-modal').classList.remove('open'));
601
+ $('add-modal').addEventListener('click',e=>{if(e.target===$('add-modal'))$('add-modal').classList.remove('open')});
602
+ $('add-confirm-btn').addEventListener('click',async()=>{
603
+ const email=$('add-email').value.trim();
604
+ const pw=$('add-password').value;
605
+ if(!email||!pw){toast('Fill in all fields','error');return}
606
+ const btn=$('add-confirm-btn');btn.disabled=true;btn.textContent='Adding...';
607
+ try{
608
+ await api('/admin/api/accounts',{method:'POST',body:{email,password:pw}});
609
+ toast('Account added');$('add-modal').classList.remove('open');loadAccounts();
610
+ }catch(err){toast(err.message,'error')}
611
+ finally{btn.disabled=false;btn.textContent='Add Account'}
612
+ });
613
+
614
+ // ---- Delete all ----
615
+ $('delete-all-btn').addEventListener('click',async()=>{
616
+ if(!confirm('Delete ALL accounts? This cannot be undone.'))return;
617
+ if(!confirm('Are you really sure?'))return;
618
+ try{await api('/admin/api/accounts',{method:'DELETE'});toast('All accounts deleted');loadAccounts()}
619
+ catch(err){toast(err.message,'error')}
620
+ });
621
+
622
+ // ---- Import: bulk text ----
623
+ $('bulk-submit-btn').addEventListener('click',async()=>{
624
+ const lines=$('bulk-text').value.trim().split('\n').map(l=>l.trim()).filter(Boolean);
625
+ if(!lines.length){toast('Enter at least one account','error');return}
626
+ const btn=$('bulk-submit-btn');btn.disabled=true;btn.textContent='Importing...';
627
+ try{
628
+ const d=await api('/admin/api/accounts/bulk',{method:'POST',body:{accounts:lines,source:'manual'}});
629
+ toast('Imported '+(d.imported||d.count||lines.length)+' accounts');
630
+ $('bulk-text').value='';
631
+ }catch(err){toast(err.message,'error')}
632
+ finally{btn.disabled=false;btn.textContent='Import Accounts'}
633
+ });
634
+
635
+ // ---- Import: file upload ----
636
+ $('file-submit-btn').addEventListener('click',async()=>{
637
+ const file=$('file-upload').files[0];
638
+ if(!file){toast('Select a file first','error');return}
639
+ const btn=$('file-submit-btn');btn.disabled=true;btn.textContent='Uploading...';
640
+ try{
641
+ const fd=new FormData();fd.append('file',file);
642
+ const d=await api('/admin/api/accounts/upload',{method:'POST',body:fd});
643
+ toast('Uploaded '+(d.imported||d.count||'')+ ' accounts');
644
+ $('file-upload').value='';
645
+ }catch(err){toast(err.message,'error')}
646
+ finally{btn.disabled=false;btn.textContent='Upload File'}
647
+ });
648
+
649
+ // ---- Keyboard: Escape closes modals ----
650
+ document.addEventListener('keydown',e=>{
651
+ if(e.key==='Escape'){
652
+ $('add-modal').classList.remove('open');
653
+ $('pw-modal').classList.remove('open');
654
+ }
655
+ });
656
+
657
+ // ---- Init ----
658
+ checkAuth();
659
+ })();
660
+ </script>
661
+
662
+ <!-- Add Account Modal -->
663
+ <div class="modal-overlay" id="add-modal" aria-modal="true" role="dialog" aria-label="Add account">
664
+ <div class="modal">
665
+ <h3>Add Account</h3>
666
+ <label for="add-email">Email</label>
667
+ <input id="add-email" type="email" placeholder="user@outlook.com" required>
668
+ <label for="add-password">Password</label>
669
+ <input id="add-password" type="text" placeholder="Password" required>
670
+ <div class="modal-actions">
671
+ <button class="btn btn-outline" id="add-cancel-btn">Cancel</button>
672
+ <button class="btn btn-primary" id="add-confirm-btn">Add Account</button>
673
+ </div>
674
+ </div>
675
+ </div>
676
+
677
+ <!-- Password Modal -->
678
+ <div class="modal-overlay" id="pw-modal" aria-modal="true" role="dialog" aria-label="Account password">
679
+ <div class="modal">
680
+ <h3>Account Password</h3>
681
+ <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>
682
+ <div class="modal-actions"><button class="btn btn-outline" id="pw-close-btn">Close</button></div>
683
+ </div>
684
+ </div>
685
+ </body>
686
+ </html>
outlook2api/static/index.html ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
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>
requirements-api.txt CHANGED
@@ -1,2 +1,8 @@
1
  fastapi>=0.109
2
  uvicorn[standard]>=0.27
 
 
 
 
 
 
 
1
  fastapi>=0.109
2
  uvicorn[standard]>=0.27
3
+ sqlalchemy[asyncio]>=2.0
4
+ aiosqlite>=0.19
5
+ asyncpg>=0.29
6
+ passlib[bcrypt]>=1.7
7
+ python-multipart>=0.0.6
8
+ jinja2>=3.1