Files
outlookEmailPlus/outlook_web/services/telegram_push.py
T
胡绍康andCopilot 308494dcc9 feat: add PUSH_RECENCY_HOURS (12h) and TELEGRAM_PUSH_DELAY_SEC (1.5s)
- Skip emails older than 12 hours to prevent flooding on re-enable
- Add 1.5s delay between Telegram sends to avoid rate limiting
- Update tests T-19/T-20/T-22 to use recent timestamps and mock delay
- Add 2 new recency filter tests (old email skipped, recent passes)
- 221 tests passing

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

404 lines
15 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.
"""outlook_web/services/telegram_push.py — Telegram 实时推送核心服务
轮询 IMAP/Graph 读取新邮件 → 构造消息 → 调用 Telegram Bot API → 丢弃内容,仅更新游标。
"""
from __future__ import annotations
import logging
import re
from datetime import datetime, timezone
from typing import List
import requests
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# 文本工具
# ---------------------------------------------------------------------------
MAX_TELEGRAM_LENGTH = 4096
MAX_PREVIEW_LENGTH = 200
MAX_EMAILS_PER_FETCH = 50
MAX_SENT_PER_JOB = 20
PUSH_RECENCY_HOURS = 12 # 超过此小时数的邮件不推送(防止首次启用时轰炸)
TELEGRAM_PUSH_DELAY_SEC = 1.5 # 连续发送 Telegram 消息的间隔(防限流)
def _escape_html(text: str) -> str:
"""转义 Telegram HTML 模式必须转义的三种字符:& < >"""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
def _html_to_plain(html_str: str) -> str:
"""将 HTML 正文提取为纯文本(strip tags),合并多余空白。"""
if not html_str:
return ""
text = re.sub(r"<[^>]+>", " ", html_str)
text = re.sub(r"\s+", " ", text).strip()
return text
def _build_telegram_message(account_email: str, email: dict) -> str:
"""构造 Telegram HTML 消息文本(PRD §3.4 格式)。"""
subject = _escape_html(email.get("subject", ""))
sender = _escape_html(email.get("sender", ""))
received_at = email.get("received_at", "")
preview = email.get("preview", "")
lines = [
"📬 新邮件通知",
"",
f"账户:{_escape_html(account_email)}",
f"发件人:{sender}",
f"主题:{subject}",
f"时间:{received_at}",
]
if preview:
truncated = preview[:MAX_PREVIEW_LENGTH]
if len(preview) > MAX_PREVIEW_LENGTH:
truncated += "..."
lines.append("")
lines.append(f"内容预览:\n{_escape_html(truncated)}")
msg = "\n".join(lines)
if len(msg) > MAX_TELEGRAM_LENGTH:
msg = msg[: MAX_TELEGRAM_LENGTH - 3] + "..."
return msg
# ---------------------------------------------------------------------------
# Telegram API
# ---------------------------------------------------------------------------
def _send_telegram_message(bot_token: str, chat_id: str, text: str) -> bool:
"""调用 Telegram sendMessage API。超时 10 秒,失败返回 False。"""
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
try:
resp = requests.post(
url,
json={
"chat_id": chat_id,
"text": text,
"parse_mode": "HTML",
"disable_web_page_preview": True,
},
timeout=10,
)
if not resp.ok:
logger.warning("[telegram_push] send HTTP %s: %s", resp.status_code, resp.text[:200])
return resp.ok
except Exception as e:
logger.warning("[telegram_push] send failed: %s", e)
return False
# ---------------------------------------------------------------------------
# 邮件拉取(IMAP / Graph)
# ---------------------------------------------------------------------------
def _fetch_new_emails_imap(account: dict, since: str) -> List[dict]:
"""通过 IMAP 获取 received_at > since 的邮件,最多返回 50 封。
两步策略:先用 INTERNALDATE 快速过滤,再对命中的邮件下载正文。
"""
import email as email_lib
import email.header
import imaplib
from datetime import datetime as dt
from outlook_web.security.crypto import decrypt_data
host = account.get("imap_host", "")
port = int(account.get("imap_port", 993))
password_raw = account.get("imap_password", "")
password = decrypt_data(password_raw) if password_raw else ""
user = account.get("email", "")
since_dt = dt.fromisoformat(since)
since_date_str = since_dt.strftime("%d-%b-%Y")
results: List[dict] = []
conn = None
try:
conn = imaplib.IMAP4_SSL(host, port, timeout=15)
conn.login(user, password)
conn.select("INBOX", readonly=True)
_, data = conn.search(None, f'(SINCE "{since_date_str}")')
msg_ids = data[0].split() if data[0] else []
if not msg_ids:
return results
# 第一步:批量获取 INTERNALDATE 快速过滤
candidate_ids = msg_ids[-MAX_EMAILS_PER_FETCH:]
id_range = b",".join(candidate_ids)
_, date_data = conn.fetch(id_range, "(INTERNALDATE)")
import re
date_pattern = re.compile(rb'INTERNALDATE "([^"]+)"')
new_msg_ids = []
for item in date_data:
if not isinstance(item, tuple):
continue
header_line = item[0] if isinstance(item[0], bytes) else b""
mid_match = re.match(rb"(\d+)", header_line)
date_match = date_pattern.search(header_line)
if not mid_match or not date_match:
continue
mid = mid_match.group(1)
idate_str = date_match.group(1).decode("ascii", errors="replace")
try:
from imaplib import Internaldate2tuple
import calendar
tt = Internaldate2tuple(b'"' + date_match.group(1) + b'"')
if tt:
ts = calendar.timegm(tt)
idate_iso = dt.utcfromtimestamp(ts).strftime("%Y-%m-%dT%H:%M:%S")
if idate_iso <= since:
continue
except Exception:
pass # 解析失败时保留,由后续 RFC822 过滤
new_msg_ids.append(mid)
# 第二步:仅对候选邮件下载 RFC822
for mid in new_msg_ids[-MAX_EMAILS_PER_FETCH:]:
try:
_, msg_data = conn.fetch(mid, "(RFC822)")
raw = msg_data[0][1]
msg = email_lib.message_from_bytes(raw)
subject_parts = email.header.decode_header(msg.get("Subject", ""))
subject = "".join(
part.decode(charset or "utf-8") if isinstance(part, bytes) else part
for part, charset in subject_parts
)
sender = msg.get("From", "")
date_str = msg.get("Date", "")
try:
from email.utils import parsedate_to_datetime
received_dt = parsedate_to_datetime(date_str)
if received_dt.tzinfo is not None:
received_dt = received_dt.astimezone(timezone.utc)
received_iso = received_dt.strftime("%Y-%m-%dT%H:%M:%S")
except Exception:
received_iso = date_str
if received_iso <= since:
continue
body = ""
if msg.is_multipart():
for part in msg.walk():
ct = part.get_content_type()
if ct == "text/plain":
payload = part.get_payload(decode=True)
if payload:
body = payload.decode(part.get_content_charset() or "utf-8", errors="replace")
break
elif ct == "text/html" and not body:
payload = part.get_payload(decode=True)
if payload:
body = _html_to_plain(
payload.decode(part.get_content_charset() or "utf-8", errors="replace")
)
else:
payload = msg.get_payload(decode=True)
if payload:
charset = msg.get_content_charset() or "utf-8"
raw_body = payload.decode(charset, errors="replace")
if msg.get_content_type() == "text/html":
body = _html_to_plain(raw_body)
else:
body = raw_body
preview = body[:MAX_PREVIEW_LENGTH] if body else ""
results.append(
{
"subject": subject,
"sender": sender,
"received_at": received_iso,
"preview": preview,
}
)
except Exception:
continue
except Exception as e:
logger.warning("[telegram_push] IMAP fetch error for %s: %s", account.get("email"), e)
raise
finally:
if conn:
try:
conn.logout()
except Exception:
pass
return results[:MAX_EMAILS_PER_FETCH]
def _fetch_new_emails_graph(account: dict, since: str) -> List[dict]:
"""通过 Microsoft Graph API 获取 received_at > since 的邮件,最多返回 50 封。"""
from outlook_web.security.crypto import decrypt_data
from outlook_web.services.graph import build_proxies, get_access_token_graph
client_id = account.get("client_id", "")
refresh_token_raw = account.get("refresh_token", "")
refresh_token = decrypt_data(refresh_token_raw) if refresh_token_raw else ""
proxy_url = account.get("proxy_url", "") or ""
access_token = get_access_token_graph(client_id, refresh_token, proxy_url)
if not access_token:
return []
since_z = since if since.endswith("Z") else since + "Z"
url = "https://graph.microsoft.com/v1.0/me/messages"
params = {
"$filter": f"receivedDateTime gt {since_z}",
"$top": MAX_EMAILS_PER_FETCH,
"$select": "subject,from,receivedDateTime,bodyPreview",
"$orderby": "receivedDateTime asc",
}
headers = {"Authorization": f"Bearer {access_token}"}
proxies = build_proxies(proxy_url)
results: List[dict] = []
try:
resp = requests.get(url, headers=headers, params=params, timeout=15, proxies=proxies)
if not resp.ok:
return []
data = resp.json()
for item in data.get("value", []):
sender_info = item.get("from", {}).get("emailAddress", {})
sender = sender_info.get("address", sender_info.get("name", ""))
received_raw = item.get("receivedDateTime", "")
received_iso = received_raw.replace("Z", "").split(".")[0] if received_raw else ""
preview = (item.get("bodyPreview", "") or "")[:MAX_PREVIEW_LENGTH]
results.append(
{
"subject": item.get("subject", ""),
"sender": sender,
"received_at": received_iso,
"preview": preview,
}
)
except Exception as e:
logger.warning("[telegram_push] Graph fetch error for %s: %s", account.get("email"), e)
raise
return results
# ---------------------------------------------------------------------------
# 主入口(调度器调用)
# ---------------------------------------------------------------------------
def _fetch_account_emails(account: dict) -> tuple:
"""并行获取单个账号新邮件,返回 (account, emails_list, error)。"""
last_checked = account.get("telegram_last_checked_at")
if last_checked is None:
return (account, None, None) # 首次运行,仅设置游标
try:
if account.get("provider") == "outlook":
emails = _fetch_new_emails_graph(account, last_checked)
else:
emails = _fetch_new_emails_imap(account, last_checked)
logger.info(
"[telegram_push] account=%s provider=%s since=%s found=%d",
account.get("email"), account.get("provider"), last_checked, len(emails),
)
return (account, emails, None)
except Exception as e:
logger.warning("[telegram_push] account=%s error: %s", account.get("email"), e)
return (account, None, e)
def run_telegram_push_job(app) -> None:
"""主入口:并行轮询 → 推送 → 更新游标。由调度器调用。"""
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
t0 = time.monotonic()
logger.info("[telegram_push] job started")
with app.app_context():
from outlook_web.repositories.accounts import (
get_telegram_push_accounts,
update_telegram_cursor,
)
from outlook_web.repositories.settings import get_setting
from outlook_web.security.crypto import decrypt_data, is_encrypted
bot_token_raw = get_setting("telegram_bot_token", "")
bot_token = decrypt_data(bot_token_raw) if bot_token_raw and is_encrypted(bot_token_raw) else bot_token_raw
chat_id = get_setting("telegram_chat_id", "")
if not bot_token or not chat_id:
logger.info("[telegram_push] job skipped: no bot_token or chat_id")
return
accounts = get_telegram_push_accounts()
if not accounts:
logger.info("[telegram_push] job skipped: no push-enabled accounts")
return
job_start_time = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S")
# 计算 recency 截止时间(超过此时间的邮件不推送)
from datetime import timedelta
recency_cutoff = (datetime.now(timezone.utc) - timedelta(hours=PUSH_RECENCY_HOURS)).strftime("%Y-%m-%dT%H:%M:%S")
sent_count = 0
# 并行获取所有账号邮件
fetch_results = []
with ThreadPoolExecutor(max_workers=min(len(accounts), 10)) as executor:
futures = {executor.submit(_fetch_account_emails, acc): acc for acc in accounts}
for future in as_completed(futures):
fetch_results.append(future.result())
# 顺序推送 + 更新游标
for account, emails, error in fetch_results:
if emails is None and error is None:
# 首次运行,仅设置游标
update_telegram_cursor(account["id"], job_start_time)
continue
if error is not None:
# fetch 失败,不推进游标
continue
if sent_count >= MAX_SENT_PER_JOB:
update_telegram_cursor(account["id"], job_start_time)
continue
for em in sorted(emails, key=lambda e: e.get("received_at", "")):
if sent_count >= MAX_SENT_PER_JOB:
break
# 跳过超过 PUSH_RECENCY_HOURS 的旧邮件
if em.get("received_at", "") < recency_cutoff:
continue
msg = _build_telegram_message(account["email"], em)
if _send_telegram_message(bot_token, chat_id, msg):
sent_count += 1
# 消息间延迟,防止 Telegram API 限流
if TELEGRAM_PUSH_DELAY_SEC > 0:
time.sleep(TELEGRAM_PUSH_DELAY_SEC)
update_telegram_cursor(account["id"], job_start_time)
elapsed = time.monotonic() - t0
logger.info("[telegram_push] job finished: sent=%d elapsed=%.1fs", sent_count, elapsed)