Files
outlookEmailPlus/outlook_web/repositories/accounts.py
T
胡绍康andCopilot 4adb255e87 fix: telegram push Graph API proxy support, client_id SELECT, and frontend null checks
- Fix _fetch_new_emails_graph to use get_access_token_graph (not get_access_token)
- Add client_id to get_telegram_push_accounts() SELECT
- JOIN groups table to get proxy_url per account
- Pass proxy_url to get_access_token_graph() and requests.get()
- Add null checks for Telegram DOM elements in loadSettings/saveSettings
- Add job start/end logging to run_telegram_push_job
- Add scheduler registration log message

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-05 16:16:49 +08:00

420 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_last_checked_at FROM accounts WHERE id = ?", (account_id,)).fetchone()
if not row:
return False
if enabled:
if row["telegram_last_checked_at"] is None:
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 = 1 WHERE id = ?", (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]