Files
ai-mail-bot/src/email_client.py

215 lines
7.7 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.
import imaplib
import email
import logging
import re
from email.header import decode_header
from email.utils import parsedate_to_datetime, parseaddr
from typing import Optional
from src.config import EmailAccount
from src.retry import retry
logger = logging.getLogger(__name__)
class Email:
def __init__(self, uid: bytes, subject: str, sender: str, recipient: str,
body: str, date: str, reply_to: str = "", account_email: str = "",
folder: str = "INBOX"):
self.uid = uid
self.subject = subject
self.sender = sender
self.recipient = recipient
self.body = body
self.date = date
self.reply_to = reply_to
self.account_email = account_email
self.folder = folder
def _decode_str(s: str) -> str:
parts = decode_header(s)
result = []
for part, charset in parts:
if isinstance(part, bytes):
try:
result.append(part.decode(charset or "utf-8", errors="replace"))
except LookupError:
result.append(part.decode("utf-8", errors="replace"))
else:
result.append(part)
return "".join(result)
def _decode_address_header(header_value: str) -> str:
if not header_value:
return ""
decoded = _decode_str(header_value)
name, addr = parseaddr(decoded)
if not addr:
return name
if name:
return f"{name} <{addr}>"
return addr
def _get_text_from_msg(msg) -> str:
if msg.is_multipart():
for part in msg.walk():
content_type = part.get_content_type()
if content_type == "text/plain":
payload = part.get_payload(decode=True)
if payload:
return payload.decode("utf-8", errors="replace")
elif content_type == "text/html":
payload = part.get_payload(decode=True)
if payload:
return payload.decode("utf-8", errors="replace")
else:
payload = msg.get_payload(decode=True)
if payload:
return payload.decode("utf-8", errors="replace")
return ""
_PROVIDERS_NEED_ID = ("163.com", "126.com")
def _check_provider(email_addr: str, providers: tuple[str, ...]) -> bool:
return any(email_addr.endswith(p) for p in providers)
def _send_id_command(conn):
imaplib.Commands["ID"] = "AUTH"
args = ("name", "AI-Mail-Bot", "version", "1.0.0", "vendor", "personal")
conn._simple_command("ID", '("' + '" "'.join(args) + '")')
def _select_mailbox(conn, mailbox: str = "INBOX"):
status, data = conn.select(mailbox)
if status != "OK":
raise RuntimeError(f"无法选择邮箱 {mailbox}: {data}")
def _login_and_prepare(account: EmailAccount, timeout: int = 30, select_inbox: bool = True):
logger.info("连接 %s:%d (超时%ds)", account.imap_server, account.imap_port, timeout)
if account.use_ssl:
conn = imaplib.IMAP4_SSL(account.imap_server, account.imap_port, timeout=timeout)
else:
conn = imaplib.IMAP4(account.imap_server, account.imap_port, timeout=timeout)
if account.use_starttls:
conn.starttls()
conn.login(account.username, account.password)
logger.info("登录成功: %s", account.username)
if _check_provider(account.username, _PROVIDERS_NEED_ID):
logger.info("发送 ID 命令 (126/163 兼容)")
_send_id_command(conn)
if select_inbox:
_select_mailbox(conn)
logger.info("已选择 INBOX")
return conn
def fetch_unseen_emails(account: EmailAccount, skip_uids: set[bytes] | None = None) -> list[Email]:
conn = _login_and_prepare(account, select_inbox=False)
if skip_uids is None:
skip_uids = set()
# 列出所有文件夹,跳过 NoSelect 文件夹
_, folder_data = conn.list()
folders = []
if folder_data:
for item in folder_data:
if item is None:
continue
line = item.decode("utf-8", errors="replace")
# 跳过 \NoSelect 文件夹(父文件夹,不能直接 SELECT
if "\\NoSelect" in line:
continue
m = re.search(r'"([^"]*)"\s*$', line)
if m:
folders.append(m.group(1))
if not folders:
folders = ["INBOX"]
logger.info("扫描文件夹: %s", ", ".join(folders))
emails = []
skipped = 0
for folder in folders:
try:
status, _ = conn.select(folder, readonly=True)
if status != "OK":
logger.debug("无法选择文件夹: %s", folder)
continue
_, data = conn.uid("SEARCH", None, "UNSEEN")
uids = data[0].split() if data[0] else []
if not uids:
continue
# 先过滤掉正在处理的 UID避免下载不需要的邮件
new_uids = [uid for uid in uids if uid not in skip_uids]
skipped += len(uids) - len(new_uids)
if not new_uids:
continue
logger.info(" %s: %d 封未读 (%d 封跳过)", folder, len(new_uids), len(uids) - len(new_uids))
for uid in new_uids:
try:
_, msg_data = conn.uid("FETCH", uid, "RFC822")
if msg_data[0] is None:
logger.warning("UID %s FETCH 返回空,跳过", uid)
continue
raw_email = msg_data[0][1]
msg = email.message_from_bytes(raw_email)
subject = _decode_str(msg.get("Subject", ""))
sender = _decode_address_header(msg.get("From", ""))
recipient = _decode_address_header(msg.get("To", ""))
reply_to = _decode_address_header(msg.get("Reply-To", ""))
date_str = msg.get("Date", "")
body = _get_text_from_msg(msg)
body_len = len(body)
logger.info(" 邮件: [%s] from=%s to=%s reply-to=%s (%d 字符)", subject, sender, recipient, reply_to, body_len)
emails.append(Email(uid=uid, subject=subject, sender=sender, recipient=recipient,
body=body, date=date_str, reply_to=reply_to, account_email=account.username,
folder=folder))
# FETCH 可能被服务器自动标已读,立即标回未读
conn.uid("STORE", uid, "-FLAGS", "\\Seen")
except Exception as e:
logger.error(" UID %s FETCH 失败,跳过: %s", uid, e)
continue
except Exception as e:
logger.warning("扫描文件夹 %s 失败: %s", folder, e)
continue
try:
conn.logout()
except Exception:
pass
logger.info("共获取 %d 封新邮件", len(emails))
return emails
@retry()
def mark_as_seen(account: EmailAccount, uids_with_folder: list[tuple[bytes, str]]):
if not uids_with_folder:
return
logger.info("标记 %d 封邮件为已读", len(uids_with_folder))
conn = _login_and_prepare(account, select_inbox=False)
# 按文件夹分组
from collections import defaultdict
by_folder: dict[str, list[bytes]] = defaultdict(list)
for uid, folder in uids_with_folder:
by_folder[folder].append(uid)
for folder, uids in by_folder.items():
try:
conn.select(folder, readonly=False)
for uid in uids:
conn.uid("STORE", uid, "+FLAGS", "\\Seen")
logger.info(" %s: 标记 %d", folder, len(uids))
except Exception as e:
logger.error(" 标记 %s 失败: %s", folder, e)
conn.logout()
logger.info("标记完成")