Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ All notable changes to OutlookMail Plus are documented in this file.
- **兼容账号导入模式**:OAuth Token 工具现固定面向个人 Microsoft 账号导入,要求 Public Client、`tenant=consumers`、`client_secret` 为空
- **账号一键写回**:获取到的 refresh token 可直接更新已有 Outlook 账号或创建新账号,并在写入前执行 token 有效性校验与轮换处理
- **配置持久化与环境变量开关**:支持 `oauth_tool_*` Settings 持久化、`OAUTH_TOOL_ENABLED` 总开关,以及 Client ID / Redirect URI / Scope / Tenant 的环境变量默认值
- **邮箱别名(+ 子地址)自动识别与回溯**:新增 `normalize_alias_email()`,统一将 `user+tag@domain` 规范化为 `user@domain`;在 `resolve_mailbox()`、external API 通用参数解析、内部邮件列表/邮件详情入口接入,保证别名地址可回溯到主账号且不改变无别名地址行为

- **分层口径收敛(why)**:分组策略仅保留规则项(`verification_code_length`、`verification_code_regex`),运行期 AI 配置统一迁移到系统设置(settings Basic Tab),避免“分组配置与系统配置双口径”导致的运维混乱。
- **系统级 AI 配置闭环(why)**:`GET/PUT /api/settings` 新增并承载 `verification_ai_enabled/base_url/api_key/model`;API Key 加密存储、脱敏回显;开启 AI 时执行保存期完整性校验。
Expand All @@ -50,6 +51,7 @@ All notable changes to OutlookMail Plus are documented in this file.
- 明确 `oauth_tool_client_secret` 的兼容读取策略:历史明文配置继续可读,不可解密的 `enc:` 值保持隐藏为空字符串
- 修复 Token 工具“写入账号”弹窗在校验失败或接口返回 400 时提示被主状态栏遮住、表现为“确认写入没反应”的问题,错误现已直接显示在弹窗内
- 收敛 Token 工具为兼容账号导入模式:前端禁用 `client_secret`、Tenant 固定 `consumers`、默认 Scope 切换到 IMAP 兼容预设,`prepare/config/save` 接口统一拒绝不兼容配置,避免“保存成功但运行态刷新失败”的模型错位
- 新增邮箱别名能力对应测试覆盖:`tests/test_email_alias_normalize.py`、`tests/test_email_alias_flow.py`、`tests/test_email_alias_migration_compat.py`,并补充 `tests/test_mailbox_resolver.py` 的别名回溯用例

- 新增 `POST /api/settings/verification-ai-test`:基于**已保存**的系统级 AI 配置执行连通性与契约探测,返回结构化诊断结果(`ok/error/message/latency_ms/http_status/parsed_output`)。
- 探测口径调整为“连通性优先”:上游返回 HTTP 2xx 即判定 `ok=true`;同时暴露 `connectivity_ok` 与 `contract_ok` 区分“可连通”与“契约完全通过”。
Expand Down
29 changes: 29 additions & 0 deletions WORKSPACE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,35 @@

### 操作记录

#### 41. 邮箱别名(+ 子地址)自动识别与无缝迁移测试补齐

**时间**:2026-04-13

**本次操作**:

1. 在干净分支上实现邮箱别名回溯能力
- 新增 `normalize_alias_email(email_addr)`:将 `user+tag@domain` 规范化为 `user@domain`
- 在 `resolve_mailbox()` 入口统一接入 normalize
- 在 `controllers/emails.py` 入口补齐 normalize:
- `_parse_external_common_args()`
- `api_get_emails()`
- `api_get_email_detail()`

2. 测试补齐(专属迁移场景)
- 新增 `tests/test_email_alias_normalize.py`
- 新增 `tests/test_email_alias_flow.py`
- 新增 `tests/test_email_alias_migration_compat.py`
- 补充 `tests/test_mailbox_resolver.py`:`test_resolve_mailbox_supports_plus_alias_lookup`

3. 回归结果
- `python -m unittest tests.test_email_alias_normalize tests.test_mailbox_resolver tests.test_email_alias_flow tests.test_email_alias_migration_compat -v`
- 结果:`Ran 20 tests in 7.103s`,`OK`

4. 文档同步
- `CHANGELOG.md`(v1.15.0)补充邮箱别名能力与测试覆盖说明

---

#### 40. 统一同步其他分支到 main(本地 + 远端)

**时间**:2026-04-13
Expand Down
134 changes: 103 additions & 31 deletions outlook_web/controllers/emails.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
from outlook_web.services import external_api as external_api_service
from outlook_web.services import graph as graph_service
from outlook_web.services import imap as imap_service
from outlook_web.services import verification_channel_routing as verification_channel_service
from outlook_web.services.mailbox_resolver import normalize_alias_email
from outlook_web.services import (
verification_channel_routing as verification_channel_service,
)
from outlook_web.services.imap_generic import (
get_email_detail_imap_generic_result,
get_emails_imap_generic,
Expand Down Expand Up @@ -84,7 +87,13 @@ def _build_account_credential_decrypt_failed_response(account: dict[str, Any]):
if not credential_errors:
return None

fields = sorted({str(item.get("field") or "").strip() for item in credential_errors if item.get("field")})
fields = sorted(
{
str(item.get("field") or "").strip()
for item in credential_errors
if item.get("field")
}
)
details = {
"fields": fields,
"errors": credential_errors,
Expand All @@ -108,7 +117,9 @@ def _persist_refresh_token(account: Dict[str, Any], new_refresh_token: str) -> N
account["refresh_token"] = token


def _update_account_summary_from_verification(account: Dict[str, Any], data: Dict[str, Any]) -> Dict[str, Any]:
def _update_account_summary_from_verification(
account: Dict[str, Any], data: Dict[str, Any]
) -> Dict[str, Any]:
return compact_summary_service.update_summary_from_verification(
int(account["id"]),
message={
Expand All @@ -131,6 +142,7 @@ def _update_account_summary_from_verification(account: Dict[str, Any], data: Dic
def api_get_emails(email_addr: str) -> Any:
"""获取邮件列表(支持分页,不使用缓存)"""
_t0 = time.monotonic()
email_addr = normalize_alias_email(email_addr) or ""
account = accounts_repo.get_account_by_email(email_addr)

if not account:
Expand All @@ -150,7 +162,9 @@ def api_get_emails(email_addr: str) -> Any:
# PRD-00005 / FD-00005 / TDD-00005:按 account_type 路由分发(Outlook 链路保持原样,IMAP 走通用 IMAP 服务)
account_type = (account.get("account_type") or "outlook").strip().lower()
if account_type != "imap":
decrypt_error_response = _build_account_credential_decrypt_failed_response(account)
decrypt_error_response = _build_account_credential_decrypt_failed_response(
account
)
if decrypt_error_response:
return decrypt_error_response

Expand All @@ -173,10 +187,12 @@ def api_get_emails(email_addr: str) -> Any:
result.get("success"),
)
if result.get("success"):
result["account_summary"] = compact_summary_service.update_summary_from_message_list(
int(account["id"]),
result.get("emails") or [],
folder=folder,
result["account_summary"] = (
compact_summary_service.update_summary_from_message_list(
int(account["id"]),
result.get("emails") or [],
folder=folder,
)
)
_LOGGER.debug(
"[PERF] get_emails | email=%s | 总耗时=%dms | type=imap",
Expand All @@ -197,7 +213,9 @@ def api_get_emails(email_addr: str) -> Any:

# 1. 尝试 Graph API
_t_graph = time.monotonic()
graph_result = graph_service.get_emails_graph(account["client_id"], account["refresh_token"], folder, skip, top, proxy_url)
graph_result = graph_service.get_emails_graph(
account["client_id"], account["refresh_token"], folder, skip, top, proxy_url
)
_LOGGER.debug(
"[PERF] get_emails | email=%s | graph_api | %dms | success=%s",
email_addr,
Expand All @@ -224,7 +242,9 @@ def api_get_emails(email_addr: str) -> Any:
{
"id": e.get("id"),
"subject": e.get("subject", "无主题"),
"from": e.get("from", {}).get("emailAddress", {}).get("address", "未知"),
"from": e.get("from", {})
.get("emailAddress", {})
.get("address", "未知"),
"date": e.get("receivedDateTime", ""),
"is_read": e.get("isRead", False),
"has_attachments": e.get("hasAttachments", False),
Expand Down Expand Up @@ -378,7 +398,9 @@ def api_delete_emails() -> Any:
message_ids = data.get("ids", [])

if not email_addr or not message_ids:
return build_error_response("INVALID_PARAM", "参数不完整", message_en="Missing required parameters")
return build_error_response(
"INVALID_PARAM", "参数不完整", message_en="Missing required parameters"
)

account = accounts_repo.get_account_by_email(email_addr)
if not account:
Expand Down Expand Up @@ -428,9 +450,13 @@ def api_delete_emails() -> Any:
f"删除邮件 {len(message_ids)} 封(Graph API)",
)
elif method_used == "imap_new":
log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP New)")
log_audit(
"delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP New)"
)
elif method_used == "imap_old":
log_audit("delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP Old)")
log_audit(
"delete", "email", email_addr, f"删除邮件 {len(message_ids)} 封(IMAP Old)"
)

return jsonify(response_data)

Expand All @@ -439,6 +465,7 @@ def api_delete_emails() -> Any:
def api_get_email_detail(email_addr: str, message_id: str) -> Any:
"""获取邮件详情"""
_t0 = time.monotonic()
email_addr = normalize_alias_email(email_addr) or ""
_LOGGER.debug(
"[PERF] get_email_detail | 开始 | email=%s message_id=%s",
email_addr,
Expand Down Expand Up @@ -496,7 +523,9 @@ def api_get_email_detail(email_addr: str, message_id: str) -> Any:
)
return jsonify({"success": True, "email": detail})
error_payload = detail_result.get("error") or {}
_LOGGER.warning("email_detail_imap_failed email=%s message_id=%s", email_addr, message_id)
_LOGGER.warning(
"email_detail_imap_failed email=%s message_id=%s", email_addr, message_id
)
return _build_response_from_error_payload(error_payload)

method = request.args.get("method", "graph")
Expand All @@ -510,7 +539,9 @@ def api_get_email_detail(email_addr: str, message_id: str) -> Any:
proxy_url = group.get("proxy_url", "") or ""

_t_graph = time.monotonic()
detail = graph_service.get_email_detail_graph(account["client_id"], account["refresh_token"], message_id, proxy_url)
detail = graph_service.get_email_detail_graph(
account["client_id"], account["refresh_token"], message_id, proxy_url
)
_LOGGER.debug(
"[PERF] get_email_detail | email=%s | graph_api | %dms | success=%s",
email_addr,
Expand All @@ -529,12 +560,20 @@ def api_get_email_detail(email_addr: str, message_id: str) -> Any:
"email": {
"id": detail.get("id"),
"subject": detail.get("subject", "无主题"),
"from": detail.get("from", {}).get("emailAddress", {}).get("address", "未知"),
"from": detail.get("from", {})
.get("emailAddress", {})
.get("address", "未知"),
"to": ", ".join(
[r.get("emailAddress", {}).get("address", "") for r in detail.get("toRecipients", [])]
[
r.get("emailAddress", {}).get("address", "")
for r in detail.get("toRecipients", [])
]
),
"cc": ", ".join(
[r.get("emailAddress", {}).get("address", "") for r in detail.get("ccRecipients", [])]
[
r.get("emailAddress", {}).get("address", "")
for r in detail.get("ccRecipients", [])
]
),
"date": detail.get("receivedDateTime", ""),
"body": detail.get("body", {}).get("content", ""),
Expand Down Expand Up @@ -646,12 +685,18 @@ def api_extract_verification(email_addr: str) -> Any:
)
return build_error_response(exc.code, exc.message, status=400)

policy_group = verification_policy.get("group") if isinstance(verification_policy, dict) else None
policy_group = (
verification_policy.get("group")
if isinstance(verification_policy, dict)
else None
)

# PRD-00005:IMAP 账号验证码提取走 IMAP(Generic)→ 详情 → extractor;Outlook 保持原 Graph→IMAP XOAUTH2 回退链
account_type = (account.get("account_type") or "outlook").strip().lower()
if account_type != "imap":
decrypt_error_response = _build_account_credential_decrypt_failed_response(account)
decrypt_error_response = _build_account_credential_decrypt_failed_response(
account
)
if decrypt_error_response:
return decrypt_error_response

Expand Down Expand Up @@ -742,7 +787,9 @@ def api_extract_verification(email_addr: str) -> Any:
(time.monotonic() - _t_regex) * 1000,
)
ai_config = get_verification_ai_runtime_config()
if ai_config.get("enabled") and not is_verification_ai_config_complete(ai_config):
if ai_config.get("enabled") and not is_verification_ai_config_complete(
ai_config
):
return build_error_response(
"VERIFICATION_AI_CONFIG_INCOMPLETE",
"验证码 AI 已开启,请完整填写 Base URL、API Key、模型 ID",
Expand Down Expand Up @@ -801,7 +848,9 @@ def api_extract_verification(email_addr: str) -> Any:
)
return jsonify({"success": False, "error": error_payload}), 404
except Exception as e:
error_payload = build_error_payload("EXTRACT_ERROR", "提取失败", "ExtractError", 500, str(e))
error_payload = build_error_payload(
"EXTRACT_ERROR", "提取失败", "ExtractError", 500, str(e)
)
return jsonify({"success": False, "error": error_payload}), 500

# 获取分组代理设置
Expand Down Expand Up @@ -889,7 +938,8 @@ def _parse_external_common_args(*, default_since_minutes: int | None = None) ->
推断 email 和 baseline_timestamp,并优先使用(覆盖 email 参数和 since_minutes)。
"""
claim_token = (request.args.get("claim_token") or "").strip() or None
email_addr = (request.args.get("email") or "").strip()
raw_email = (request.args.get("email") or "").strip()
email_addr = normalize_alias_email(raw_email) if raw_email else ""

# PR#27: 使用 resolve_external_mail_scope 统一处理 claim_token + email
email_addr, baseline_timestamp = external_api_service.resolve_external_mail_scope(
Expand Down Expand Up @@ -923,7 +973,9 @@ def _int_arg(name: str, default: int) -> int:
try:
since_minutes = int(since_minutes_raw)
except Exception as exc:
raise external_api_service.InvalidParamError("since_minutes 参数无效") from exc
raise external_api_service.InvalidParamError(
"since_minutes 参数无效"
) from exc
if since_minutes < 1:
raise external_api_service.InvalidParamError("since_minutes 参数无效")

Expand All @@ -948,7 +1000,11 @@ def _resolve_external_error(
resolved_status = int(exc.status)

nested_error = exc.data if isinstance(exc.data, dict) else None
if allow_nested_upstream and isinstance(exc, external_api_service.UpstreamReadFailedError) and nested_error:
if (
allow_nested_upstream
and isinstance(exc, external_api_service.UpstreamReadFailedError)
and nested_error
):
nested_code = str(nested_error.get("code") or "").strip().upper()
if nested_code in _EXTERNAL_NESTED_UPSTREAM_CODES:
resolved_code = nested_code
Expand All @@ -966,9 +1022,15 @@ def _resolve_external_error(
}


def _external_error_response(exc: external_api_service.ExternalApiError, *, allow_nested_upstream: bool = False):
def _external_error_response(
exc: external_api_service.ExternalApiError, *, allow_nested_upstream: bool = False
):
resolved = _resolve_external_error(exc, allow_nested_upstream=allow_nested_upstream)
return jsonify(external_api_service.fail(resolved["code"], resolved["message"], data=resolved["data"])), resolved["status"]
return jsonify(
external_api_service.fail(
resolved["code"], resolved["message"], data=resolved["data"]
)
), resolved["status"]


@api_key_required
Expand Down Expand Up @@ -1002,7 +1064,11 @@ def api_external_get_messages() -> Any:
details={"method": method, "count": len(filtered)},
)

return jsonify(external_api_service.ok({"emails": filtered, "count": len(filtered), "has_more": False}))
return jsonify(
external_api_service.ok(
{"emails": filtered, "count": len(filtered), "has_more": False}
)
)
except external_api_service.ExternalApiError as exc:
external_api_service.audit_external_api_access(
action="external_api_access",
Expand Down Expand Up @@ -1179,7 +1245,9 @@ def api_external_get_verification_code() -> Any:
expected_field="verification_code",
)
if not result.get("verification_code"):
raise external_api_service.VerificationCodeNotFoundError("未找到符合条件的验证码邮件")
raise external_api_service.VerificationCodeNotFoundError(
"未找到符合条件的验证码邮件"
)

external_api_service.audit_external_api_access(
action="external_api_access",
Expand Down Expand Up @@ -1238,7 +1306,9 @@ def api_external_get_verification_link() -> Any:
expected_field="verification_link",
)
if not result.get("verification_link"):
raise external_api_service.VerificationLinkNotFoundError("未找到符合条件的验证链接邮件")
raise external_api_service.VerificationLinkNotFoundError(
"未找到符合条件的验证链接邮件"
)

external_api_service.audit_external_api_access(
action="external_api_access",
Expand Down Expand Up @@ -1349,7 +1419,9 @@ def api_external_get_probe_status(probe_id: str) -> Any:
"""P2: 查询异步探测状态与结果"""
try:
result = external_api_service.get_probe_status(probe_id)
external_api_service.ensure_external_email_access(result.get("email") or "", allow_finished=True)
external_api_service.ensure_external_email_access(
result.get("email") or "", allow_finished=True
)
if result.get("status") == "cancelled":
external_api_service.audit_external_api_access(
action="external_api_access",
Expand Down
Loading
Loading