Files
outlookEmailPlus/outlook_web/services/imap_generic.py
T
胡绍康andClaude Opus 4.6 64d8a6c357 feat(multi-mailbox): 实现多邮箱统一管理功能(PRD-00005)
核心变更:
- 数据库:Schema v3,accounts 表新增 account_type/provider/imap_host/imap_port/imap_password 字段
- 后端:新增通用 IMAP 服务(imap_generic.py)与邮箱提供商配置(providers.py)
- 导入:支持 Outlook/QQ/163/Gmail/自定义 IMAP 多格式批量导入,兼容注释行与导出格式
- 导出:按邮箱类型分组导出,保留 provider 信息便于重新导入
- 邮件读取:IMAP 账号走通用 IMAP 服务,Outlook 保持原 Graph/XOAUTH2 链路
- 验证码提取:IMAP 账号通过 IMAP 获取邮件详情后提取
- 安全约束:IMAP 账号禁用远程删除,敏感字段(imap_password)加密存储
- 前端:新增邮箱类型下拉、自定义 IMAP 配置区、provider 标签显示
- 调度器:定时刷新仅处理 Outlook 账号(IMAP 无 OAuth token 刷新语义)
- 日志:配置 outlook_web 命名空间日志输出到 stderr

技术细节:
- 保持 Outlook 旧格式完全兼容(四段式:email----password----client_id----refresh_token)
- IMAP 账号使用空字符串占位 client_id/refresh_token(满足 DB NOT NULL 约束)
- 支持导出格式智能识别(5 段 custom、3 段 provider、2 段配合输入框)
- 前端 DOMPurify 未加载时回退为基本 HTML 过滤(CDN 不可达场景)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:54:39 +08:00

460 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.
from __future__ import annotations
import email
import imaplib
import logging
import re
import socket
from email.header import decode_header
from typing import Any, Dict, List, Optional, Tuple
from outlook_web.errors import build_error_payload, sanitize_error_details
from outlook_web.services.providers import get_imap_folder_candidates
_LOGGER = logging.getLogger("outlook_web.imap_generic")
def decode_header_value(header_value: str) -> str:
"""解码邮件头字段(兼容多段编码)"""
if not header_value:
return ""
try:
decoded_parts = decode_header(str(header_value))
decoded_string = ""
for part, charset in decoded_parts:
if isinstance(part, bytes):
try:
decoded_string += part.decode(
charset if charset else "utf-8", "replace"
)
except (LookupError, UnicodeDecodeError):
decoded_string += part.decode("utf-8", "replace")
else:
decoded_string += str(part)
return decoded_string
except Exception:
return str(header_value) if header_value else ""
def _strip_html(html_text: str) -> str:
if not html_text:
return ""
try:
text = re.sub(r"(?is)<script.*?>.*?</script>", " ", html_text)
text = re.sub(r"(?is)<style.*?>.*?</style>", " ", text)
text = re.sub(r"(?s)<[^>]+>", " ", text)
text = re.sub(r"\s+", " ", text)
return text.strip()
except Exception:
return html_text
def _extract_text_and_html(msg: email.message.Message) -> Tuple[str, str]:
"""提取 text/plain 与 text/html(不含附件 part)。"""
text_part = ""
html_part = ""
def _decode_payload(part: email.message.Message) -> str:
try:
payload = part.get_payload(decode=True)
charset = part.get_content_charset() or "utf-8"
if isinstance(payload, (bytes, bytearray)):
return payload.decode(charset, errors="replace")
return str(payload) if payload is not None else ""
except Exception:
try:
return str(part.get_payload())
except Exception:
return ""
if msg.is_multipart():
for part in msg.walk():
try:
content_disposition = str(part.get("Content-Disposition", "") or "")
except Exception:
content_disposition = ""
if "attachment" in content_disposition.lower():
continue
content_type = (part.get_content_type() or "").lower()
if content_type == "text/plain" and not text_part:
text_part = _decode_payload(part)
elif content_type == "text/html" and not html_part:
html_part = _decode_payload(part)
if text_part and html_part:
break
else:
content_type = (msg.get_content_type() or "").lower()
if content_type == "text/html":
html_part = _decode_payload(msg)
else:
text_part = _decode_payload(msg)
return text_part or "", html_part or ""
def _has_attachments(msg: email.message.Message) -> bool:
try:
if not msg.is_multipart():
return False
for part in msg.walk():
try:
disp = str(part.get("Content-Disposition", "") or "").lower()
except Exception:
disp = ""
if "attachment" in disp:
return True
return False
except Exception:
return False
def _extract_flags_from_fetch(fetch_item: Any) -> str:
try:
if isinstance(fetch_item, tuple) and fetch_item:
meta = fetch_item[0]
if isinstance(meta, (bytes, bytearray)):
return meta.decode("utf-8", errors="ignore")
return str(meta)
if isinstance(fetch_item, (bytes, bytearray)):
return fetch_item.decode("utf-8", errors="ignore")
return str(fetch_item)
except Exception:
return ""
def _quote_if_needed(folder_name: str) -> List[str]:
name = (folder_name or "").strip()
if not name:
return []
if name.startswith('"') and name.endswith('"'):
return [name]
if " " in name:
return [name, f'"{name}"']
return [name]
def _resolve_imap_folder(
mail: imaplib.IMAP4_SSL,
candidates: List[str],
) -> Optional[str]:
"""按优先级 SELECT 文件夹,返回第一个成功的文件夹名。"""
for folder_name in candidates or []:
for try_name in _quote_if_needed(folder_name):
try:
status, resp = mail.select(try_name, readonly=True)
_LOGGER.debug("imap_select try=%s status=%s resp=%s", try_name, status, resp)
if status == "OK":
return try_name
except Exception as exc:
_LOGGER.debug("imap_select_exception try=%s err=%s", try_name, exc)
continue
return None
def _create_imap_connection(
imap_host: str,
imap_port: int,
) -> imaplib.IMAP4_SSL:
host = (imap_host or "").strip()
port = int(imap_port) if imap_port else 993
if not host:
raise ValueError("IMAP host 不能为空")
_LOGGER.info("imap_connect host=%s port=%s", host, port)
# 优先使用 imaplib 的 timeout 参数(低风险;旧版本 Python 可能不支持)
try:
mail = imaplib.IMAP4_SSL(host, port, timeout=30)
except TypeError:
old_timeout = socket.getdefaulttimeout()
socket.setdefaulttimeout(30)
try:
mail = imaplib.IMAP4_SSL(host, port)
finally:
socket.setdefaulttimeout(old_timeout)
# 发送 IMAP ID 命令(163/126 等网易邮箱要求此命令,否则报 Unsafe Login)
try:
imaplib.Commands["ID"] = ("NONAUTH", "AUTH", "SELECTED")
mail._simple_command(
"ID",
'("name" "outlookmail" "version" "1.0" "vendor" "outlookmail")',
)
_LOGGER.debug("imap_id_sent host=%s", host)
except Exception as exc:
_LOGGER.debug("imap_id_skipped host=%s reason=%s", host, exc)
return mail
def get_emails_imap_generic(
email_addr: str,
imap_password: str,
imap_host: str,
imap_port: int = 993,
folder: str = "inbox",
provider: str = "_default",
skip: int = 0,
top: int = 20,
) -> Dict[str, Any]:
"""
标准 IMAP 邮件列表(LOGIN 认证)。
返回格式:
{
"success": True,
"emails": [...],
"method": "IMAP (Generic)",
"has_more": False
}
错误时返回:
{
"success": False,
"error": "<脱敏消息>",
"error_code": "IMAP_AUTH_FAILED|IMAP_CONNECT_FAILED|IMAP_FOLDER_NOT_FOUND"
}
"""
mail = None
try:
skip = max(0, int(skip or 0))
top = max(1, int(top or 20))
mail = _create_imap_connection(imap_host, imap_port)
try:
mail.login(email_addr, imap_password)
_LOGGER.info("imap_login_ok email=%s provider=%s", email_addr, provider)
except imaplib.IMAP4.error as e:
_LOGGER.warning("imap_login_failed email=%s provider=%s err=%s", email_addr, provider, sanitize_error_details(str(e)))
message = sanitize_error_details(str(e)) or "IMAP 认证失败"
provider_key = (provider or "").strip().lower()
if provider_key == "gmail":
message = (
"IMAP 认证失败:Gmail 通常需要“应用专用密码”(非登录密码),并在 Gmail 设置中开启 IMAP"
)
return {
"success": False,
"error": build_error_payload(
"IMAP_AUTH_FAILED",
message,
"IMAPAuthError",
401,
"",
),
"error_code": "IMAP_AUTH_FAILED",
}
candidates = get_imap_folder_candidates(provider, folder)
selected = _resolve_imap_folder(mail, candidates)
if not selected:
_LOGGER.warning(
"imap_folder_not_found email=%s provider=%s folder=%s candidates=%s",
email_addr, provider, folder, candidates,
)
return {
"success": False,
"error": build_error_payload(
"IMAP_FOLDER_NOT_FOUND",
"IMAP 文件夹不存在或无权限访问",
"IMAPFolderError",
400,
{"provider": provider, "folder": folder, "candidates": candidates},
),
"error_code": "IMAP_FOLDER_NOT_FOUND",
}
status, data = mail.uid("SEARCH", None, "ALL")
if status != "OK":
return {
"success": False,
"error": build_error_payload(
"IMAP_SEARCH_FAILED",
"IMAP 搜索邮件失败",
"IMAPSearchError",
502,
f"status={status}",
),
"error_code": "IMAP_CONNECT_FAILED",
}
uid_bytes = data[0] if data else b""
if not uid_bytes:
return {"success": True, "emails": [], "method": "IMAP (Generic)", "has_more": False}
uids = uid_bytes.split()
total = len(uids)
start_idx = max(0, total - skip - top)
end_idx = total - skip
if start_idx >= end_idx:
return {"success": True, "emails": [], "method": "IMAP (Generic)", "has_more": False}
paged_uids = uids[start_idx:end_idx][::-1]
emails_data: List[Dict[str, Any]] = []
for uid in paged_uids:
try:
f_status, f_data = mail.uid("FETCH", uid, "(FLAGS RFC822)")
if f_status != "OK" or not f_data:
continue
# 兼容不同 IMAP 服务器返回结构
raw_email = None
flags_text = ""
for item in f_data:
if not item:
continue
if isinstance(item, tuple) and len(item) >= 2:
flags_text = _extract_flags_from_fetch(item)
raw_email = item[1]
break
if not raw_email:
continue
msg = email.message_from_bytes(raw_email)
subject = decode_header_value(msg.get("Subject", "无主题"))
from_text = decode_header_value(msg.get("From", "未知发件人"))
date_text = msg.get("Date", "未知时间")
body_text, body_html = _extract_text_and_html(msg)
preview = (body_text or _strip_html(body_html))[:200]
if (body_text or body_html) and len(body_text or _strip_html(body_html)) > 200:
preview = preview + "..."
emails_data.append(
{
"id": uid.decode("utf-8", errors="ignore")
if isinstance(uid, (bytes, bytearray))
else str(uid),
"subject": subject,
"from": from_text,
"date": date_text,
"is_read": "\\Seen" in (flags_text or ""),
"has_attachments": _has_attachments(msg),
"body_preview": preview,
}
)
except Exception:
continue
return {
"success": True,
"emails": emails_data,
"method": "IMAP (Generic)",
"has_more": False,
}
except Exception as exc:
try:
_LOGGER.warning(
"imap_generic_failed provider=%s host=%s port=%s email=%s err=%s",
(provider or "").strip().lower(),
(imap_host or "").strip(),
imap_port,
(email_addr or "").strip(),
sanitize_error_details(str(exc)),
)
except Exception:
pass
return {
"success": False,
"error": build_error_payload(
"IMAP_CONNECT_FAILED",
sanitize_error_details(str(exc)) or "IMAP 连接失败",
"IMAPConnectError",
502,
"",
),
"error_code": "IMAP_CONNECT_FAILED",
}
finally:
if mail:
try:
mail.logout()
except Exception:
pass
def get_email_detail_imap_generic(
email_addr: str,
imap_password: str,
imap_host: str,
imap_port: int = 993,
message_id: str = "",
folder: str = "inbox",
provider: str = "_default",
) -> Optional[Dict[str, Any]]:
"""标准 IMAP 邮件详情(按 UID fetch),失败返回 None。"""
if not message_id:
return None
mail = None
try:
mail = _create_imap_connection(imap_host, imap_port)
try:
mail.login(email_addr, imap_password)
_LOGGER.info("imap_detail_login_ok email=%s uid=%s", email_addr, message_id)
except imaplib.IMAP4.error as e:
_LOGGER.warning("imap_detail_login_failed email=%s err=%s", email_addr, sanitize_error_details(str(e)))
return None
candidates = get_imap_folder_candidates(provider, folder)
selected = _resolve_imap_folder(mail, candidates)
if not selected:
_LOGGER.warning("imap_detail_folder_not_found email=%s folder=%s", email_addr, folder)
return None
uid = message_id.encode("utf-8") if isinstance(message_id, str) else message_id
status, data = mail.uid("FETCH", uid, "(FLAGS BODY[])")
if status != "OK" or not data:
return None
raw_email = None
flags_text = ""
for item in data:
if not item:
continue
if isinstance(item, tuple) and len(item) >= 2:
flags_text = _extract_flags_from_fetch(item)
raw_email = item[1]
break
if not raw_email:
return None
msg = email.message_from_bytes(raw_email)
body_text, body_html = _extract_text_and_html(msg)
detail: Dict[str, Any] = {
"id": message_id,
"subject": decode_header_value(msg.get("Subject", "无主题")),
"from": decode_header_value(msg.get("From", "未知发件人")),
"to": decode_header_value(msg.get("To", "")),
"cc": decode_header_value(msg.get("Cc", "")),
"date": msg.get("Date", "未知时间"),
"is_read": "\\Seen" in (flags_text or ""),
"has_attachments": _has_attachments(msg),
"body_text": body_text or "",
"body_html": body_html or "",
}
# 兼容现有前端/验证码提取:提供 body/body_type
if detail["body_html"]:
detail["body"] = detail["body_html"]
detail["body_type"] = "html"
else:
detail["body"] = detail["body_text"]
detail["body_type"] = "text"
return detail
except Exception:
return None
finally:
if mail:
try:
mail.logout()
except Exception:
pass