核心变更: - 数据库: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>
460 lines
15 KiB
Python
460 lines
15 KiB
Python
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
|