Spaces:
Paused
Paused
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 +1 -0
- outlook2api/admin_routes.py +51 -3
- outlook2api/outlook_imap.py +206 -21
- outlook2api/static/admin.html +256 -147
- outlook2api/static/index.html +87 -42
.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,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
)
|
| 315 |
except Exception as e:
|
| 316 |
raise HTTPException(status_code=502, detail=f"IMAP error: {e}")
|
| 317 |
-
return
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
try:
|
| 61 |
-
_, msg_data = mail.
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 85 |
-
elif ct == "text/html":
|
| 86 |
-
html
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
verification_code = _extract_verification_code(text, html)
|
|
|
|
|
|
|
| 99 |
|
| 100 |
messages.append({
|
| 101 |
-
"id":
|
| 102 |
-
"
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 9 |
<style>
|
| 10 |
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
|
|
|
|
|
| 11 |
:root{
|
| 12 |
-
--bg:#
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
-
--
|
| 17 |
-
--
|
| 18 |
-
--
|
| 19 |
-
--r:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
-
|
|
|
|
| 22 |
body{min-height:100vh}
|
| 23 |
a{color:var(--brand);text-decoration:none}
|
| 24 |
-
input,textarea,select{font-family:inherit;font-size:.
|
| 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:.
|
| 28 |
|
| 29 |
/* ---- Buttons ---- */
|
| 30 |
-
.btn{display:inline-flex;align-items:center;gap:.4rem;padding:.5rem 1rem;border-radius:var(--r);font-weight:500;transition:
|
| 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(--
|
| 33 |
-
.btn-d{background:var(--err);color:
|
| 34 |
-
.btn-s{padding:.3rem .
|
| 35 |
-
.btn:disabled{opacity:.
|
|
|
|
| 36 |
|
| 37 |
/* ---- Toast ---- */
|
| 38 |
-
#toast-box{position:fixed;top:
|
| 39 |
-
.toast{padding:.
|
| 40 |
.toast-err{border-left-color:var(--err)}
|
| 41 |
-
@keyframes tIn{from{opacity:0;transform:
|
| 42 |
|
| 43 |
/* ---- Login ---- */
|
| 44 |
-
#login{display:flex;align-items:center;justify-content:center;min-height:100vh;padding:
|
| 45 |
-
.login-box{background:var(--surface);border:1px solid var(--border);border-radius:
|
| 46 |
-
.login-box h1{font-size:1.
|
| 47 |
-
.login-box
|
| 48 |
-
.login-box
|
| 49 |
-
.login-box
|
| 50 |
-
.login-box
|
|
|
|
| 51 |
|
| 52 |
/* ---- Layout ---- */
|
| 53 |
#app{display:none;min-height:100vh}
|
| 54 |
.lay{display:flex;min-height:100vh}
|
| 55 |
-
.side{width:
|
| 56 |
-
.side-brand{padding:0 1.
|
| 57 |
-
.side-brand
|
| 58 |
-
.
|
|
|
|
| 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:
|
| 64 |
-
.main h2{font-size:1.
|
| 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,.
|
| 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(
|
| 79 |
-
.stat-c{background:var(--surface);border:1px solid var(--border);border-radius:var(--
|
| 80 |
-
.stat-c
|
| 81 |
-
.stat-c .
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
|
| 83 |
/* ---- Table ---- */
|
| 84 |
-
.tbl-w{overflow-x:auto;background:var(--surface);border:1px solid var(--border);border-radius:var(--
|
| 85 |
-
table{width:100%;border-collapse:collapse;font-size:.
|
| 86 |
-
th{text-align:left;padding:.7rem
|
| 87 |
-
td{padding:.
|
| 88 |
tr:last-child td{border-bottom:none}
|
| 89 |
-
tr:hover td{background:
|
| 90 |
-
.acts{display:flex;gap:.
|
| 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:.
|
| 99 |
|
| 100 |
/* ---- Modal ---- */
|
| 101 |
-
.modal-bg{display:none;position:fixed;inset:0;background:rgba(0,0,0,.
|
| 102 |
-
.modal{background:var(--surface);border-radius:
|
| 103 |
-
.modal h3{margin-bottom:
|
| 104 |
-
.modal label{display:block;font-weight:500;margin-bottom:.
|
| 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.
|
| 108 |
|
| 109 |
/* ---- Import ---- */
|
| 110 |
-
.imp{background:var(--surface);border:1px solid var(--border);border-radius:var(--
|
| 111 |
-
.imp h3{font-size:1rem;margin-bottom:.
|
| 112 |
-
.imp p{color:var(--text2);font-size:.
|
| 113 |
.imp textarea{width:100%;min-height:120px;margin-bottom:.8rem}
|
| 114 |
-
.imp code{background:var(--
|
| 115 |
|
| 116 |
/* ---- Badges ---- */
|
| 117 |
-
.badge{display:inline-
|
| 118 |
.b-ok{background:var(--ok-bg);color:var(--ok)}
|
| 119 |
.b-err{background:var(--err-bg);color:var(--err)}
|
| 120 |
-
.
|
| 121 |
-
.
|
| 122 |
-
.m-
|
| 123 |
-
.m-
|
| 124 |
-
.m-
|
|
|
|
| 125 |
|
| 126 |
/* ---- Mailbox 3-col ---- */
|
| 127 |
-
.mb-wrap{display:flex;height:calc(100vh -
|
| 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(--
|
| 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 .
|
| 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:.
|
| 140 |
-
.doc-head{padding:.
|
| 141 |
-
.doc-head:hover{background:var(--
|
| 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:
|
| 146 |
-
.doc-tab.on{background:var(--bg);color:var(--
|
| 147 |
-
.doc-pre{background:var(--bg);border:1px solid var(--border);border-radius:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:.
|
| 152 |
-
pre{background:var(--bg);border:1px solid var(--border);border-radius:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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">
|
| 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 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 .
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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)">
|
|
|
|
|
|
|
|
|
|
| 278 |
<!-- Detail view -->
|
| 279 |
-
<div id="mbDetail" style="display:none;flex
|
| 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;
|
| 283 |
-
<
|
|
|
|
|
|
|
|
|
|
| 284 |
</div>
|
| 285 |
-
<div style="font-size:.82rem;color:var(--text2)">
|
| 286 |
<span id="mbFrom"></span>
|
| 287 |
-
<span id="
|
|
|
|
|
|
|
| 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:
|
| 292 |
</div>
|
| 293 |
</div>
|
| 294 |
<!-- Compose view -->
|
| 295 |
-
<div id="mbCompose" style="display:none;flex
|
| 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:
|
| 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
|
| 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:.
|
| 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;
|
| 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
|
| 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;
|
| 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
|
| 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:.
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
|
|
|
| 643 |
+'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.2rem">'
|
| 644 |
-
+'<span style="
|
| 645 |
-
+
|
| 646 |
-
+'</
|
| 647 |
-
+'<
|
| 648 |
-
+'<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')
|
| 662 |
-
else if(v==='detail'){
|
| 663 |
-
else if(v==='compose'){
|
| 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 |
-
|
|
|
|
|
|
|
| 675 |
if(m.verification_code){
|
| 676 |
$('mbCode').innerHTML='<span class="badge b-ok">Code: '+esc(m.verification_code)+'</span>';
|
| 677 |
}else{$('mbCode').innerHTML=''}
|
| 678 |
-
|
|
|
|
|
|
|
|
|
|
| 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;
|
| 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
|
| 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
|
| 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:.
|
| 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 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=
|
| 9 |
<style>
|
| 10 |
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 21 |
-
|
| 22 |
-
color:#c96442;margin-bottom:24px;letter-spacing:.02em}
|
| 23 |
.pill svg{width:14px;height:14px}
|
| 24 |
-
h1{font-size:
|
| 25 |
-
|
| 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:
|
| 31 |
-
.stat:hover{transform:translateY(-2px);box-shadow:
|
| 32 |
.stat::before{content:'';position:absolute;top:0;left:0;right:0;height:3px}
|
| 33 |
-
.stat:first-child::before{background:
|
| 34 |
-
.stat:last-child::before{background:
|
| 35 |
-
.stat-l{font-size:11px;font-weight:600;color:
|
| 36 |
.stat-v{font-size:32px;font-weight:700;margin-top:6px;letter-spacing:-.02em}
|
| 37 |
-
.stat:last-child .stat-v{color:
|
| 38 |
|
| 39 |
/* Features */
|
| 40 |
.features{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:40px}
|
| 41 |
-
.feat{background:
|
| 42 |
-
.feat:hover{transform:translateY(-2px);box-shadow:
|
| 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:
|
| 46 |
-
.fi-admin{background:
|
| 47 |
-
.fi-code{background:
|
| 48 |
-
.fi-ci{background:
|
| 49 |
.feat h3{font-size:14px;font-weight:600;margin-bottom:4px}
|
| 50 |
-
.feat p{font-size:13px;color:
|
| 51 |
-
.feat code{background:
|
| 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:
|
| 56 |
-
.link:hover{box-shadow:
|
| 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:
|
| 60 |
-
.li-docs{background:
|
| 61 |
.link-txt{flex:1}
|
| 62 |
.link-t{font-size:15px;font-weight:600}
|
| 63 |
-
.link-d{font-size:13px;color:
|
| 64 |
-
.link-arr{color:
|
| 65 |
-
.link:hover .link-arr{transform:translateX(4px);color:
|
| 66 |
|
| 67 |
/* API Quick Ref */
|
| 68 |
-
.api-ref{background:
|
| 69 |
-
.api-hdr{padding:16px 22px;border-bottom:1px solid
|
| 70 |
-
.api-hdr h3{font-size:14px;font-weight:600}.api-hdr span{font-size:12px;color:
|
| 71 |
-
.ep{display:flex;align-items:center;gap:14px;padding:10px 22px;border-bottom:1px solid
|
| 72 |
.ep:last-child{border-bottom:none}
|
| 73 |
-
.ep:hover{background:
|
| 74 |
-
.m{display:inline-block;padding:2px 8px;border-radius:
|
| 75 |
-
.m-g{background:
|
| 76 |
-
.ep-p{font-family:monospace;color:
|
| 77 |
-
.ep-d{color:
|
| 78 |
|
| 79 |
/* Footer */
|
| 80 |
-
.foot{text-align:center;padding-top:16px;border-top:1px solid
|
| 81 |
-
.foot a{color:
|
| 82 |
-
.foot a:hover{color:
|
| 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 · <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 · <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;
|