ohmyapi Claude Opus 4.6 (1M context) commited on
Commit
b41ab48
·
1 Parent(s): 7c1d695

feat: comprehensive upgrade — dark mode, email improvements, Claude-style UI

Browse files

- Fix admin page click bug (duplicate display properties on mbDetail/mbCompose)
- Add dark/light mode with CSS variables and localStorage persistence
- Add IMAP folder support (inbox/junk/sent/drafts/deleted/archive)
- Add email message deletion via IMAP
- Improve verification code extraction (keyword-aware, 4-8 digit support)
- Add verification link extraction from emails
- Add attachment detection and read/unread status
- UID-based IMAP search with folder resolution and search parameter
- Redesign admin panel and homepage with warm amber theme (Inter font)
- Add folder selector in mailbox, delete button for messages
- New API endpoints: GET /folders, POST /messages/delete

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

.gitignore CHANGED
@@ -13,3 +13,4 @@ output/
13
  .staging_outlook/
14
  *.crx
15
  .claude/
 
 
13
  .staging_outlook/
14
  *.crx
15
  .claude/
16
+ refs/
outlook2api/admin_routes.py CHANGED
@@ -15,7 +15,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
15
 
16
  from outlook2api.config import get_config
17
  from outlook2api.database import Account, get_db, get_stats
18
- from outlook2api.outlook_imap import fetch_messages_imap
19
  from outlook2api.outlook_smtp import send_email
20
 
21
  admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
@@ -63,6 +63,11 @@ class SendEmailRequest(BaseModel):
63
  references: str = ""
64
 
65
 
 
 
 
 
 
66
  @admin_router.post("/login")
67
  async def admin_login(body: LoginRequest):
68
  cfg = get_config()
@@ -302,6 +307,8 @@ async def get_account_messages(
302
  request: Request,
303
  db: AsyncSession = Depends(get_db),
304
  limit: int = 30,
 
 
305
  ):
306
  """Fetch messages from an account's mailbox via IMAP."""
307
  _verify_admin(request)
@@ -310,11 +317,52 @@ async def get_account_messages(
310
  raise HTTPException(status_code=404, detail="Account not found")
311
  try:
312
  messages = await asyncio.to_thread(
313
- fetch_messages_imap, account.email, account.password, "INBOX", limit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  )
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")
 
15
 
16
  from outlook2api.config import get_config
17
  from outlook2api.database import Account, get_db, get_stats
18
+ from outlook2api.outlook_imap import fetch_messages_imap, list_folders, delete_messages_imap
19
  from outlook2api.outlook_smtp import send_email
20
 
21
  admin_router = APIRouter(prefix="/admin/api", tags=["admin"])
 
63
  references: str = ""
64
 
65
 
66
+ class DeleteMessagesRequest(BaseModel):
67
+ message_ids: list[str]
68
+ folder: str = "INBOX"
69
+
70
+
71
  @admin_router.post("/login")
72
  async def admin_login(body: LoginRequest):
73
  cfg = get_config()
 
307
  request: Request,
308
  db: AsyncSession = Depends(get_db),
309
  limit: int = 30,
310
+ folder: str = "INBOX",
311
+ search: str = "",
312
  ):
313
  """Fetch messages from an account's mailbox via IMAP."""
314
  _verify_admin(request)
 
317
  raise HTTPException(status_code=404, detail="Account not found")
318
  try:
319
  messages = await asyncio.to_thread(
320
+ fetch_messages_imap, account.email, account.password, folder, limit, search=search
321
+ )
322
+ except RuntimeError as e:
323
+ raise HTTPException(status_code=502, detail=str(e))
324
+ except Exception as e:
325
+ raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
326
+ return {"email": account.email, "messages": messages, "folder": folder}
327
+
328
+
329
+ @admin_router.get("/accounts/{account_id}/folders")
330
+ async def get_account_folders(
331
+ account_id: str,
332
+ request: Request,
333
+ db: AsyncSession = Depends(get_db),
334
+ ):
335
+ """List IMAP folders for an account."""
336
+ _verify_admin(request)
337
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
338
+ if not account:
339
+ raise HTTPException(status_code=404, detail="Account not found")
340
+ try:
341
+ folders = await asyncio.to_thread(list_folders, account.email, account.password)
342
+ except Exception as e:
343
+ raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
344
+ return {"email": account.email, "folders": folders}
345
+
346
+
347
+ @admin_router.post("/accounts/{account_id}/messages/delete")
348
+ async def delete_account_messages(
349
+ account_id: str,
350
+ body: DeleteMessagesRequest,
351
+ request: Request,
352
+ db: AsyncSession = Depends(get_db),
353
+ ):
354
+ """Delete messages from an account's mailbox."""
355
+ _verify_admin(request)
356
+ account = (await db.execute(select(Account).where(Account.id == account_id))).scalar_one_or_none()
357
+ if not account:
358
+ raise HTTPException(status_code=404, detail="Account not found")
359
+ try:
360
+ result = await asyncio.to_thread(
361
+ delete_messages_imap, account.email, account.password, body.message_ids, body.folder
362
  )
363
  except Exception as e:
364
  raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
365
+ return result
366
 
367
 
368
  @admin_router.post("/accounts/{account_id}/send")
outlook2api/outlook_imap.py CHANGED
@@ -8,6 +8,16 @@ import re
8
  from email.header import decode_header
9
  from typing import Optional
10
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  def _decode_subject(header_val: str) -> str:
13
  """Decode email subject from RFC 2047 encoding."""
@@ -23,18 +33,138 @@ def _decode_subject(header_val: str) -> str:
23
  return "".join(result)
24
 
25
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  def _extract_verification_code(text: str, html: str = "") -> str:
27
- """Extract 6-digit OTP or XXX-XXX format from email body."""
 
 
 
28
  content = f"{text}\n{html}"
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  m = re.search(r"\b(\d{6})\b", content)
30
  if m:
31
  return m.group(1)
 
32
  m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", content)
33
  if m:
34
  return m.group(1)
35
  return ""
36
 
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  def fetch_messages_imap(
39
  email_addr: str,
40
  password: str,
@@ -42,48 +172,83 @@ def fetch_messages_imap(
42
  limit: int = 20,
43
  host: str = "outlook.office365.com",
44
  port: int = 993,
 
45
  ) -> list[dict]:
46
  """Connect via IMAP and fetch recent messages.
47
 
48
- Returns list of dicts with keys: id, from, subject, intro, text, html, verification_code.
 
49
  """
50
  messages = []
51
  try:
52
- mail = imaplib.IMAP4_SSL(host, port)
53
  mail.login(email_addr, password)
54
- mail.select(folder)
55
- _, data = mail.search(None, "ALL")
56
- ids = data[0].split()
57
- ids = ids[-limit:] if len(ids) > limit else ids
58
 
59
- for i, msg_id in enumerate(reversed(ids)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  try:
61
- _, msg_data = mail.fetch(msg_id, "(RFC822)")
62
- if not msg_data:
63
  continue
 
64
  raw = msg_data[0][1]
 
 
 
 
 
 
 
 
65
  if isinstance(raw, bytes):
66
  msg = email.message_from_bytes(raw)
67
  else:
68
  msg = email.message_from_string(raw.decode("utf-8", errors="replace"))
69
 
70
  subject = _decode_subject(msg.get("Subject", ""))
71
- from_addr = msg.get("From", "")
 
 
 
 
 
 
 
72
 
73
  text = ""
74
  html = ""
 
 
75
  if msg.is_multipart():
76
  for part in msg.walk():
 
 
 
77
  ct = part.get_content_type()
78
  payload = part.get_payload(decode=True)
79
  if payload is None:
80
  continue
81
  charset = part.get_content_charset() or "utf-8"
82
  decoded = payload.decode(charset, errors="replace")
83
- if ct == "text/plain":
84
- text += decoded
85
- elif ct == "text/html":
86
- html += decoded
87
  else:
88
  payload = msg.get_payload(decode=True)
89
  if payload:
@@ -94,23 +259,43 @@ def fetch_messages_imap(
94
  else:
95
  text = decoded
96
 
97
- intro = (text or html)[:200].replace("\n", " ")
 
 
 
 
 
 
 
98
  verification_code = _extract_verification_code(text, html)
 
 
99
 
100
  messages.append({
101
- "id": msg_id.decode() if isinstance(msg_id, bytes) else str(msg_id),
102
- "from": {"address": from_addr, "name": ""},
 
103
  "subject": subject,
104
  "intro": intro,
105
  "text": text,
106
- "html": [html],
 
 
 
107
  "verification_code": verification_code,
 
 
108
  })
109
  except Exception:
110
  continue
111
 
112
  mail.logout()
113
- except Exception:
 
 
 
 
 
114
  pass
115
  return messages
116
 
@@ -123,7 +308,7 @@ def validate_login(
123
  ) -> bool:
124
  """Verify that email+password can login to Outlook IMAP."""
125
  try:
126
- mail = imaplib.IMAP4_SSL(host, port)
127
  mail.login(email_addr, password)
128
  mail.logout()
129
  return True
 
8
  from email.header import decode_header
9
  from typing import Optional
10
 
11
+ # Folder name mappings for Outlook
12
+ OUTLOOK_FOLDERS = {
13
+ "inbox": ["INBOX"],
14
+ "junk": ["Junk", "Junk Email", "JUNK"],
15
+ "sent": ["Sent", "Sent Items", "SENT"],
16
+ "drafts": ["Drafts", "DRAFTS"],
17
+ "deleted": ["Deleted", "Deleted Items", "Trash", "TRASH"],
18
+ "archive": ["Archive", "ARCHIVE"],
19
+ }
20
+
21
 
22
  def _decode_subject(header_val: str) -> str:
23
  """Decode email subject from RFC 2047 encoding."""
 
33
  return "".join(result)
34
 
35
 
36
+ def _strip_html(html: str) -> str:
37
+ """Strip HTML tags for plain text preview."""
38
+ text = re.sub(r"<(script|style)[^>]*>.*?</\1>", "", html, flags=re.DOTALL | re.IGNORECASE)
39
+ text = re.sub(r"<[^>]+>", " ", text)
40
+ text = re.sub(r"\s+", " ", text).strip()
41
+ return text
42
+
43
+
44
+ def _has_attachment(part) -> bool:
45
+ """Check if a MIME part is an attachment."""
46
+ try:
47
+ cd = part.get("Content-Disposition", "")
48
+ return cd and ("attachment" in cd.lower() or "inline" in cd.lower())
49
+ except Exception:
50
+ return False
51
+
52
+
53
  def _extract_verification_code(text: str, html: str = "") -> str:
54
+ """Extract verification code from email body.
55
+
56
+ Supports: 4-8 digit OTP, XXX-XXX format, and keyword-based extraction.
57
+ """
58
  content = f"{text}\n{html}"
59
+
60
+ # Look for codes near verification keywords
61
+ keywords = r"(?:验证码|verification|verify|code|OTP|confirm|pin|安全码|授权码)"
62
+ # Pattern: keyword followed by a code within ~50 chars
63
+ m = re.search(keywords + r".{0,50}?\b(\d{4,8})\b", content, re.IGNORECASE)
64
+ if m:
65
+ return m.group(1)
66
+ # Pattern: code followed by keyword
67
+ m = re.search(r"\b(\d{4,8})\b.{0,30}?" + keywords, content, re.IGNORECASE)
68
+ if m:
69
+ return m.group(1)
70
+
71
+ # Fallback: standalone 6-digit code
72
  m = re.search(r"\b(\d{6})\b", content)
73
  if m:
74
  return m.group(1)
75
+ # Dash-separated format
76
  m = re.search(r"\b([A-Z0-9]{3}-[A-Z0-9]{3})\b", content)
77
  if m:
78
  return m.group(1)
79
  return ""
80
 
81
 
82
+ def _extract_verification_link(text: str, html: str = "") -> str:
83
+ """Extract verification/confirmation link from email body."""
84
+ content = f"{text}\n{html}"
85
+ link_keywords = r"(?:verify|confirm|activate|validate|reset|unsubscribe)"
86
+ urls = re.findall(r'https?://[^\s<>"\']+', content)
87
+ for url in urls:
88
+ if re.search(link_keywords, url, re.IGNORECASE):
89
+ return url
90
+ return ""
91
+
92
+
93
+ def _resolve_folder(mail: imaplib.IMAP4_SSL, folder_key: str) -> str:
94
+ """Resolve a folder key to an actual IMAP folder name."""
95
+ candidates = OUTLOOK_FOLDERS.get(folder_key.lower(), [folder_key])
96
+ # Try each candidate
97
+ for name in candidates:
98
+ try:
99
+ status, _ = mail.select(name, readonly=True)
100
+ if status == "OK":
101
+ return name
102
+ except Exception:
103
+ continue
104
+ # Fallback: try the key itself
105
+ return candidates[0] if candidates else folder_key
106
+
107
+
108
+ def list_folders(
109
+ email_addr: str,
110
+ password: str,
111
+ host: str = "outlook.office365.com",
112
+ port: int = 993,
113
+ ) -> list[dict]:
114
+ """List available IMAP folders for an account."""
115
+ folders = []
116
+ try:
117
+ mail = imaplib.IMAP4_SSL(host, port, timeout=30)
118
+ mail.login(email_addr, password)
119
+ status, folder_data = mail.list()
120
+ if status == "OK":
121
+ for item in folder_data:
122
+ if isinstance(item, bytes):
123
+ decoded = item.decode("utf-8", errors="replace")
124
+ # Parse IMAP LIST response: (\\flags) "delimiter" "name"
125
+ m = re.match(r'\(([^)]*)\)\s+"([^"]+)"\s+"?([^"]+)"?', decoded)
126
+ if m:
127
+ flags, delimiter, name = m.groups()
128
+ name = name.strip('"')
129
+ folders.append({
130
+ "name": name,
131
+ "flags": flags,
132
+ "delimiter": delimiter,
133
+ })
134
+ mail.logout()
135
+ except Exception:
136
+ pass
137
+ return folders
138
+
139
+
140
+ def delete_messages_imap(
141
+ email_addr: str,
142
+ password: str,
143
+ message_ids: list[str],
144
+ folder: str = "INBOX",
145
+ host: str = "outlook.office365.com",
146
+ port: int = 993,
147
+ ) -> dict:
148
+ """Delete messages by IMAP ID. Returns count of deleted messages."""
149
+ deleted = 0
150
+ errors = []
151
+ try:
152
+ mail = imaplib.IMAP4_SSL(host, port, timeout=30)
153
+ mail.login(email_addr, password)
154
+ mail.select(folder)
155
+ for msg_id in message_ids:
156
+ try:
157
+ mail.store(msg_id.encode() if isinstance(msg_id, str) else msg_id, "+FLAGS", "\\Deleted")
158
+ deleted += 1
159
+ except Exception as e:
160
+ errors.append(f"Failed to delete {msg_id}: {e}")
161
+ mail.expunge()
162
+ mail.logout()
163
+ except Exception as e:
164
+ errors.append(f"IMAP error: {e}")
165
+ return {"deleted": deleted, "errors": errors}
166
+
167
+
168
  def fetch_messages_imap(
169
  email_addr: str,
170
  password: str,
 
172
  limit: int = 20,
173
  host: str = "outlook.office365.com",
174
  port: int = 993,
175
+ search: str = "",
176
  ) -> list[dict]:
177
  """Connect via IMAP and fetch recent messages.
178
 
179
+ Returns list of dicts with keys: id, from, subject, intro, text, html,
180
+ verification_code, verification_link, has_attachments, date, folder.
181
  """
182
  messages = []
183
  try:
184
+ mail = imaplib.IMAP4_SSL(host, port, timeout=30)
185
  mail.login(email_addr, password)
 
 
 
 
186
 
187
+ # Resolve folder name
188
+ actual_folder = _resolve_folder(mail, folder) if folder != "INBOX" else "INBOX"
189
+ status, _ = mail.select(actual_folder, readonly=True)
190
+ if status != "OK":
191
+ mail.select("INBOX", readonly=True)
192
+ actual_folder = "INBOX"
193
+
194
+ # Build search criteria
195
+ search_criteria = "ALL"
196
+ if search:
197
+ search_criteria = f'(OR SUBJECT "{search}" FROM "{search}")'
198
+
199
+ _, data = mail.uid("SEARCH", None, search_criteria)
200
+ uids = data[0].split()
201
+ uids = uids[-limit:] if len(uids) > limit else uids
202
+
203
+ for uid in reversed(uids):
204
  try:
205
+ _, msg_data = mail.uid("FETCH", uid, "(RFC822 FLAGS)")
206
+ if not msg_data or not msg_data[0]:
207
  continue
208
+
209
  raw = msg_data[0][1]
210
+ # Extract flags
211
+ flags_str = ""
212
+ if len(msg_data) > 1 and msg_data[1]:
213
+ flags_match = re.search(r"FLAGS \(([^)]*)\)",
214
+ msg_data[1].decode() if isinstance(msg_data[1], bytes) else str(msg_data[1]))
215
+ if flags_match:
216
+ flags_str = flags_match.group(1)
217
+
218
  if isinstance(raw, bytes):
219
  msg = email.message_from_bytes(raw)
220
  else:
221
  msg = email.message_from_string(raw.decode("utf-8", errors="replace"))
222
 
223
  subject = _decode_subject(msg.get("Subject", ""))
224
+ from_raw = msg.get("From", "")
225
+ date_str = msg.get("Date", "")
226
+ msg_id_header = msg.get("Message-ID", "")
227
+
228
+ # Parse from address
229
+ from_match = re.match(r'"?([^"<]*)"?\s*<?([^>]*)>?', from_raw)
230
+ from_name = from_match.group(1).strip() if from_match else ""
231
+ from_addr = from_match.group(2).strip() if from_match else from_raw
232
 
233
  text = ""
234
  html = ""
235
+ has_attachments = False
236
+
237
  if msg.is_multipart():
238
  for part in msg.walk():
239
+ if _has_attachment(part):
240
+ has_attachments = True
241
+ continue
242
  ct = part.get_content_type()
243
  payload = part.get_payload(decode=True)
244
  if payload is None:
245
  continue
246
  charset = part.get_content_charset() or "utf-8"
247
  decoded = payload.decode(charset, errors="replace")
248
+ if ct == "text/plain" and not text:
249
+ text = decoded
250
+ elif ct == "text/html" and not html:
251
+ html = decoded
252
  else:
253
  payload = msg.get_payload(decode=True)
254
  if payload:
 
259
  else:
260
  text = decoded
261
 
262
+ # Generate preview
263
+ if text:
264
+ intro = text[:200].replace("\n", " ").strip()
265
+ elif html:
266
+ intro = _strip_html(html)[:200]
267
+ else:
268
+ intro = ""
269
+
270
  verification_code = _extract_verification_code(text, html)
271
+ verification_link = _extract_verification_link(text, html)
272
+ is_read = "\\Seen" in flags_str
273
 
274
  messages.append({
275
+ "id": uid.decode() if isinstance(uid, bytes) else str(uid),
276
+ "message_id": msg_id_header,
277
+ "from": {"address": from_addr, "name": from_name},
278
  "subject": subject,
279
  "intro": intro,
280
  "text": text,
281
+ "html": [html] if html else [],
282
+ "date": date_str,
283
+ "is_read": is_read,
284
+ "has_attachments": has_attachments,
285
  "verification_code": verification_code,
286
+ "verification_link": verification_link,
287
+ "folder": actual_folder,
288
  })
289
  except Exception:
290
  continue
291
 
292
  mail.logout()
293
+ except imaplib.IMAP4.error as e:
294
+ raise RuntimeError(f"IMAP authentication failed: {e}") from e
295
+ except Exception as e:
296
+ if "LOGIN" in str(e).upper() or "AUTH" in str(e).upper():
297
+ raise RuntimeError(f"IMAP authentication failed: {e}") from e
298
+ # For other errors, return whatever we collected
299
  pass
300
  return messages
301
 
 
308
  ) -> bool:
309
  """Verify that email+password can login to Outlook IMAP."""
310
  try:
311
+ mail = imaplib.IMAP4_SSL(host, port, timeout=30)
312
  mail.login(email_addr, password)
313
  mail.logout()
314
  return True
outlook2api/static/admin.html CHANGED
@@ -5,89 +5,116 @@
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:#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}
@@ -95,61 +122,75 @@ tr:hover td{background:rgba(0,0,0,.015)}
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>
@@ -159,11 +200,11 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -175,14 +216,19 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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
 
@@ -205,8 +251,8 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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">
@@ -231,13 +277,13 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -256,17 +302,26 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -274,30 +329,38 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -331,7 +394,7 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -347,7 +410,7 @@ pre{background:var(--bg);border:1px solid var(--border);border-radius:6px;paddin
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>
@@ -366,7 +429,6 @@ 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
 
@@ -399,9 +461,29 @@ function api(path,opts){
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(){
@@ -420,6 +502,11 @@ $('loginForm').onsubmit=function(e){
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){
@@ -428,7 +515,6 @@ navBtns.forEach(function(b){
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
  };
@@ -492,10 +578,10 @@ function loadAccts(){
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>'
@@ -508,7 +594,6 @@ function loadAccts(){
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}})
@@ -527,11 +612,9 @@ W._del=function(id){
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')};
@@ -545,7 +628,6 @@ $('addOk').onclick=function(){
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;
@@ -557,22 +639,22 @@ $('delAllBtn').onclick=function(){
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 ========== */
@@ -587,6 +669,11 @@ $('mbSearch').oninput=function(){
587
  mbSTimer=setTimeout(filterMbAccts,250);
588
  };
589
 
 
 
 
 
 
590
  function filterMbAccts(){
591
  var q=$('mbSearch').value.toLowerCase();
592
  var items=$('mbAcctList').querySelectorAll('.mb-item');
@@ -601,11 +688,12 @@ function loadMbAccts(){
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
  });
@@ -621,11 +709,11 @@ function populateCFrom(){
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
 
@@ -634,18 +722,21 @@ function loadMbMsgs(){
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){
@@ -658,32 +749,45 @@ function loadMbMsgs(){
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');
@@ -692,10 +796,9 @@ $('mbReplyBtn').onclick=function(){
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;
@@ -707,22 +810,20 @@ $('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')});
@@ -798,10 +899,18 @@ admin:[
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"})',
@@ -828,7 +937,7 @@ function renderDocGrp(cid,eps,pfx){
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>'
 
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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
  <style>
10
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
+
12
+ /* ====== Theme System ====== */
13
  :root{
14
+ --bg:#f5f5f4;--bg2:#fafaf9;--surface:#fff;--surface2:#f5f5f4;
15
+ --border:#e7e5e4;--border2:#d6d3d1;
16
+ --text:#1c1917;--text2:#57534e;--text3:#a8a29e;
17
+ --brand:#d97706;--brand-h:#b45309;--brand-bg:rgba(217,119,6,.06);--brand-text:#92400e;
18
+ --ok:#059669;--ok-bg:rgba(5,150,105,.06);--ok-text:#065f46;
19
+ --err:#dc2626;--err-bg:rgba(220,38,38,.06);--err-text:#991b1b;
20
+ --info:#2563eb;--info-bg:rgba(37,99,235,.06);
21
+ --r:10px;--r2:14px;
22
+ --shadow:0 1px 2px rgba(0,0,0,.04);--shadow2:0 4px 16px rgba(0,0,0,.06);--shadow3:0 8px 32px rgba(0,0,0,.08);
23
+ --font:'Inter',system-ui,-apple-system,sans-serif;
24
+ --transition:all .15s ease;
25
+ }
26
+
27
+ [data-theme="dark"]{
28
+ --bg:#0c0a09;--bg2:#1c1917;--surface:#1c1917;--surface2:#292524;
29
+ --border:#292524;--border2:#44403c;
30
+ --text:#fafaf9;--text2:#a8a29e;--text3:#78716c;
31
+ --brand:#f59e0b;--brand-h:#fbbf24;--brand-bg:rgba(245,158,11,.1);--brand-text:#fbbf24;
32
+ --ok:#34d399;--ok-bg:rgba(52,211,153,.1);--ok-text:#6ee7b7;
33
+ --err:#f87171;--err-bg:rgba(248,113,113,.1);--err-text:#fca5a5;
34
+ --info:#60a5fa;--info-bg:rgba(96,165,250,.1);
35
+ --shadow:0 1px 2px rgba(0,0,0,.2);--shadow2:0 4px 16px rgba(0,0,0,.3);--shadow3:0 8px 32px rgba(0,0,0,.4);
36
  }
37
+
38
+ html{font-family:var(--font);font-size:15px;color:var(--text);background:var(--bg);transition:background .2s,color .2s}
39
  body{min-height:100vh}
40
  a{color:var(--brand);text-decoration:none}
41
+ input,textarea,select{font-family:inherit;font-size:.88rem;border:1px solid var(--border);border-radius:var(--r);padding:.55rem .85rem;background:var(--surface);color:var(--text);outline:none;transition:var(--transition)}
42
+ input:focus,textarea:focus,select:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-bg)}
43
  textarea{resize:vertical}
44
+ button{font-family:inherit;cursor:pointer;border:none;background:none;font-size:.88rem}
45
 
46
  /* ---- Buttons ---- */
47
+ .btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1.1rem;border-radius:var(--r);font-weight:500;transition:var(--transition);font-size:.84rem;white-space:nowrap}
48
+ .btn-p{background:var(--brand);color:#fff}.btn-p:hover{background:var(--brand-h);box-shadow:var(--shadow)}
49
+ .btn-o{border:1px solid var(--border);color:var(--text2);background:var(--surface)}.btn-o:hover{border-color:var(--brand);color:var(--brand);background:var(--brand-bg)}
50
+ .btn-d{background:var(--err-bg);color:var(--err)}.btn-d:hover{background:var(--err);color:#fff}
51
+ .btn-s{padding:.3rem .65rem;font-size:.78rem}
52
+ .btn:disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
53
+ .btn svg{width:15px;height:15px;flex-shrink:0}
54
 
55
  /* ---- Toast ---- */
56
+ #toast-box{position:fixed;top:1.2rem;right:1.2rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;pointer-events:none}
57
+ .toast{padding:.7rem 1.1rem;border-radius:var(--r);background:var(--surface);border:1px solid var(--border);box-shadow:var(--shadow2);font-size:.84rem;animation:tIn .3s ease;max-width:360px;border-left:3px solid var(--ok);pointer-events:auto}
58
  .toast-err{border-left-color:var(--err)}
59
+ @keyframes tIn{from{opacity:0;transform:translateY(-10px) scale(.97)}to{opacity:1;transform:translateY(0) scale(1)}}
60
 
61
  /* ---- Login ---- */
62
+ #login{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:1.5rem}
63
+ .login-box{background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);padding:2.5rem;width:100%;max-width:380px;box-shadow:var(--shadow3)}
64
+ .login-box h1{font-size:1.5rem;margin-bottom:.25rem;display:flex;align-items:center;gap:.5rem}
65
+ .login-box h1 svg{width:28px;height:28px;color:var(--brand)}
66
+ .login-box p{color:var(--text2);font-size:.88rem;margin-bottom:1.8rem}
67
+ .login-box label{display:block;font-weight:500;margin-bottom:.4rem;font-size:.84rem;color:var(--text2)}
68
+ .login-box input{width:100%;margin-bottom:1.2rem}
69
+ .login-box .btn{width:100%;justify-content:center;padding:.65rem}
70
 
71
  /* ---- Layout ---- */
72
  #app{display:none;min-height:100vh}
73
  .lay{display:flex;min-height:100vh}
74
+ .side{width:240px;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}
75
+ .side-brand{padding:0 1.4rem;font-size:1.15rem;font-weight:700;color:var(--brand);border-bottom:1px solid var(--border);margin-bottom:.6rem;padding-bottom:1.1rem;display:flex;align-items:center;gap:.5rem}
76
+ .side-brand svg{width:22px;height:22px}
77
+ .side-brand span{color:var(--text3);font-weight:400;font-size:.76rem;display:block;margin-top:.1rem}
78
+ .nav{display:flex;align-items:center;gap:.65rem;padding:.6rem 1.4rem;color:var(--text2);font-weight:500;font-size:.88rem;transition:var(--transition);cursor:pointer;border:none;width:100%;text-align:left;background:none;border-radius:0}
79
  .nav:hover{color:var(--text);background:var(--brand-bg)}
80
+ .nav.on{color:var(--brand);background:var(--brand-bg);font-weight:600}
81
  .nav svg{width:18px;height:18px;flex-shrink:0}
82
+ .side-foot{margin-top:auto;padding-top:.5rem;border-top:1px solid var(--border);display:flex;flex-direction:column}
83
+ .main{margin-left:240px;flex:1;padding:2rem 2.5rem;max-width:1000px;width:100%}
84
+ .main h2{font-size:1.35rem;margin-bottom:1.2rem;font-weight:700;letter-spacing:-.01em}
85
 
86
  /* ---- Mobile ---- */
87
  .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}
88
+ .ov{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:90;backdrop-filter:blur(2px)}
89
  @media(max-width:768px){
90
  .side{transform:translateX(-100%)}
91
  .side.open{transform:translateX(0)}
92
  .ov.open{display:block}
93
  .menu-btn{display:block}
94
  .main{margin-left:0;padding:1rem;padding-top:3.5rem}
95
+ .mb-wrap{flex-direction:column;height:auto!important}
96
+ .mb-col1,.mb-col2{width:100%!important;height:200px;border-right:none!important;border-bottom:1px solid var(--border)}
97
+ .mb-col3{height:400px}
98
  }
99
 
100
  /* ---- Cards/Stats ---- */
101
+ .stat-g{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:1rem;margin-bottom:1.5rem}
102
+ .stat-c{background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);padding:1.3rem 1.5rem;box-shadow:var(--shadow);transition:var(--transition)}
103
+ .stat-c:hover{box-shadow:var(--shadow2);transform:translateY(-1px)}
104
+ .stat-c .l{font-size:.72rem;color:var(--text3);text-transform:uppercase;letter-spacing:.06em;font-weight:600}
105
+ .stat-c .v{font-size:2rem;font-weight:700;margin-top:.35rem;letter-spacing:-.02em}
106
+ .stat-c:nth-child(2) .v{color:var(--ok)}
107
+ .stat-c:nth-child(3) .v{color:var(--err)}
108
+ .stat-c:nth-child(4) .v{color:var(--brand)}
109
 
110
  /* ---- Table ---- */
111
+ .tbl-w{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);box-shadow:var(--shadow)}
112
+ table{width:100%;border-collapse:collapse;font-size:.86rem}
113
+ th{text-align:left;padding:.7rem 1rem;font-weight:600;color:var(--text3);font-size:.73rem;text-transform:uppercase;letter-spacing:.04em;border-bottom:1px solid var(--border);background:var(--bg2)}
114
+ td{padding:.55rem 1rem;border-bottom:1px solid var(--border)}
115
  tr:last-child td{border-bottom:none}
116
+ tr:hover td{background:var(--brand-bg)}
117
+ .acts{display:flex;gap:.35rem;flex-wrap:wrap}
118
 
119
  /* ---- Toolbar ---- */
120
  .bar{display:flex;gap:.6rem;flex-wrap:wrap;margin-bottom:1rem;align-items:center}
 
122
  .bar select{min-width:120px}
123
 
124
  /* ---- Pagination ---- */
125
+ .pag{display:flex;align-items:center;gap:.8rem;justify-content:center;margin-top:1rem;font-size:.86rem;color:var(--text2)}
126
 
127
  /* ---- Modal ---- */
128
+ .modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:500;align-items:center;justify-content:center;padding:1rem;backdrop-filter:blur(4px)}
129
+ .modal{background:var(--surface);border-radius:var(--r2);padding:2rem;width:100%;max-width:440px;box-shadow:var(--shadow3);border:1px solid var(--border)}
130
+ .modal h3{margin-bottom:1.2rem;font-size:1.15rem}
131
+ .modal label{display:block;font-weight:500;margin-bottom:.35rem;font-size:.84rem;margin-top:.8rem;color:var(--text2)}
132
  .modal label:first-of-type{margin-top:0}
133
  .modal input{width:100%}
134
+ .modal-foot{display:flex;gap:.5rem;justify-content:flex-end;margin-top:1.5rem}
135
 
136
  /* ---- Import ---- */
137
+ .imp{background:var(--surface);border:1px solid var(--border);border-radius:var(--r2);padding:1.5rem;margin-bottom:1.2rem;box-shadow:var(--shadow)}
138
+ .imp h3{font-size:1rem;margin-bottom:.5rem}
139
+ .imp p{color:var(--text2);font-size:.84rem;margin-bottom:.8rem}
140
  .imp textarea{width:100%;min-height:120px;margin-bottom:.8rem}
141
+ .imp code{background:var(--surface2);padding:.15rem .45rem;border-radius:5px;font-size:.8rem}
142
 
143
  /* ---- Badges ---- */
144
+ .badge{display:inline-flex;align-items:center;gap:.25rem;padding:.15rem .55rem;border-radius:99px;font-size:.72rem;font-weight:600}
145
  .b-ok{background:var(--ok-bg);color:var(--ok)}
146
  .b-err{background:var(--err-bg);color:var(--err)}
147
+ .b-info{background:var(--info-bg);color:var(--info)}
148
+ .meth{display:inline-block;padding:.12rem .45rem;border-radius:5px;font-size:.68rem;font-weight:700;font-family:monospace;min-width:48px;text-align:center}
149
+ .m-get{background:var(--ok-bg);color:var(--ok)}
150
+ .m-post{background:var(--info-bg);color:var(--info)}
151
+ .m-patch{background:var(--brand-bg);color:var(--brand)}
152
+ .m-del{background:var(--err-bg);color:var(--err)}
153
 
154
  /* ---- Mailbox 3-col ---- */
155
+ .mb-wrap{display:flex;height:calc(100vh - 10px);border:1px solid var(--border);border-radius:var(--r2);overflow:hidden;background:var(--surface)}
156
  .mb-col1{width:240px;border-right:1px solid var(--border);background:var(--surface);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
157
  .mb-col2{width:300px;border-right:1px solid var(--border);background:var(--surface);display:flex;flex-direction:column;flex-shrink:0;overflow:hidden}
158
+ .mb-col3{flex:1;background:var(--bg2);display:flex;flex-direction:column;min-width:0;overflow:hidden}
159
+ .mb-hdr{padding:.75rem 1rem;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between;gap:.5rem;flex-shrink:0;background:var(--surface)}
160
  .mb-list{flex:1;overflow-y:auto}
161
+ .mb-item{padding:.7rem .85rem;border-bottom:1px solid var(--border);cursor:pointer;transition:var(--transition);font-size:.82rem}
162
  .mb-item:hover{background:var(--brand-bg)}
163
+ .mb-item.on{background:var(--brand-bg);border-left:3px solid var(--brand)}
164
+ .mb-item.unread{font-weight:600}
165
  .mb-empty{text-align:center;padding:2rem;color:var(--text3);font-size:.84rem}
166
+ .mb-folder-sel{font-size:.78rem;padding:.3rem .5rem;border-radius:6px;background:var(--surface2);border:1px solid var(--border);color:var(--text);cursor:pointer}
167
 
168
  /* ---- Docs ---- */
169
+ .doc-card{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);margin-bottom:.5rem;overflow:hidden;box-shadow:var(--shadow)}
170
+ .doc-head{padding:.6rem 1rem;cursor:pointer;display:flex;align-items:center;gap:.7rem;transition:var(--transition)}
171
+ .doc-head:hover{background:var(--bg2)}
172
  .doc-body{border-top:1px solid var(--border);padding:1rem;display:none}
173
  .doc-desc{color:var(--text2);font-size:.82rem;margin-bottom:.6rem}
174
  .doc-tabs{display:flex;gap:.3rem;margin-bottom:.5rem}
175
+ .doc-tab{padding:.25rem .6rem;border-radius:6px;font-size:.78rem;font-weight:500;color:var(--text3);cursor:pointer;transition:var(--transition)}
176
+ .doc-tab.on{background:var(--brand-bg);color:var(--brand)}
177
+ .doc-pre{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:.75rem;font-size:.76rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-family:'JetBrains Mono',monospace;color:var(--text)}
178
+
179
+ /* ---- Theme toggle ---- */
180
+ .theme-btn{display:flex;align-items:center;justify-content:center;width:36px;height:36px;border-radius:var(--r);border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;transition:var(--transition);flex-shrink:0}
181
+ .theme-btn:hover{border-color:var(--brand);color:var(--brand);background:var(--brand-bg)}
182
+ .theme-btn svg{width:18px;height:18px}
183
 
184
  /* ---- Misc ---- */
185
  .loading{text-align:center;padding:2rem;color:var(--text3)}
186
+ .empty{text-align:center;padding:2rem;color:var(--text3);font-size:.88rem}
187
+ pre{background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.6rem;font-size:.78rem;overflow-x:auto;white-space:pre-wrap;word-break:break-all;font-family:'JetBrains Mono',monospace;color:var(--text)}
188
+
189
+ /* ---- Scrollbar ---- */
190
+ ::-webkit-scrollbar{width:6px;height:6px}
191
+ ::-webkit-scrollbar-track{background:transparent}
192
+ ::-webkit-scrollbar-thumb{background:var(--border2);border-radius:3px}
193
+ ::-webkit-scrollbar-thumb:hover{background:var(--text3)}
194
  </style>
195
  </head>
196
  <body>
 
200
  <!-- ===== LOGIN ===== -->
201
  <div id="login">
202
  <div class="login-box">
203
+ <h1><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>Outlook2API</h1>
204
  <p>Sign in to the admin panel</p>
205
  <form id="loginForm">
206
  <label for="loginPw">Password</label>
207
+ <input id="loginPw" type="password" placeholder="Enter admin password" required>
208
  <button type="submit" class="btn btn-p" id="loginBtn">Sign in</button>
209
  </form>
210
  </div>
 
216
  <div class="ov" id="ov"></div>
217
  <div class="lay">
218
  <nav class="side" id="side">
219
+ <div class="side-brand"><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>Outlook2API<span>Admin Panel</span></div></div>
220
  <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>
221
  <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>
222
  <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>
223
  <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>
224
  <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>
225
  <div class="side-foot">
226
+ <div style="display:flex;align-items:center;justify-content:space-between;padding:.4rem 1.4rem;gap:.5rem">
227
+ <button class="theme-btn" id="themeBtn" title="Toggle theme">
228
+ <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
229
+ </button>
230
+ <button class="btn btn-o btn-s" id="logoutBtn" style="flex:1"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><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>
231
+ </div>
232
  </div>
233
  </nav>
234
 
 
251
  <div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:.5rem;margin-bottom:1.2rem">
252
  <h2 style="margin-bottom:0">Accounts</h2>
253
  <div style="display:flex;gap:.5rem">
254
+ <button class="btn btn-p btn-s" id="addBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Add</button>
255
+ <button class="btn btn-d btn-s" id="delAllBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg> Delete All</button>
256
  </div>
257
  </div>
258
  <div class="bar">
 
277
  <h3>Text Import</h3>
278
  <p>Enter accounts, one per line in <code>email:password</code> format.</p>
279
  <textarea id="bulkText" placeholder="user1@outlook.com:password1&#10;user2@outlook.com:password2"></textarea>
280
+ <button class="btn btn-p" id="bulkBtn"><svg width="14" height="14" 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 Accounts</button>
281
  </div>
282
  <div class="imp">
283
  <h3>File Upload</h3>
284
  <p>Upload a <code>.txt</code> file with one <code>email:password</code> per line.</p>
285
  <input type="file" id="fileIn" accept=".txt" style="margin-bottom:.6rem">
286
+ <div><button class="btn btn-p" id="fileBtn"><svg width="14" height="14" 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> Upload File</button></div>
287
  </div>
288
  <div class="imp">
289
  <h3>CI Import</h3>
 
302
  <div class="mb-col1">
303
  <div class="mb-hdr">
304
  <strong style="font-size:.85rem">Accounts</strong>
305
+ <button class="btn btn-p btn-s" id="composeBtn"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Compose</button>
306
  </div>
307
  <div style="padding:.5rem;border-bottom:1px solid var(--border)">
308
+ <input type="text" id="mbSearch" placeholder="Search accounts..." style="width:100%;padding:.4rem .65rem;font-size:.8rem">
309
  </div>
310
  <div class="mb-list" id="mbAcctList"><div class="mb-empty">Loading...</div></div>
311
  </div>
312
  <!-- Col 2: Messages -->
313
  <div class="mb-col2">
314
  <div class="mb-hdr">
315
+ <div style="display:flex;align-items:center;gap:.5rem;overflow:hidden;flex:1">
316
+ <select id="mbFolderSel" class="mb-folder-sel">
317
+ <option value="INBOX">Inbox</option>
318
+ <option value="junk">Junk</option>
319
+ <option value="sent">Sent</option>
320
+ <option value="drafts">Drafts</option>
321
+ <option value="deleted">Deleted</option>
322
+ </select>
323
+ <span id="mbInboxTitle" style="font-size:.78rem;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
324
+ </div>
325
  <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>
326
  </div>
327
  <div class="mb-list" id="mbMsgList"><div class="mb-empty">Select an account</div></div>
 
329
  <!-- Col 3: Detail/Compose -->
330
  <div class="mb-col3">
331
  <!-- Empty state -->
332
+ <div id="mbEmpty" style="flex:1;display:flex;align-items:center;justify-content:center;color:var(--text3);flex-direction:column;gap:.5rem">
333
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="opacity:.4"><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>
334
+ <span>Select a message to read</span>
335
+ </div>
336
  <!-- Detail view -->
337
+ <div id="mbDetail" style="display:none;flex-direction:column;flex:1">
338
+ <div style="padding:1rem 1.2rem;border-bottom:1px solid var(--border);background:var(--surface)">
339
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.4rem;gap:.5rem">
340
+ <h3 id="mbSubj" style="font-size:1rem;margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">Subject</h3>
341
+ <div style="display:flex;gap:.3rem;flex-shrink:0">
342
+ <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>
343
+ <button class="btn btn-d btn-s" id="mbDeleteBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/></svg></button>
344
+ </div>
345
  </div>
346
+ <div style="font-size:.82rem;color:var(--text2);display:flex;align-items:center;gap:.6rem;flex-wrap:wrap">
347
  <span id="mbFrom"></span>
348
+ <span id="mbDate" style="color:var(--text3);font-size:.76rem"></span>
349
+ <span id="mbCode"></span>
350
+ <span id="mbLink"></span>
351
  </div>
352
  </div>
353
  <div style="flex:1;padding:1rem;overflow:auto">
354
+ <iframe id="mbBody" style="width:100%;height:100%;min-height:400px;border:1px solid var(--border);border-radius:var(--r);background:#fff" sandbox="allow-same-origin"></iframe>
355
  </div>
356
  </div>
357
  <!-- Compose view -->
358
+ <div id="mbCompose" style="display:none;flex-direction:column;flex:1">
359
+ <div style="padding:1rem 1.2rem;border-bottom:1px solid var(--border);background:var(--surface);display:flex;align-items:center;justify-content:space-between">
360
  <h3 style="font-size:1rem;margin:0">Compose Email</h3>
361
  <button class="btn btn-o btn-s" id="composeCancel">Cancel</button>
362
  </div>
363
+ <div style="flex:1;padding:1.2rem;overflow:auto">
364
  <div style="max-width:600px">
365
  <label style="display:block;font-size:.82rem;font-weight:500;color:var(--text2);margin-bottom:.3rem">From</label>
366
  <select id="cFrom" style="width:100%;margin-bottom:.8rem"></select>
 
394
  </div>
395
  </div>
396
 
397
+ <!-- ===== MODALS ===== -->
398
  <div class="modal-bg" id="addModal">
399
  <div class="modal">
400
  <h3>Add Account</h3>
 
410
  <div class="modal-bg" id="pwModal">
411
  <div class="modal">
412
  <h3>Account Password</h3>
413
+ <p id="pwText" style="font-family:'JetBrains Mono',monospace;background:var(--bg);padding:.8rem;border-radius:8px;margin-top:.5rem;word-break:break-all;border:1px solid var(--border);font-size:.88rem">Loading...</p>
414
  <div class="modal-foot"><button class="btn btn-o" id="pwClose">Close</button></div>
415
  </div>
416
  </div>
 
429
  function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML}
430
  function fmtD(s){if(!s)return'--';try{return new Date(s).toLocaleDateString()}catch(e){return s}}
431
 
 
432
  function show(id,disp){var el=typeof id==='string'?$(id):id;if(el)el.style.display=disp||''}
433
  function hide(id){var el=typeof id==='string'?$(id):id;if(el)el.style.display='none'}
434
 
 
461
  });
462
  }
463
 
464
+ /* ========== THEME ========== */
465
+ function getTheme(){return localStorage.getItem('outlook2api-theme')||'light'}
466
+ function setTheme(t){
467
+ localStorage.setItem('outlook2api-theme',t);
468
+ document.documentElement.setAttribute('data-theme',t);
469
+ updateThemeIcon(t);
470
+ }
471
+ function updateThemeIcon(t){
472
+ var icon=$('themeIcon');
473
+ if(!icon)return;
474
+ if(t==='dark'){
475
+ icon.innerHTML='<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
476
+ }else{
477
+ icon.innerHTML='<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>';
478
+ }
479
+ }
480
+ // Init theme
481
+ setTheme(getTheme());
482
+
483
  /* ========== STATE ========== */
484
  var curTab='dash',page=1,total=0,PS=20;
485
  var mbMsgs=[],mbAccts=[],selAcctId=null,selAcctEmail='',curMsgIdx=-1;
486
+ var curFolder='INBOX';
487
 
488
  /* ========== AUTH ========== */
489
  function checkAuth(){
 
502
 
503
  $('logoutBtn').onclick=function(){delCk('admin_token');checkAuth()};
504
 
505
+ /* ========== THEME TOGGLE ========== */
506
+ $('themeBtn').onclick=function(){
507
+ setTheme(getTheme()==='dark'?'light':'dark');
508
+ };
509
+
510
  /* ========== SIDEBAR NAV ========== */
511
  var navBtns=document.querySelectorAll('.nav[data-t]');
512
  navBtns.forEach(function(b){
 
515
  b.classList.add('on');
516
  curTab=b.getAttribute('data-t');
517
  loadTab(curTab);
 
518
  $('side').classList.remove('open');
519
  $('ov').classList.remove('open');
520
  };
 
578
  $('prevBtn').disabled=page<=1;$('nextBtn').disabled=page>=tp;
579
  if(!a.length){tb.innerHTML='<tr><td colspan="5" class="empty">No accounts found</td></tr>';return}
580
  tb.innerHTML=a.map(function(x){
581
+ return '<tr><td style="font-family:monospace;font-size:.82rem">'+esc(x.email||x.address||'')+'</td>'
582
  +'<td><span class="badge '+(x.is_active!==false?'b-ok':'b-err')+'">'+(x.is_active!==false?'Active':'Inactive')+'</span></td>'
583
+ +'<td><span class="badge b-info">'+esc(x.source||'--')+'</span></td>'
584
+ +'<td style="color:var(--text2);font-size:.82rem">'+fmtD(x.created_at||x.created||'')+'</td>'
585
  +'<td><div class="acts">'
586
  +'<button class="btn btn-o btn-s" onclick="W._tog(\''+esc(x.id)+'\','+(!x.is_active)+')">'+(x.is_active!==false?'Deactivate':'Activate')+'</button>'
587
  +'<button class="btn btn-o btn-s" onclick="W._pw(\''+esc(x.id)+'\')">Password</button>'
 
594
  });
595
  }
596
 
 
597
  var W=window;
598
  W._tog=function(id,v){
599
  api('/admin/api/accounts/'+id,{method:'PATCH',body:{is_active:v}})
 
612
  .then(function(){toast('Account deleted');loadAccts()}).catch(function(e){toast(e.message,1)});
613
  };
614
 
 
615
  $('pwClose').onclick=function(){hide('pwModal')};
616
  $('pwModal').onclick=function(e){if(e.target===$('pwModal'))hide('pwModal')};
617
 
 
618
  $('addBtn').onclick=function(){$('addEmail').value='';$('addPw').value='';show('addModal','flex');$('addEmail').focus()};
619
  $('addCancel').onclick=function(){hide('addModal')};
620
  $('addModal').onclick=function(e){if(e.target===$('addModal'))hide('addModal')};
 
628
  .finally(function(){btn.disabled=false;btn.textContent='Add Account'});
629
  };
630
 
 
631
  $('delAllBtn').onclick=function(){
632
  if(!confirm('Delete ALL accounts? Cannot be undone.'))return;
633
  if(!confirm('Are you really sure?'))return;
 
639
  $('bulkBtn').onclick=function(){
640
  var lines=$('bulkText').value.trim().split('\n').map(function(l){return l.trim()}).filter(Boolean);
641
  if(!lines.length){toast('Enter at least one account',1);return}
642
+ var btn=$('bulkBtn');btn.disabled=true;
643
  api('/admin/api/accounts/bulk',{method:'POST',body:{accounts:lines,source:'manual'}})
644
  .then(function(d){toast('Imported '+(d.imported||0)+' accounts'+(d.skipped?' ('+d.skipped+' skipped)':''));$('bulkText').value=''})
645
  .catch(function(e){toast(e.message,1)})
646
+ .finally(function(){btn.disabled=false});
647
  };
648
 
649
  $('fileBtn').onclick=function(){
650
  var f=$('fileIn').files[0];
651
  if(!f){toast('Select a file first',1);return}
652
+ var btn=$('fileBtn');btn.disabled=true;
653
  var fd=new FormData();fd.append('file',f);
654
  api('/admin/api/accounts/upload',{method:'POST',body:fd})
655
  .then(function(d){toast('Uploaded '+(d.imported||0)+' accounts'+(d.skipped?' ('+d.skipped+' skipped)':''));$('fileIn').value=''})
656
  .catch(function(e){toast(e.message,1)})
657
+ .finally(function(){btn.disabled=false});
658
  };
659
 
660
  /* ========== KEYBOARD ========== */
 
669
  mbSTimer=setTimeout(filterMbAccts,250);
670
  };
671
 
672
+ $('mbFolderSel').onchange=function(){
673
+ curFolder=$('mbFolderSel').value;
674
+ if(selAcctId)loadMbMsgs();
675
+ };
676
+
677
  function filterMbAccts(){
678
  var q=$('mbSearch').value.toLowerCase();
679
  var items=$('mbAcctList').querySelectorAll('.mb-item');
 
688
  if(!mbAccts.length){list.innerHTML='<div class="mb-empty">No accounts</div>';return}
689
  list.innerHTML=mbAccts.map(function(a){
690
  return '<div class="mb-item'+(a.id===selAcctId?' on':'')+'" data-id="'+esc(a.id)+'" data-em="'+esc((a.email||'').toLowerCase())+'">'
691
+ +'<div style="font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.83rem">'+esc(a.email||'')+'</div>'
692
+ +'<div style="color:var(--text3);font-size:.73rem;margin-top:.15rem">'
693
+ +'<span class="badge '+(a.is_active!==false?'b-ok':'b-err')+'" style="font-size:.65rem">'+(a.is_active!==false?'Active':'Inactive')+'</span>'
694
+ +' '+esc(a.source||'')+'</div>'
695
  +'</div>';
696
  }).join('');
 
697
  list.querySelectorAll('.mb-item').forEach(function(el){
698
  el.onclick=function(){selectMbAcct(el.getAttribute('data-id'),el.getAttribute('data-em'))};
699
  });
 
709
 
710
  function selectMbAcct(id,email){
711
  selAcctId=id;selAcctEmail=email||'';
 
712
  $('mbAcctList').querySelectorAll('.mb-item').forEach(function(el){
713
  el.classList.toggle('on',el.getAttribute('data-id')===id);
714
  });
715
+ $('mbInboxTitle').textContent=selAcctEmail;
716
+ curFolder=$('mbFolderSel').value;
717
  loadMbMsgs();
718
  }
719
 
 
722
  var list=$('mbMsgList');
723
  list.innerHTML='<div class="mb-empty">Loading via IMAP...</div>';
724
  showMbView('empty');
725
+ api('/admin/api/accounts/'+selAcctId+'/messages?folder='+encodeURIComponent(curFolder)).then(function(d){
726
  mbMsgs=d.messages||[];
727
+ if(!mbMsgs.length){list.innerHTML='<div class="mb-empty">No messages in '+esc(curFolder)+'</div>';return}
728
  list.innerHTML=mbMsgs.map(function(m,i){
729
+ var from=(m.from&&m.from.name)||(m.from&&m.from.address)||m.from||'';
730
+ var isRead=m.is_read!==false;
731
+ return '<div class="mb-item'+(i===curMsgIdx?' on':'')+(isRead?'':' unread')+'" data-i="'+i+'">'
732
  +'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.2rem">'
733
+ +'<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding-right:.5rem">'+esc(from)+'</span>'
734
+ +'<div style="display:flex;gap:.25rem;flex-shrink:0">'
735
+ +(m.has_attachments?'<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color:var(--text3)"><path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/></svg>':'')
736
+ +(m.verification_code?'<span class="badge b-ok" style="font-size:.65rem">'+esc(m.verification_code)+'</span>':'')
737
+ +'</div></div>'
738
+ +'<div style="font-weight:'+(isRead?'400':'600')+';color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.82rem">'+esc(m.subject||'(no subject)')+'</div>'
739
+ +'<div style="color:var(--text3);font-size:.72rem;margin-top:.15rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+esc((m.intro||'').substring(0,80))+'</div>'
740
  +'</div>';
741
  }).join('');
742
  list.querySelectorAll('.mb-item').forEach(function(el){
 
749
 
750
  function showMbView(v){
751
  hide('mbEmpty');hide('mbDetail');hide('mbCompose');
752
+ if(v==='empty'){$('mbEmpty').style.display='flex'}
753
+ else if(v==='detail'){$('mbDetail').style.display='flex'}
754
+ else if(v==='compose'){$('mbCompose').style.display='flex'}
755
  }
756
 
757
  function showMbMsg(idx){
758
  var m=mbMsgs[idx];if(!m)return;
759
  curMsgIdx=idx;
 
760
  $('mbMsgList').querySelectorAll('.mb-item').forEach(function(el){
761
  el.classList.toggle('on',parseInt(el.getAttribute('data-i'))===idx);
762
  });
763
  $('mbSubj').textContent=m.subject||'(no subject)';
764
+ var fromText=(m.from&&m.from.name?m.from.name+' <'+m.from.address+'>':null)||(m.from&&m.from.address)||m.from||'';
765
+ $('mbFrom').textContent='From: '+fromText;
766
+ $('mbDate').textContent=m.date||'';
767
  if(m.verification_code){
768
  $('mbCode').innerHTML='<span class="badge b-ok">Code: '+esc(m.verification_code)+'</span>';
769
  }else{$('mbCode').innerHTML=''}
770
+ if(m.verification_link){
771
+ $('mbLink').innerHTML='<a href="'+esc(m.verification_link)+'" target="_blank" class="badge b-info" style="text-decoration:none;font-size:.72rem">Verify Link</a>';
772
+ }else{$('mbLink').innerHTML=''}
773
+ $('mbBody').srcdoc=(m.html&&m.html[0])||('<pre style="white-space:pre-wrap;font-family:inherit;padding:1rem">'+(esc(m.text)||'(empty)')+'</pre>');
774
  showMbView('detail');
775
  }
776
 
777
+ // Delete message
778
+ $('mbDeleteBtn').onclick=function(){
779
+ if(curMsgIdx<0||!selAcctId)return;
780
+ var m=mbMsgs[curMsgIdx];if(!m)return;
781
+ if(!confirm('Delete this message?'))return;
782
+ api('/admin/api/accounts/'+selAcctId+'/messages/delete',{method:'POST',body:{message_ids:[m.id],folder:curFolder}})
783
+ .then(function(d){toast('Message deleted');curMsgIdx=-1;loadMbMsgs()})
784
+ .catch(function(e){toast(e.message,1)});
785
+ };
786
+
787
  $('mbRefresh').onclick=function(){
788
  if(selAcctId)loadMbMsgs();else toast('Select an account first',1);
789
  };
790
 
 
791
  $('mbReplyBtn').onclick=function(){
792
  if(curMsgIdx<0)return;var m=mbMsgs[curMsgIdx];if(!m)return;
793
  showMbView('compose');
 
796
  $('cCc').value='';
797
  $('cSubj').value=(m.subject||'').indexOf('Re: ')===0?m.subject:'Re: '+(m.subject||'');
798
  $('cBody').value='\n\n--- Original Message ---\n'+(m.text||'');
799
+ $('cReplyTo').value=m.message_id||m.id||'';$('cRefs').value=m.message_id||m.id||'';
800
  };
801
 
 
802
  $('composeBtn').onclick=function(){
803
  showMbView('compose');
804
  if(selAcctId)$('cFrom').value=selAcctId;
 
810
  if(curMsgIdx>=0)showMbView('detail');else showMbView('empty');
811
  };
812
 
 
813
  $('sendBtn').onclick=function(){
814
  var fromId=$('cFrom').value,to=$('cTo').value.trim(),subj=$('cSubj').value.trim(),body=$('cBody').value;
815
  if(!fromId){toast('Select a sender',1);return}
816
  if(!to){toast('Enter a recipient',1);return}
817
  if(!subj){toast('Enter a subject',1);return}
818
+ var btn=$('sendBtn');btn.disabled=true;
819
  api('/admin/api/accounts/'+fromId+'/send',{method:'POST',body:{
820
  to:to,subject:subj,body_text:body,body_html:'',cc:$('cCc').value.trim(),
821
  in_reply_to:$('cReplyTo').value,references:$('cRefs').value
822
  }}).then(function(){toast('Email sent!');showMbView('empty')})
823
  .catch(function(e){toast(e.message,1)})
824
+ .finally(function(){btn.disabled=false});
825
  };
826
 
 
827
  W._mb=function(id,email){
828
  curTab='mail';
829
  navBtns.forEach(function(b){b.classList.remove('on');if(b.getAttribute('data-t')==='mail')b.classList.add('on')});
 
899
  curl:'curl /admin/api/accounts/123/password -H "Authorization: Bearer TOKEN"',
900
  py:'r = requests.get("/admin/api/accounts/123/password",\n headers={"Authorization":"Bearer TOKEN"})\npw = r.json()["password"]',
901
  js:'const {password} = await (await fetch("/admin/api/accounts/123/password",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
902
+ {m:'GET',p:'/admin/api/accounts/{id}/messages',d:'Fetch mailbox (?folder&search)',
903
+ curl:'curl "/admin/api/accounts/123/messages?folder=INBOX" \\\n -H "Authorization: Bearer TOKEN"',
904
+ py:'r = requests.get("/admin/api/accounts/123/messages",\n params={"folder":"INBOX"},\n headers={"Authorization":"Bearer TOKEN"})',
905
+ js:'const {messages} = await (await fetch("/admin/api/accounts/123/messages?folder=INBOX",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
906
+ {m:'GET',p:'/admin/api/accounts/{id}/folders',d:'List IMAP folders',
907
+ curl:'curl /admin/api/accounts/123/folders -H "Authorization: Bearer TOKEN"',
908
+ py:'r = requests.get("/admin/api/accounts/123/folders",\n headers={"Authorization":"Bearer TOKEN"})',
909
+ js:'const {folders} = await (await fetch("/admin/api/accounts/123/folders",\n {headers:{"Authorization":"Bearer TOKEN"}})).json();'},
910
+ {m:'POST',p:'/admin/api/accounts/{id}/messages/delete',d:'Delete messages',
911
+ curl:'curl -X POST /admin/api/accounts/123/messages/delete \\\n -H "Authorization: Bearer TOKEN" \\\n -H "Content-Type: application/json" \\\n -d \'{"message_ids":["1","2"],"folder":"INBOX"}\'',
912
+ py:'r = requests.post("/admin/api/accounts/123/messages/delete",\n json={"message_ids":["1","2"],"folder":"INBOX"},\n headers={"Authorization":"Bearer TOKEN"})',
913
+ js:'await fetch("/admin/api/accounts/123/messages/delete",{method:"POST",\n headers:{"Content-Type":"application/json","Authorization":"Bearer TOKEN"},\n body:JSON.stringify({message_ids:["1","2"],folder:"INBOX"})});'},
914
  {m:'POST',p:'/admin/api/accounts/{id}/send',d:'Send email via SMTP',
915
  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!"}\'',
916
  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"})',
 
937
  var card=document.createElement('div');card.className='doc-card';
938
  card.innerHTML='<div class="doc-head" onclick="W._docTog(\''+id+'\')">'
939
  +'<span class="meth '+(MC[ep.m]||'')+'">'+ep.m+'</span>'
940
+ +'<span style="font-family:monospace;font-size:.84rem">'+esc(ep.p)+'</span>'
941
  +'<span style="margin-left:auto;color:var(--text3);font-size:.78rem">'+esc(ep.d)+'</span>'
942
  +'<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>'
943
  +'</div>'
outlook2api/static/index.html CHANGED
@@ -5,81 +5,104 @@
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}
@@ -90,6 +113,11 @@ h1{font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:10px;
90
  </style>
91
  </head>
92
  <body>
 
 
 
 
 
93
  <div class="page">
94
  <div class="hero">
95
  <div class="pill">
@@ -159,7 +187,24 @@ h1{font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:10px;
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;
 
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=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
9
  <style>
10
  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
11
+
12
+ :root{
13
+ --bg:#f5f5f4;--surface:#fff;--border:#e7e5e4;
14
+ --text:#1c1917;--text2:#57534e;--text3:#a8a29e;
15
+ --brand:#d97706;--brand-h:#b45309;--brand-bg:rgba(217,119,6,.06);
16
+ --ok:#059669;--ok-bg:rgba(5,150,105,.06);
17
+ --info:#2563eb;--info-bg:rgba(37,99,235,.06);
18
+ --purple:#7c3aed;--purple-bg:rgba(124,58,237,.06);
19
+ --r:12px;--shadow:0 1px 3px rgba(0,0,0,.04);--shadow2:0 8px 24px rgba(0,0,0,.06);
20
+ }
21
+
22
+ [data-theme="dark"]{
23
+ --bg:#0c0a09;--surface:#1c1917;--border:#292524;
24
+ --text:#fafaf9;--text2:#a8a29e;--text3:#78716c;
25
+ --brand:#f59e0b;--brand-h:#fbbf24;--brand-bg:rgba(245,158,11,.1);
26
+ --ok:#34d399;--ok-bg:rgba(52,211,153,.1);
27
+ --info:#60a5fa;--info-bg:rgba(96,165,250,.1);
28
+ --purple:#a78bfa;--purple-bg:rgba(167,139,250,.1);
29
+ --shadow:0 1px 3px rgba(0,0,0,.2);--shadow2:0 8px 24px rgba(0,0,0,.3);
30
+ }
31
+
32
+ body{font-family:'Inter',system-ui,-apple-system,sans-serif;min-height:100vh;color:var(--text);line-height:1.6;background:var(--bg);transition:background .2s,color .2s}
33
  a{text-decoration:none;transition:color .15s}
34
 
35
  .page{max-width:820px;margin:0 auto;padding:64px 24px 48px}
36
 
37
+ /* Theme toggle */
38
+ .theme-float{position:fixed;top:1rem;right:1rem;z-index:100;width:38px;height:38px;border-radius:10px;border:1px solid var(--border);background:var(--surface);color:var(--text2);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;box-shadow:var(--shadow)}
39
+ .theme-float:hover{border-color:var(--brand);color:var(--brand);background:var(--brand-bg)}
40
+ .theme-float svg{width:18px;height:18px}
41
+
42
  /* Hero */
43
  .hero{text-align:center;margin-bottom:52px}
44
  .pill{display:inline-flex;align-items:center;gap:6px;padding:6px 16px;
45
+ background:var(--brand-bg);border:1px solid var(--border);border-radius:99px;
46
+ font-size:12px;font-weight:600;color:var(--brand);margin-bottom:24px;letter-spacing:.02em}
 
47
  .pill svg{width:14px;height:14px}
48
+ h1{font-size:38px;font-weight:700;letter-spacing:-.03em;margin-bottom:10px;color:var(--brand)}
49
+ .hero p{color:var(--text2);font-size:16px;max-width:500px;margin:0 auto}
 
50
 
51
  /* Stats */
52
  .stats{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
53
+ .stat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:22px 26px;position:relative;overflow:hidden;transition:transform .2s,box-shadow .2s}
54
+ .stat:hover{transform:translateY(-2px);box-shadow:var(--shadow2)}
55
  .stat::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
56
+ .stat:first-child::before{background:var(--brand)}
57
+ .stat:last-child::before{background:var(--ok)}
58
+ .stat-l{font-size:11px;font-weight:600;color:var(--text3);text-transform:uppercase;letter-spacing:.08em}
59
  .stat-v{font-size:32px;font-weight:700;margin-top:6px;letter-spacing:-.02em}
60
+ .stat:last-child .stat-v{color:var(--ok)}
61
 
62
  /* Features */
63
  .features{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
64
+ .feat{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:22px;transition:transform .2s,box-shadow .2s}
65
+ .feat:hover{transform:translateY(-2px);box-shadow:var(--shadow2)}
66
  .feat-ico{width:40px;height:40px;border-radius:10px;display:flex;align-items:center;justify-content:center;margin-bottom:14px}
67
  .feat-ico svg{width:20px;height:20px}
68
+ .fi-mail{background:var(--info-bg);color:var(--info)}
69
+ .fi-admin{background:var(--brand-bg);color:var(--brand)}
70
+ .fi-code{background:var(--ok-bg);color:var(--ok)}
71
+ .fi-ci{background:var(--purple-bg);color:var(--purple)}
72
  .feat h3{font-size:14px;font-weight:600;margin-bottom:4px}
73
+ .feat p{font-size:13px;color:var(--text2);line-height:1.5}
74
+ .feat code{background:var(--brand-bg);padding:1px 5px;border-radius:4px;font-size:12px;color:var(--brand)}
75
 
76
  /* Links */
77
  .links{display:flex;flex-direction:column;gap:12px;margin-bottom:40px}
78
+ .link{display:flex;align-items:center;gap:16px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:18px 22px;transition:all .2s;color:inherit}
79
+ .link:hover{box-shadow:var(--shadow2);border-color:var(--brand);transform:translateY(-1px)}
80
  .link-ico{width:44px;height:44px;border-radius:10px;display:flex;align-items:center;justify-content:center;flex-shrink:0}
81
  .link-ico svg{width:22px;height:22px}
82
+ .li-admin{background:var(--brand-bg);color:var(--brand)}
83
+ .li-docs{background:var(--info-bg);color:var(--info)}
84
  .link-txt{flex:1}
85
  .link-t{font-size:15px;font-weight:600}
86
+ .link-d{font-size:13px;color:var(--text2);margin-top:1px}
87
+ .link-arr{color:var(--text3);font-size:20px;transition:transform .2s}
88
+ .link:hover .link-arr{transform:translateX(4px);color:var(--brand)}
89
 
90
  /* API Quick Ref */
91
+ .api-ref{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);overflow:hidden;margin-bottom:40px}
92
+ .api-hdr{padding:16px 22px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between}
93
+ .api-hdr h3{font-size:14px;font-weight:600}.api-hdr span{font-size:12px;color:var(--text3)}
94
+ .ep{display:flex;align-items:center;gap:14px;padding:10px 22px;border-bottom:1px solid var(--border);font-size:13px}
95
  .ep:last-child{border-bottom:none}
96
+ .ep:hover{background:var(--brand-bg)}
97
+ .m{display:inline-block;padding:2px 8px;border-radius:5px;font-size:11px;font-weight:700;font-family:monospace;min-width:48px;text-align:center}
98
+ .m-g{background:var(--ok-bg);color:var(--ok)}.m-p{background:var(--info-bg);color:var(--info)}.m-d{background:rgba(220,38,38,.06);color:#dc2626}
99
+ .ep-p{font-family:monospace;color:var(--text)}
100
+ .ep-d{color:var(--text3);margin-left:auto;font-size:12px}
101
 
102
  /* Footer */
103
+ .foot{text-align:center;padding-top:16px;border-top:1px solid var(--border);color:var(--text3);font-size:13px}
104
+ .foot a{color:var(--text2);font-weight:500}
105
+ .foot a:hover{color:var(--brand)}
106
 
107
  @media(max-width:560px){
108
  .page{padding:40px 16px 32px}
 
113
  </style>
114
  </head>
115
  <body>
116
+
117
+ <button class="theme-float" id="themeBtn" title="Toggle theme">
118
+ <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>
119
+ </button>
120
+
121
  <div class="page">
122
  <div class="hero">
123
  <div class="pill">
 
187
 
188
  <div class="foot">Outlook2API &middot; <a href="https://github.com/shenhao-stu/outlook2api">GitHub</a></div>
189
  </div>
190
+
191
  <script>
192
+ // Theme
193
+ function getTheme(){return localStorage.getItem('outlook2api-theme')||'light'}
194
+ function setTheme(t){
195
+ localStorage.setItem('outlook2api-theme',t);
196
+ document.documentElement.setAttribute('data-theme',t);
197
+ var icon=document.getElementById('themeIcon');
198
+ if(t==='dark'){
199
+ icon.innerHTML='<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>';
200
+ }else{
201
+ icon.innerHTML='<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/>';
202
+ }
203
+ }
204
+ setTheme(getTheme());
205
+ document.getElementById('themeBtn').onclick=function(){setTheme(getTheme()==='dark'?'light':'dark')};
206
+
207
+ // Stats
208
  fetch('/admin/api/public-stats').then(function(r){if(r.ok)return r.json();throw r}).then(function(d){
209
  document.getElementById('st').textContent=d.total!=null?d.total:0;
210
  document.getElementById('sa').textContent=d.active!=null?d.active:0;