diff --git a/CHANGELOG.md b/CHANGELOG.md index 704227d..2df3467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 时执行保存期完整性校验。 @@ -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` 区分“可连通”与“契约完全通过”。 diff --git a/WORKSPACE.md b/WORKSPACE.md index ab4bee8..43a47cf 100644 --- a/WORKSPACE.md +++ b/WORKSPACE.md @@ -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 diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index cb02ce1..b360ee6 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -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, @@ -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, @@ -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={ @@ -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: @@ -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 @@ -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", @@ -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, @@ -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), @@ -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: @@ -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) @@ -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, @@ -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") @@ -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, @@ -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", ""), @@ -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 @@ -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", @@ -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 # 获取分组代理设置 @@ -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( @@ -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 参数无效") @@ -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 @@ -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 @@ -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", @@ -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", @@ -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", @@ -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", diff --git a/outlook_web/services/mailbox_resolver.py b/outlook_web/services/mailbox_resolver.py index df103d1..203b3e4 100644 --- a/outlook_web/services/mailbox_resolver.py +++ b/outlook_web/services/mailbox_resolver.py @@ -14,15 +14,36 @@ def _external_api_service(): return external_api_service +def normalize_alias_email(email_addr: str | None) -> str | None: + """剥离邮箱别名后缀,返回主地址。 + + Outlook/大多数邮箱服务商支持 + 子地址:user+tag@domain → user@domain。 + 本函数将 user+anything@domain 规范化为 user@domain,使系统能正确 + 将别名地址回溯到主账号。 + + 不含 + 的地址原样返回。 + """ + if email_addr is None: + return None + if not email_addr or "@" not in email_addr: + return email_addr + local, domain = email_addr.rsplit("@", 1) + if "+" in local: + local = local[: local.index("+")] + return f"{local}@{domain}" + + def resolve_mailbox(email_addr: str) -> dict[str, Any]: external_api_service = _external_api_service() - normalized_email = str(email_addr or "").strip() + normalized_email = normalize_alias_email(str(email_addr or "").strip()) or "" if not normalized_email or "@" not in normalized_email: raise external_api_service.InvalidParamError("email 参数无效") # BUG-04: accounts 与 temp_emails 同邮箱命中时,必须显式冲突(避免安全边界被绕开) account = accounts_repo.get_account_by_email(normalized_email) - temp_mailbox = temp_emails_repo.get_temp_email_by_address(normalized_email, view="descriptor") + temp_mailbox = temp_emails_repo.get_temp_email_by_address( + normalized_email, view="descriptor" + ) if account and temp_mailbox: raise external_api_service.MailboxConflictError( "邮箱冲突:accounts 与 temp_emails 同时存在", @@ -52,8 +73,12 @@ def resolve_mailbox(email_addr: str) -> dict[str, Any]: if not meta.get("provider_name"): meta["provider_name"] = "cloudflare_temp_mail" email_addr_parsed = str(account.get("email") or "").strip() - prefix = email_addr_parsed.split("@", 1)[0] if "@" in email_addr_parsed else "" - domain = email_addr_parsed.split("@", 1)[1] if "@" in email_addr_parsed else "" + prefix = ( + email_addr_parsed.split("@", 1)[0] if "@" in email_addr_parsed else "" + ) + domain = ( + email_addr_parsed.split("@", 1)[1] if "@" in email_addr_parsed else "" + ) return { "kind": "temp", "email": email_addr_parsed, @@ -77,18 +102,26 @@ def resolve_mailbox(email_addr: str) -> dict[str, Any]: return { "kind": "account", "email": normalized_email, - "source": str(account.get("provider") or account.get("account_type") or "outlook"), + "source": str( + account.get("provider") or account.get("account_type") or "outlook" + ), "provider_name": ( - "imap_generic" if str(account.get("account_type") or "").strip().lower() == "imap" else "outlook_graph" + "imap_generic" + if str(account.get("account_type") or "").strip().lower() == "imap" + else "outlook_graph" ), "status": str(account.get("status") or "active"), - "read_capability": "imap" if str(account.get("account_type") or "").strip().lower() == "imap" else "graph", + "read_capability": "imap" + if str(account.get("account_type") or "").strip().lower() == "imap" + else "graph", "meta": {"account": account}, } if temp_mailbox: return temp_mailbox - raise external_api_service.AccountNotFoundError("账号不存在", data={"email": normalized_email}) + raise external_api_service.AccountNotFoundError( + "账号不存在", data={"email": normalized_email} + ) def ensure_mailbox_can_read( @@ -102,7 +135,10 @@ def ensure_mailbox_can_read( kind = str(mailbox.get("kind") or "") if kind == "account": - allowed_emails = [str(item or "").strip().lower() for item in (consumer.get("allowed_emails") or [])] + allowed_emails = [ + str(item or "").strip().lower() + for item in (consumer.get("allowed_emails") or []) + ] target_email = str(mailbox.get("email") or "").strip().lower() if allowed_emails and target_email not in allowed_emails: raise external_api_service.EmailScopeForbiddenError( @@ -113,12 +149,20 @@ def ensure_mailbox_can_read( "consumer_name": consumer.get("name"), }, ) - return external_api_service.ensure_account_can_read((mailbox.get("meta") or {}).get("account") or {}) + return external_api_service.ensure_account_can_read( + (mailbox.get("meta") or {}).get("account") or {} + ) if kind != "temp": - raise external_api_service.AccountNotFoundError("账号不存在", data={"email": mailbox.get("email")}) + raise external_api_service.AccountNotFoundError( + "账号不存在", data={"email": mailbox.get("email")} + ) - temp_mailbox = mailbox if mailbox.get("kind") == "temp" else (mailbox.get("meta") or {}).get("temp_mailbox") or {} + temp_mailbox = ( + mailbox + if mailbox.get("kind") == "temp" + else (mailbox.get("meta") or {}).get("temp_mailbox") or {} + ) status = str(temp_mailbox.get("status") or "active").strip().lower() if status == "finished" and not allow_finished: raise external_api_service.TaskFinishedError( diff --git a/tests/test_email_alias_flow.py b/tests/test_email_alias_flow.py new file mode 100644 index 0000000..d11531b --- /dev/null +++ b/tests/test_email_alias_flow.py @@ -0,0 +1,174 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from tests._import_app import clear_login_attempts, import_web_app_module + + +class EmailAliasFlowTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + with self.app.app_context(): + clear_login_attempts() + from outlook_web.db import get_db + from outlook_web.repositories import settings as settings_repo + + db = get_db() + db.execute("DELETE FROM accounts WHERE email LIKE '%@aliasflow.test'") + db.execute("DELETE FROM audit_logs WHERE resource_type = 'external_api'") + db.commit() + settings_repo.set_setting("external_api_key", "") + + @staticmethod + def _utc_iso_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + @staticmethod + def _graph_email() -> dict: + return { + "id": "msg-1", + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "receivedDateTime": EmailAliasFlowTests._utc_iso_now(), + "isRead": False, + "hasAttachments": False, + "bodyPreview": "Your code is 123456", + } + + @staticmethod + def _graph_detail() -> dict: + return { + "id": "msg-1", + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@aliasflow.test"}}], + "receivedDateTime": EmailAliasFlowTests._utc_iso_now(), + "body": {"content": "Your code is 123456", "contentType": "text"}, + } + + def _insert_outlook_account(self, email_addr: str) -> None: + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + db.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, status, account_type, provider) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + email_addr, + "pw", + "cid-test", + "rt-test", + 1, + "active", + "outlook", + "outlook", + ), + ) + db.commit() + + def _set_external_api_key(self, value: str): + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", value) + + @staticmethod + def _auth_headers(value: str = "abc123"): + return {"X-API-Key": value} + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_messages_supports_plus_alias_email(self, mock_get_emails_graph): + self._insert_outlook_account("user@aliasflow.test") + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email()], + } + + client = self.app.test_client() + resp = client.get( + "/api/external/messages", + query_string={"email": "user+signup@aliasflow.test"}, + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_code_supports_plus_alias_email( + self, + mock_get_emails_graph, + mock_get_email_detail_graph, + mock_get_email_raw_graph, + ): + self._insert_outlook_account("user@aliasflow.test") + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email()], + } + mock_get_email_detail_graph.return_value = self._graph_detail() + mock_get_email_raw_graph.return_value = "RAW MIME CONTENT" + + client = self.app.test_client() + resp = client.get( + "/api/external/verification-code", + query_string={"email": "user+signup@aliasflow.test"}, + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json().get("data", {}).get("verification_code"), "123456" + ) + + @patch("outlook_web.services.graph.get_emails_graph") + def test_internal_get_emails_supports_plus_alias_email(self, mock_get_emails_graph): + self._insert_outlook_account("user@aliasflow.test") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email()], + } + + client = self.app.test_client() + self._login(client) + resp = client.get("/api/emails/user+signup@aliasflow.test") + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + @patch("outlook_web.services.graph.get_email_detail_graph") + def test_internal_get_email_detail_supports_plus_alias_email( + self, mock_get_email_detail_graph + ): + self._insert_outlook_account("user@aliasflow.test") + mock_get_email_detail_graph.return_value = self._graph_detail() + + client = self.app.test_client() + self._login(client) + resp = client.get("/api/email/user+signup@aliasflow.test/msg-1") + + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) diff --git a/tests/test_email_alias_migration_compat.py b/tests/test_email_alias_migration_compat.py new file mode 100644 index 0000000..4fd8455 --- /dev/null +++ b/tests/test_email_alias_migration_compat.py @@ -0,0 +1,207 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +from tests._import_app import clear_login_attempts, import_web_app_module + + +class EmailAliasMigrationCompatTests(unittest.TestCase): + """验证邮箱别名能力上线后,用户正常使用可无缝迁移。""" + + @classmethod + def setUpClass(cls): + cls.module = import_web_app_module() + cls.app = cls.module.app + + def setUp(self): + with self.app.app_context(): + clear_login_attempts() + from outlook_web.db import get_db + from outlook_web.repositories import settings as settings_repo + + db = get_db() + db.execute("DELETE FROM accounts WHERE email LIKE '%@aliascompat.test'") + db.execute("DELETE FROM audit_logs WHERE resource_type = 'external_api'") + db.commit() + + settings_repo.set_setting("external_api_key", "") + + @staticmethod + def _utc_iso_now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + @staticmethod + def _graph_email(message_id: str = "msg-1") -> dict: + return { + "id": message_id, + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "receivedDateTime": EmailAliasMigrationCompatTests._utc_iso_now(), + "isRead": False, + "hasAttachments": False, + "bodyPreview": "Your code is 123456", + } + + @staticmethod + def _graph_detail(message_id: str = "msg-1") -> dict: + return { + "id": message_id, + "subject": "Your verification code", + "from": {"emailAddress": {"address": "noreply@example.com"}}, + "toRecipients": [{"emailAddress": {"address": "user@aliascompat.test"}}], + "receivedDateTime": EmailAliasMigrationCompatTests._utc_iso_now(), + "body": {"content": "Your code is 123456", "contentType": "text"}, + } + + def _insert_outlook_account(self, email_addr: str = "user@aliascompat.test") -> str: + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + db.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, status, account_type, provider) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + email_addr, + "pw", + "cid-test", + "rt-test", + 1, + "active", + "outlook", + "outlook", + ), + ) + db.commit() + return email_addr + + def _set_external_api_key(self, value: str = "abc123"): + with self.app.app_context(): + from outlook_web.repositories import settings as settings_repo + + settings_repo.set_setting("external_api_key", value) + + @staticmethod + def _auth_headers(value: str = "abc123"): + return {"X-API-Key": value} + + def _login(self, client, password: str = "testpass123"): + resp = client.post("/login", json={"password": password}) + self.assertEqual(resp.status_code, 200) + self.assertTrue(resp.get_json().get("success")) + + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_messages_alias_and_canonical_are_equivalent( + self, mock_get_emails_graph + ): + canonical_email = self._insert_outlook_account() + alias_email = "user+signup@aliascompat.test" + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email("msg-1")], + } + + client = self.app.test_client() + + resp_canonical = client.get( + "/api/external/messages", + query_string={"email": canonical_email}, + headers=self._auth_headers(), + ) + resp_alias = client.get( + "/api/external/messages", + query_string={"email": alias_email}, + headers=self._auth_headers(), + ) + + self.assertEqual(resp_canonical.status_code, 200) + self.assertEqual(resp_alias.status_code, 200) + + data_canonical = resp_canonical.get_json().get("data", {}) + data_alias = resp_alias.get_json().get("data", {}) + self.assertEqual(data_canonical.get("count"), data_alias.get("count")) + self.assertEqual( + (data_canonical.get("emails") or [])[0].get("id"), + (data_alias.get("emails") or [])[0].get("id"), + ) + + @patch("outlook_web.services.graph.get_email_raw_graph") + @patch("outlook_web.services.graph.get_email_detail_graph") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_code_alias_and_canonical_are_equivalent( + self, + mock_get_emails_graph, + mock_get_email_detail_graph, + mock_get_email_raw_graph, + ): + canonical_email = self._insert_outlook_account() + alias_email = "user+signup@aliascompat.test" + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email("msg-1")], + } + mock_get_email_detail_graph.return_value = self._graph_detail("msg-1") + mock_get_email_raw_graph.return_value = "RAW MIME CONTENT" + + client = self.app.test_client() + + resp_canonical = client.get( + "/api/external/verification-code", + query_string={"email": canonical_email}, + headers=self._auth_headers(), + ) + resp_alias = client.get( + "/api/external/verification-code", + query_string={"email": alias_email}, + headers=self._auth_headers(), + ) + + self.assertEqual(resp_canonical.status_code, 200) + self.assertEqual(resp_alias.status_code, 200) + + data_canonical = resp_canonical.get_json().get("data", {}) + data_alias = resp_alias.get_json().get("data", {}) + self.assertEqual( + data_canonical.get("verification_code"), data_alias.get("verification_code") + ) + self.assertEqual(data_alias.get("verification_code"), "123456") + + @patch("outlook_web.services.graph.get_emails_graph") + def test_internal_get_emails_alias_and_canonical_are_equivalent( + self, mock_get_emails_graph + ): + canonical_email = self._insert_outlook_account() + alias_email = "user+signup@aliascompat.test" + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email("msg-1")], + } + + client = self.app.test_client() + self._login(client) + + resp_canonical = client.get(f"/api/emails/{canonical_email}") + resp_alias = client.get(f"/api/emails/{alias_email}") + + self.assertEqual(resp_canonical.status_code, 200) + self.assertEqual(resp_alias.status_code, 200) + + data_canonical = resp_canonical.get_json() + data_alias = resp_alias.get_json() + self.assertTrue(data_canonical.get("success")) + self.assertTrue(data_alias.get("success")) + self.assertEqual( + (data_canonical.get("emails") or [])[0].get("id"), + (data_alias.get("emails") or [])[0].get("id"), + ) diff --git a/tests/test_email_alias_normalize.py b/tests/test_email_alias_normalize.py new file mode 100644 index 0000000..e545484 --- /dev/null +++ b/tests/test_email_alias_normalize.py @@ -0,0 +1,44 @@ +from __future__ import annotations + +import unittest + +from outlook_web.services.mailbox_resolver import normalize_alias_email + + +class TestNormalizeAliasEmail(unittest.TestCase): + def test_plus_alias_stripped(self): + self.assertEqual( + normalize_alias_email("user+taobao@outlook.com"), + "user@outlook.com", + ) + + def test_plus_alias_with_multiple_plus(self): + self.assertEqual( + normalize_alias_email("user+tag1+tag2@gmail.com"), + "user@gmail.com", + ) + + def test_no_alias_unchanged(self): + self.assertEqual( + normalize_alias_email("user@outlook.com"), + "user@outlook.com", + ) + + def test_empty_and_none(self): + self.assertEqual(normalize_alias_email(""), "") + self.assertEqual(normalize_alias_email(None), None) + + def test_invalid_format(self): + self.assertEqual(normalize_alias_email("no-at-sign"), "no-at-sign") + + def test_case_preserved(self): + self.assertEqual( + normalize_alias_email("User+Tag@Outlook.COM"), + "User@Outlook.COM", + ) + + def test_dots_before_plus_preserved(self): + self.assertEqual( + normalize_alias_email("first.last+tag@outlook.com"), + "first.last@outlook.com", + ) diff --git a/tests/test_mailbox_resolver.py b/tests/test_mailbox_resolver.py index 2494f74..5009399 100644 --- a/tests/test_mailbox_resolver.py +++ b/tests/test_mailbox_resolver.py @@ -19,7 +19,9 @@ def setUp(self): db = get_db() db.execute("DELETE FROM accounts WHERE email LIKE '%@resolver.test'") - db.execute("DELETE FROM temp_email_messages WHERE email_address LIKE '%@resolver.test'") + db.execute( + "DELETE FROM temp_email_messages WHERE email_address LIKE '%@resolver.test'" + ) db.execute("DELETE FROM temp_emails WHERE email LIKE '%@resolver.test'") db.commit() @@ -53,6 +55,35 @@ def test_resolve_mailbox_returns_account_descriptor_for_regular_account(self): self.assertEqual(mailbox["email"], "user@resolver.test") self.assertEqual(mailbox["read_capability"], "graph") + def test_resolve_mailbox_supports_plus_alias_lookup(self): + with self.app.app_context(): + from outlook_web.db import get_db + from outlook_web.services import mailbox_resolver + + db = get_db() + db.execute( + """ + INSERT INTO accounts (email, password, client_id, refresh_token, group_id, status, account_type, provider) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "alias@resolver.test", + "pw", + "cid", + "rt", + 1, + "active", + "outlook", + "outlook", + ), + ) + db.commit() + + mailbox = mailbox_resolver.resolve_mailbox("alias+signup@resolver.test") + + self.assertEqual(mailbox["kind"], "account") + self.assertEqual(mailbox["email"], "alias@resolver.test") + def test_resolve_mailbox_returns_temp_descriptor_for_temp_mailbox(self): with self.app.app_context(): from outlook_web.repositories import temp_emails as temp_emails_repo