426 lines
13 KiB
Python
426 lines
13 KiB
Python
from __future__ import annotations
|
||
|
||
import sqlite3
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from outlook_web.db import get_db
|
||
from outlook_web.security.crypto import decrypt_data, encrypt_data
|
||
|
||
|
||
def load_accounts(group_id: int = None) -> List[Dict]:
|
||
"""从数据库加载邮箱账号(自动解密敏感字段,批量加载 tags 避免 N+1)"""
|
||
db = get_db()
|
||
if group_id:
|
||
cursor = db.execute(
|
||
"""
|
||
SELECT a.*, g.name as group_name, g.color as group_color
|
||
FROM accounts a
|
||
LEFT JOIN groups g ON a.group_id = g.id
|
||
WHERE a.group_id = ?
|
||
ORDER BY a.created_at DESC
|
||
""",
|
||
(group_id,),
|
||
)
|
||
else:
|
||
cursor = db.execute("""
|
||
SELECT a.*, g.name as group_name, g.color as group_color
|
||
FROM accounts a
|
||
LEFT JOIN groups g ON a.group_id = g.id
|
||
ORDER BY a.created_at DESC
|
||
""")
|
||
rows = cursor.fetchall()
|
||
|
||
tags_by_account: Dict[int, List[Dict[str, Any]]] = {}
|
||
try:
|
||
account_ids = [int(r["id"]) for r in rows]
|
||
except Exception:
|
||
account_ids = []
|
||
|
||
if account_ids:
|
||
try:
|
||
placeholders = ",".join(["?"] * len(account_ids))
|
||
tag_rows = db.execute(
|
||
f"""
|
||
SELECT at.account_id as account_id, t.*
|
||
FROM account_tags at
|
||
JOIN tags t ON t.id = at.tag_id
|
||
WHERE at.account_id IN ({placeholders})
|
||
ORDER BY at.account_id ASC, t.created_at DESC
|
||
""",
|
||
account_ids,
|
||
).fetchall()
|
||
|
||
for tr in tag_rows:
|
||
tag_dict = dict(tr)
|
||
acc_id = tag_dict.pop("account_id", None)
|
||
if acc_id is None:
|
||
continue
|
||
tags_by_account.setdefault(int(acc_id), []).append(tag_dict)
|
||
except Exception:
|
||
tags_by_account = {}
|
||
|
||
accounts: List[Dict[str, Any]] = []
|
||
for row in rows:
|
||
account = dict(row)
|
||
|
||
if account.get("password"):
|
||
try:
|
||
account["password"] = decrypt_data(account["password"])
|
||
except Exception:
|
||
pass
|
||
if account.get("refresh_token"):
|
||
try:
|
||
account["refresh_token"] = decrypt_data(account["refresh_token"])
|
||
except Exception:
|
||
pass
|
||
if account.get("imap_password"):
|
||
try:
|
||
account["imap_password"] = decrypt_data(account["imap_password"])
|
||
except Exception:
|
||
pass
|
||
|
||
account_id_value = account.get("id")
|
||
try:
|
||
account_id_value = int(account_id_value)
|
||
except Exception:
|
||
account_id_value = None
|
||
|
||
account["tags"] = tags_by_account.get(account_id_value, []) if account_id_value is not None else []
|
||
accounts.append(account)
|
||
return accounts
|
||
|
||
|
||
def get_account_by_email(email_addr: str) -> Optional[Dict]:
|
||
"""根据邮箱地址获取账号(自动解密敏感字段)"""
|
||
db = get_db()
|
||
cursor = db.execute("SELECT * FROM accounts WHERE email = ?", (email_addr,))
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return None
|
||
account = dict(row)
|
||
if account.get("password"):
|
||
try:
|
||
account["password"] = decrypt_data(account["password"])
|
||
except Exception:
|
||
pass
|
||
if account.get("refresh_token"):
|
||
try:
|
||
account["refresh_token"] = decrypt_data(account["refresh_token"])
|
||
except Exception:
|
||
pass
|
||
if account.get("imap_password"):
|
||
try:
|
||
account["imap_password"] = decrypt_data(account["imap_password"])
|
||
except Exception:
|
||
pass
|
||
return account
|
||
|
||
|
||
def get_account_by_id(account_id: int) -> Optional[Dict]:
|
||
"""根据 ID 获取账号(含 group_name/group_color,自动解密敏感字段)"""
|
||
db = get_db()
|
||
cursor = db.execute(
|
||
"""
|
||
SELECT a.*, g.name as group_name, g.color as group_color
|
||
FROM accounts a
|
||
LEFT JOIN groups g ON a.group_id = g.id
|
||
WHERE a.id = ?
|
||
""",
|
||
(account_id,),
|
||
)
|
||
row = cursor.fetchone()
|
||
if not row:
|
||
return None
|
||
account = dict(row)
|
||
if account.get("password"):
|
||
try:
|
||
account["password"] = decrypt_data(account["password"])
|
||
except Exception:
|
||
pass
|
||
if account.get("refresh_token"):
|
||
try:
|
||
account["refresh_token"] = decrypt_data(account["refresh_token"])
|
||
except Exception:
|
||
pass
|
||
if account.get("imap_password"):
|
||
try:
|
||
account["imap_password"] = decrypt_data(account["imap_password"])
|
||
except Exception:
|
||
pass
|
||
return account
|
||
|
||
|
||
def add_account(
|
||
email_addr: str,
|
||
password: str,
|
||
client_id: str,
|
||
refresh_token: str,
|
||
group_id: int = 1,
|
||
remark: str = "",
|
||
account_type: str = "outlook",
|
||
provider: str = "outlook",
|
||
imap_host: str = "",
|
||
imap_port: int = 993,
|
||
imap_password: str = "",
|
||
db: Optional[sqlite3.Connection] = None,
|
||
commit: bool = True,
|
||
) -> bool:
|
||
"""添加邮箱账号(支持外部事务批量导入)"""
|
||
db = db or get_db()
|
||
try:
|
||
account_type = (account_type or "outlook").strip().lower()
|
||
provider = (provider or ("outlook" if account_type != "imap" else "custom")).strip().lower()
|
||
|
||
# PRD-00005 / TDD-00005:
|
||
# - Outlook:必须提供 client_id/refresh_token(OAuth2)
|
||
# - IMAP:必须提供 imap_password;client_id/refresh_token 在 DB 中使用空字符串占位(保持 NOT NULL 约束)
|
||
if account_type == "imap":
|
||
if not (imap_password or "").strip():
|
||
return False
|
||
if provider == "custom" and not (imap_host or "").strip():
|
||
return False
|
||
else:
|
||
if not (client_id or "").strip() or not (refresh_token or "").strip():
|
||
return False
|
||
|
||
encrypted_password = encrypt_data(password) if password else password
|
||
encrypted_refresh_token = encrypt_data(refresh_token) if refresh_token else refresh_token
|
||
encrypted_imap_password = encrypt_data(imap_password) if imap_password else imap_password
|
||
|
||
db.execute(
|
||
"""
|
||
INSERT INTO accounts (
|
||
email, password, client_id, refresh_token,
|
||
account_type, provider, imap_host, imap_port, imap_password,
|
||
group_id, remark
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
""",
|
||
(
|
||
email_addr,
|
||
encrypted_password,
|
||
client_id or "",
|
||
encrypted_refresh_token,
|
||
account_type,
|
||
provider,
|
||
imap_host or "",
|
||
int(imap_port) if imap_port else 993,
|
||
encrypted_imap_password,
|
||
group_id,
|
||
remark,
|
||
),
|
||
)
|
||
if commit:
|
||
db.commit()
|
||
return True
|
||
except sqlite3.IntegrityError:
|
||
return False
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def update_account(
|
||
account_id: int,
|
||
email_addr: str,
|
||
password: Optional[str],
|
||
client_id: Optional[str],
|
||
refresh_token: Optional[str],
|
||
group_id: int,
|
||
remark: str,
|
||
status: str,
|
||
) -> bool:
|
||
"""更新邮箱账号"""
|
||
db = get_db()
|
||
try:
|
||
existing = db.execute(
|
||
"""
|
||
SELECT password, client_id, refresh_token, account_type, imap_password
|
||
FROM accounts
|
||
WHERE id = ?
|
||
""",
|
||
(account_id,),
|
||
).fetchone()
|
||
if not existing:
|
||
return False
|
||
|
||
account_type = (existing["account_type"] or "outlook").strip().lower()
|
||
|
||
# PRD-00005 / TDD-00005:IMAP 账号不要求 client_id/refresh_token(DB 约束使用空字符串占位)
|
||
# 允许更新:email/group/remark/status;如用户在 UI 的“密码”栏输入内容,则视为更新 imap_password。
|
||
if account_type == "imap":
|
||
encrypted_imap_password = existing["imap_password"]
|
||
if isinstance(password, str) and password.strip():
|
||
encrypted_imap_password = encrypt_data(password)
|
||
|
||
if not email_addr:
|
||
return False
|
||
|
||
db.execute(
|
||
"""
|
||
UPDATE accounts
|
||
SET email = ?,
|
||
imap_password = ?,
|
||
group_id = ?,
|
||
remark = ?,
|
||
status = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
email_addr,
|
||
encrypted_imap_password,
|
||
group_id,
|
||
remark,
|
||
status,
|
||
account_id,
|
||
),
|
||
)
|
||
db.commit()
|
||
return True
|
||
|
||
new_client_id = client_id.strip() if isinstance(client_id, str) and client_id.strip() else existing["client_id"]
|
||
|
||
encrypted_password = existing["password"]
|
||
if isinstance(password, str) and password.strip():
|
||
encrypted_password = encrypt_data(password)
|
||
|
||
encrypted_refresh_token = existing["refresh_token"]
|
||
if isinstance(refresh_token, str) and refresh_token.strip():
|
||
encrypted_refresh_token = encrypt_data(refresh_token)
|
||
|
||
if not email_addr or not new_client_id or not encrypted_refresh_token:
|
||
return False
|
||
|
||
db.execute(
|
||
"""
|
||
UPDATE accounts
|
||
SET email = ?, password = ?, client_id = ?, refresh_token = ?,
|
||
group_id = ?, remark = ?, status = ?, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
""",
|
||
(
|
||
email_addr,
|
||
encrypted_password,
|
||
new_client_id,
|
||
encrypted_refresh_token,
|
||
group_id,
|
||
remark,
|
||
status,
|
||
account_id,
|
||
),
|
||
)
|
||
db.commit()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def delete_account_by_id(account_id: int) -> bool:
|
||
"""删除邮箱账号"""
|
||
db = get_db()
|
||
try:
|
||
db.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
|
||
db.commit()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def delete_account_by_email(email_addr: str) -> bool:
|
||
"""根据邮箱地址删除账号"""
|
||
db = get_db()
|
||
try:
|
||
db.execute("DELETE FROM accounts WHERE email = ?", (email_addr,))
|
||
db.commit()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def update_account_credentials(account_id: int, **fields) -> bool:
|
||
"""仅更新账号的凭据相关字段(用于覆盖导入场景),敏感字段自动加密。"""
|
||
allowed = {
|
||
"password",
|
||
"client_id",
|
||
"refresh_token",
|
||
"imap_password",
|
||
"imap_host",
|
||
"imap_port",
|
||
"account_type",
|
||
"provider",
|
||
"group_id",
|
||
}
|
||
updates = {k: v for k, v in fields.items() if k in allowed}
|
||
if not updates:
|
||
return False
|
||
|
||
# 加密敏感字段
|
||
for key in ("password", "refresh_token", "imap_password"):
|
||
if key in updates and updates[key]:
|
||
updates[key] = encrypt_data(updates[key])
|
||
|
||
db = get_db()
|
||
try:
|
||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||
values = list(updates.values()) + [account_id]
|
||
db.execute(
|
||
f"UPDATE accounts SET {set_clause}, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||
values,
|
||
)
|
||
db.commit()
|
||
return True
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def toggle_telegram_push(account_id: int, enabled: bool) -> bool:
|
||
"""切换账号 Telegram 推送开关。从禁用切换到启用时重置游标为当前 UTC 时间,
|
||
已启用时重复调用不改变游标(幂等)。"""
|
||
from datetime import datetime, timezone
|
||
|
||
db = get_db()
|
||
row = db.execute(
|
||
"SELECT id, telegram_push_enabled, telegram_last_checked_at FROM accounts WHERE id = ?",
|
||
(account_id,),
|
||
).fetchone()
|
||
if not row:
|
||
return False
|
||
|
||
if enabled:
|
||
already_enabled = bool(row["telegram_push_enabled"])
|
||
if already_enabled:
|
||
return True
|
||
now_utc = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
|
||
db.execute(
|
||
"UPDATE accounts SET telegram_push_enabled = 1, telegram_last_checked_at = ? WHERE id = ?",
|
||
(now_utc, account_id),
|
||
)
|
||
else:
|
||
db.execute("UPDATE accounts SET telegram_push_enabled = 0 WHERE id = ?", (account_id,))
|
||
|
||
db.commit()
|
||
return True
|
||
|
||
|
||
def update_telegram_cursor(account_id: int, checked_at: str) -> None:
|
||
"""更新账号的 telegram_last_checked_at 游标。"""
|
||
db = get_db()
|
||
db.execute(
|
||
"UPDATE accounts SET telegram_last_checked_at = ? WHERE id = ?",
|
||
(checked_at, account_id),
|
||
)
|
||
db.commit()
|
||
|
||
|
||
def get_telegram_push_accounts() -> List[Dict]:
|
||
"""返回所有 telegram_push_enabled=1 且非 disabled 状态的账号。"""
|
||
db = get_db()
|
||
rows = db.execute("""SELECT a.id, a.email, a.provider, a.client_id, a.refresh_token,
|
||
a.imap_host, a.imap_port, a.imap_password,
|
||
a.telegram_last_checked_at, a.group_id,
|
||
g.proxy_url
|
||
FROM accounts a
|
||
LEFT JOIN groups g ON a.group_id = g.id
|
||
WHERE a.telegram_push_enabled = 1 AND a.status != 'disabled'""").fetchall()
|
||
return [dict(r) for r in rows]
|