diff --git a/CHANGELOG.md b/CHANGELOG.md index a69da157..4a67b2a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to OutlookMail Plus are documented in this file. ### 修复 / Bug Fixes +- **验证码提取大小写被篡改**:验证码提取收口处(规则分支 `_smart_extract_code_by_keywords` / `_fallback_extract_code` 及历史函数 `smart_extract_verification_code` / `fallback_extract_verification_code`,以及 AI 回退 `enhance_verification_with_ai_fallback`)不再强制 `.upper()`,`verification_code` 与 `formatted` 保持邮件原文大小写(如 `ab12cd` 不再返回 `AB12CD`)。补充回归测试。 - **Issue #65 Watchtower 容器镜像过时**:`docker-compose.yml` 中固定 Watchtower 版本为 `containrrr/watchtower:1.7.1`,避免本地缓存的旧版镜像(内嵌 Docker 客户端 API 1.25)连接新版本 Docker Engine(要求 API 1.44+)时失败。README 新增故障排查指引。 ## [v2.7.0] - 2026-05-29 diff --git a/ant-design-pro/src/components/MailboxLayout/ResizableWorkbench.tsx b/ant-design-pro/src/components/MailboxLayout/ResizableWorkbench.tsx index 7e699ad5..22d02058 100644 --- a/ant-design-pro/src/components/MailboxLayout/ResizableWorkbench.tsx +++ b/ant-design-pro/src/components/MailboxLayout/ResizableWorkbench.tsx @@ -9,6 +9,8 @@ import { clampWidth, createDefaultLayout, loadLayoutState, + MAX_WIDTHS, + MIN_WIDTHS, PANEL_LABELS, saveLayoutState, type MailboxLayoutState, @@ -255,10 +257,16 @@ const ResizableWorkbench: React.FC = ({ }; const resizer = (forPanel: PanelKey) => ( -
onResizerDown(forPanel, e)} @@ -266,23 +274,14 @@ const ResizableWorkbench: React.FC = ({ style={{ width: 6, cursor: layout.panels[forPanel].collapsed ? 'default' : 'col-resize', - background: 'transparent', + background: + 'linear-gradient(to right, transparent 0 2px, rgba(5,5,5,0.12) 2px 4px, transparent 4px 6px)', + border: 0, + margin: 0, position: 'relative', alignSelf: 'stretch', }} - > -
-
+ /> ); return ( diff --git a/ant-design-pro/src/pages/accounts/index.tsx b/ant-design-pro/src/pages/accounts/index.tsx index 5825af93..4ebb60ff 100644 --- a/ant-design-pro/src/pages/accounts/index.tsx +++ b/ant-design-pro/src/pages/accounts/index.tsx @@ -671,7 +671,7 @@ const AccountsPage: React.FC = () => { imap_port: 993, }} onValuesChange={(changed) => { - if (Object.prototype.hasOwnProperty.call(changed, 'provider')) { + if (Object.hasOwn(changed, 'provider')) { setImportProvider(String(changed.provider || 'outlook')); } }} diff --git a/ant-design-pro/src/pages/mailbox/index.tsx b/ant-design-pro/src/pages/mailbox/index.tsx index 7ea665ea..10190810 100644 --- a/ant-design-pro/src/pages/mailbox/index.tsx +++ b/ant-design-pro/src/pages/mailbox/index.tsx @@ -541,9 +541,13 @@ const MailboxPage: React.FC = () => { const polling = !!(selectedEmail && isPolling(selectedEmail)); const pollSnapMap = useMemo(() => { const m = new Map(); - allPollSnaps.forEach((s) => m.set(s.email, s)); + allPollSnaps.forEach((s) => { + m.set(s.email, s); + }); // 保证订阅外也能读到最新 - getPollSnapshots().forEach((s) => m.set(s.email, s)); + getPollSnapshots().forEach((s) => { + m.set(s.email, s); + }); return m; }, [allPollSnaps]); @@ -935,7 +939,7 @@ const MailboxPage: React.FC = () => { // ── Compact 视图 ── const compactView = ( - + 分组 @@ -971,7 +975,6 @@ const MailboxPage: React.FC = () => { 简洁账号列表 @@ -1002,7 +1005,6 @@ const MailboxPage: React.FC = () => { } - bodyStyle={{ padding: 0 }} > {filteredCompactAccounts.length === 0 ? ( @@ -1255,7 +1257,7 @@ const MailboxPage: React.FC = () => { } > - + Compact Poll 高级 间隔(秒) diff --git a/ant-design-pro/src/requestErrorConfig.test.ts b/ant-design-pro/src/requestErrorConfig.test.ts index 28a6f352..4bd74423 100644 --- a/ant-design-pro/src/requestErrorConfig.test.ts +++ b/ant-design-pro/src/requestErrorConfig.test.ts @@ -70,7 +70,7 @@ describe('requestErrorConfig', () => { expect(error.name).toBe('BizError'); expect(error.info.errorCode).toBe(403); expect(error.info.errorMessage).toBe('Forbidden'); - expect(error.info.showType).toBe(3); + expect(error.info.showType).toBe(2); expect(error.info.data).toEqual({ detail: 'more info' }); } }); @@ -142,7 +142,7 @@ describe('requestErrorConfig', () => { errorHandler(error, {}); expect(notification.open).toHaveBeenCalledWith({ - title: 1004, + title: '1004', description: 'This is a notification', }); }); @@ -187,7 +187,7 @@ describe('requestErrorConfig', () => { errorHandler(error, {}); - expect(message.error).toHaveBeenCalledWith('Response status:500'); + expect(message.error).toHaveBeenCalledWith('请求失败'); }); it('should handle offline error', () => { diff --git a/ant-design-pro/src/services/outlook/auth.ts b/ant-design-pro/src/services/outlook/auth.ts index 1fe6d381..ed56daef 100644 --- a/ant-design-pro/src/services/outlook/auth.ts +++ b/ant-design-pro/src/services/outlook/auth.ts @@ -40,6 +40,11 @@ export type OutlookApiResult = { let csrfTokenCache: string | null = null; let csrfRefreshPromise: Promise | null = null; +type CsrfTokenPayload = { + csrf_token?: string | null; + csrf_disabled?: boolean; +}; + /** 获取 / 刷新 CSRF Token(与旧前端 main.js 行为对齐) */ export async function ensureCsrfToken(force = false): Promise { if (!force && csrfTokenCache) { @@ -51,14 +56,14 @@ export async function ensureCsrfToken(force = false): Promise { csrfRefreshPromise = (async () => { try { - const data = await request<{ - csrf_token?: string | null; - csrf_disabled?: boolean; - }>('/api/csrf-token', { + const response = (await request('/api/csrf-token', { method: 'GET', skipErrorHandler: true, credentials: 'include', - } as any); + } as any)) as unknown as CsrfTokenPayload | { data?: CsrfTokenPayload }; + const data = + ((response as { data?: CsrfTokenPayload }).data ?? + response) as CsrfTokenPayload; if (data?.csrf_disabled) { csrfTokenCache = null; diff --git a/outlook_web/controllers/external_pool.py b/outlook_web/controllers/external_pool.py index f6b216c1..8bb07a0e 100644 --- a/outlook_web/controllers/external_pool.py +++ b/outlook_web/controllers/external_pool.py @@ -82,10 +82,7 @@ def _check_pool_access(endpoint: str): ) -@api_key_required -@external_api_guards(feature="pool_claim_random") -def api_external_pool_claim_random(): - endpoint = "/api/external/pool/claim-random" +def _claim_pool_account(endpoint: str, *, require_email_domain: bool = False): disabled_resp = _check_pool_external_enabled(endpoint) if disabled_resp is not None: return disabled_resp @@ -99,6 +96,10 @@ def api_external_pool_claim_random(): project_key = body.get("project_key") email_domain = body.get("email_domain") + if require_email_domain and not str(email_domain or "").strip(): + _audit(endpoint, "error", details={"code": "EMAIL_DOMAIN_REQUIRED"}) + return jsonify(external_api_service.fail("EMAIL_DOMAIN_REQUIRED", "email_domain 不能为空")), 400 + try: account = claim_random( caller_id=caller_id, @@ -137,6 +138,18 @@ def api_external_pool_claim_random(): return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 +@api_key_required +@external_api_guards(feature="pool_claim_random") +def api_external_pool_claim_random(): + return _claim_pool_account("/api/external/pool/claim-random") + + +@api_key_required +@external_api_guards(feature="pool_claim_random") +def api_external_pool_claim_domain(): + return _claim_pool_account("/api/external/pool/claim-domain", require_email_domain=True) + + @api_key_required @external_api_guards(feature="pool_claim_release") def api_external_pool_claim_release(): diff --git a/outlook_web/controllers/token_tool.py b/outlook_web/controllers/token_tool.py index 73d02915..9289ef4a 100644 --- a/outlook_web/controllers/token_tool.py +++ b/outlook_web/controllers/token_tool.py @@ -215,8 +215,9 @@ def save_to_account() -> Any: if compatibility_error: return build_error_response("OAUTH_CONFIG_INVALID", compatibility_error, status=400) - validation_scope = (data.get("scope") or "").strip() or COMPATIBLE_SCOPE - if validation_scope == LEGACY_GRAPH_SCOPE: + explicit_scope = (data.get("scope") or data.get("requested_scope") or data.get("granted_scope") or "").strip() + validation_scope = explicit_scope or COMPATIBLE_SCOPE + if not explicit_scope and validation_scope == LEGACY_GRAPH_SCOPE: validation_scope = COMPATIBLE_SCOPE valid, error_msg, new_rt = graph_service.test_refresh_token_with_rotation( diff --git a/outlook_web/routes/external_pool.py b/outlook_web/routes/external_pool.py index c1966252..d395b762 100644 --- a/outlook_web/routes/external_pool.py +++ b/outlook_web/routes/external_pool.py @@ -20,6 +20,11 @@ def create_blueprint(csrf_exempt: Optional[Callable] = None) -> Blueprint: external_pool_controller.api_external_pool_claim_random, ["POST"], ), + ( + "/api/external/pool/claim-domain", + external_pool_controller.api_external_pool_claim_domain, + ["POST"], + ), ( "/api/external/pool/claim-release", external_pool_controller.api_external_pool_claim_release, diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 95d1e398..27f2a674 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -545,7 +545,14 @@ def fetch_and_detail_imap_with_server( if status != "OK": return {"success": True, "emails": [], "detail": None} - for i, (msg_id_str, raw_email) in enumerate(_parse_batch_fetch_response(all_data or [])): + raw_by_id = {msg_id_str: raw_email for msg_id_str, raw_email in _parse_batch_fetch_response(all_data or [])} + + for i, msg_id in enumerate(paged_ids): + msg_id_str = msg_id.decode("ascii", errors="ignore").strip() + raw_email = raw_by_id.get(msg_id_str) + if raw_email is None: + continue + msg = email.message_from_bytes(raw_email) body_preview = get_email_body(msg) email_item = { diff --git a/outlook_web/services/verification_channel_routing.py b/outlook_web/services/verification_channel_routing.py index b6243408..767b0657 100644 --- a/outlook_web/services/verification_channel_routing.py +++ b/outlook_web/services/verification_channel_routing.py @@ -408,13 +408,22 @@ def extract_verification_for_outlook( reverse=True, )[0] + latest_id = str(latest.get("id") or "") if channel.startswith("imap_"): detail = channel_result.get("detail") + detail_id = str((detail or {}).get("id") or "") + if latest_id and detail_id and detail_id != latest_id: + detail = fetch_email_detail_for_channel( + account=account, + channel=channel, + message_id=latest_id, + proxy_url=proxy_url, + ) else: detail = fetch_email_detail_for_channel( account=account, channel=channel, - message_id=latest.get("id", ""), + message_id=latest_id, proxy_url=proxy_url, ) diff --git a/outlook_web/services/verification_extractor.py b/outlook_web/services/verification_extractor.py index 340df2c8..3d0d4a7b 100644 --- a/outlook_web/services/verification_extractor.py +++ b/outlook_web/services/verification_extractor.py @@ -154,7 +154,7 @@ def smart_extract_verification_code(email_content: str) -> Optional[str]: # 过滤掉纯字母的匹配(验证码通常包含数字) for match in matches: if any(c.isdigit() for c in match): - return match.upper() + return match return None @@ -183,8 +183,6 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]: # 过滤规则 filtered = [] for match in matches: - match_upper = match.upper() - # 必须包含至少一个数字 if not any(c.isdigit() for c in match): continue @@ -209,7 +207,7 @@ def fallback_extract_verification_code(email_content: str) -> Optional[str]: if 2020 <= num <= 2030: continue - filtered.append(match_upper) + filtered.append(match) return filtered[0] if filtered else None @@ -460,7 +458,7 @@ def _smart_extract_code_by_keywords(email_content: str, code_re: re.Pattern) -> for m in code_re.finditer(context): value = m.group(0) if value and any(c.isdigit() for c in value): - return value.upper() + return value return None @@ -492,7 +490,7 @@ def _fallback_extract_code(email_content: str, code_re: re.Pattern) -> Optional[ if 2020 <= num <= 2030: continue - candidates.append(value.upper()) + candidates.append(value) return candidates[0] if candidates else None @@ -1055,7 +1053,7 @@ def _apply_output_policy(payload: Dict[str, Any]) -> Dict[str, Any]: updated = False if ai_code: - result["verification_code"] = ai_code.upper() + result["verification_code"] = ai_code result["code_confidence"] = "high" if ai_confidence == "high" else "low" updated = True if ai_link: diff --git a/registration-mail-pool-api.en.md b/registration-mail-pool-api.en.md index bc3c3006..76bad4de 100644 --- a/registration-mail-pool-api.en.md +++ b/registration-mail-pool-api.en.md @@ -104,7 +104,8 @@ Time fields use ISO 8601, for example: | Endpoint | Purpose | Recommended | | --- | --- | --- | -| `POST /api/external/pool/claim-random` | claim a mailbox | Common | +| `POST /api/external/pool/claim-random` | claim a mailbox, optionally filtered by domain | Common | +| `POST /api/external/pool/claim-domain` | claim a mailbox from a required domain | Common | | `POST /api/external/pool/claim-release` | release a mailbox | Common | | `POST /api/external/pool/claim-complete` | submit the task result | Common | | `GET /api/external/pool/stats` | inspect pool counts | Optional | @@ -324,12 +325,15 @@ Request body: | `caller_id` | string | Yes | caller instance, node, or worker identity | | `task_id` | string | Yes | unique task ID | | `provider` | string | No | provider filter: `outlook` / `imap` / `custom` / `cloudflare_temp_mail` | +| `project_key` | string | No | project-level reuse and duplicate-prevention context | +| `email_domain` | string | No | mailbox domain filter; when provided, only eligible mailboxes in that domain are claimed | Current implementation notes: -- the current pool API supports filtering only by `provider` -- `outlook.com`, `hotmail.com`, `live.com`, and `live.cn` all map to `provider=outlook` -- the current external pool API does not support extra filtering by domain, group, or tags +- `claim-random` claims an eligible mailbox randomly by default; when `email_domain` is provided, it claims randomly within that domain +- for a clearer domain-specific contract, use `POST /api/external/pool/claim-domain`; that endpoint requires `email_domain` +- `outlook.com`, `hotmail.com`, `live.com`, and `live.cn` all map to `provider=outlook`; use `email_domain` when those domains need to be distinguished +- the current external pool API does not support claiming a specific full mailbox, group, or tag - when `provider=cloudflare_temp_mail` and no eligible mailbox exists in pool, the service dynamically creates a CF temp mailbox and returns it as claimed Success response fields: @@ -391,6 +395,35 @@ No-available response example: } ``` +### `POST /api/external/pool/claim-domain` + +Purpose: claim a mailbox from a specific domain. This is the explicit endpoint for `claim-random + email_domain`; internally it reuses the same pool claim, lease, audit, and completion state machine. + +Request body: + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `caller_id` | string | Yes | caller instance, node, or worker identity | +| `task_id` | string | Yes | unique task ID | +| `email_domain` | string | Yes | mailbox domain to claim from, for example `zerodotsix.top` | +| `provider` | string | No | optional provider filter | +| `project_key` | string | No | project-level reuse and duplicate-prevention context | + +If `email_domain` is missing or blank, the endpoint returns HTTP `400` with code `EMAIL_DOMAIN_REQUIRED`. + +Copy-paste example: + +```bash +curl -X POST https://api.example.com/api/external/pool/claim-domain \ + -H "X-API-Key: YOUR_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "caller_id": "reg-worker-001", + "task_id": "task-20260409-0001", + "email_domain": "zerodotsix.top" + }' +``` + ### `POST /api/external/pool/claim-release` Request body: diff --git a/static/js/features/token_tool.js b/static/js/features/token_tool.js index c6fde9ef..02d96ecd 100644 --- a/static/js/features/token_tool.js +++ b/static/js/features/token_tool.js @@ -434,6 +434,7 @@ async function confirmSaveToAccount() { mode, refresh_token: resultData.refresh_token, client_id: resultData.client_id, + scope: resultData.requested_scope || resultData.granted_scope || '', }; if (mode === 'update') { diff --git a/tests/test_ai_fallback_trigger_condition.py b/tests/test_ai_fallback_trigger_condition.py index e9966619..dc42c4d7 100644 --- a/tests/test_ai_fallback_trigger_condition.py +++ b/tests/test_ai_fallback_trigger_condition.py @@ -193,6 +193,31 @@ def test_code_high_link_low_enforces_mutual_exclusion(self, mock_config, mock_ai self.assertEqual(result["link_confidence"], "low") +class AiFallbackCasePreservationTests(unittest.TestCase): + """验证码大小写:AI fallback 返回的验证码必须保持原大小写""" + + @patch("outlook_web.services.verification_extractor._call_verification_ai") + @patch("outlook_web.services.verification_extractor.get_verification_ai_runtime_config") + def test_ai_code_case_preserved(self, mock_config, mock_ai_call): + """AI 返回小写 code → 结果保持小写,不转大写""" + mock_config.return_value = _AI_CONFIG + mock_ai_call.return_value = { + "schema_version": "verification_ai_v1", + "verification_code": "cbdfdb", + "verification_link": "", + "confidence": "high", + "reason": "test", + } + extracted = _make_extracted("low", "low") + + result = extractor.enhance_verification_with_ai_fallback(email=_EMAIL_OBJ, extracted=extracted) + + mock_ai_call.assert_called_once() + self.assertTrue(result.get("ai_used")) + self.assertEqual(result["verification_code"], "cbdfdb") + self.assertEqual(result["formatted"], "cbdfdb") + + class AiFallbackEdgeCaseTests(unittest.TestCase): """边界情况:缺失 confidence 字段时默认为 low""" diff --git a/tests/test_external_pool.py b/tests/test_external_pool.py index 4b099ec8..303bc118 100644 --- a/tests/test_external_pool.py +++ b/tests/test_external_pool.py @@ -280,6 +280,7 @@ def fake_csrf_exempt(handler): set(wrapped_handlers), { "api_external_pool_claim_random", + "api_external_pool_claim_domain", "api_external_pool_claim_release", "api_external_pool_claim_complete", "api_external_pool_stats", diff --git a/tests/test_imap_connection_reuse.py b/tests/test_imap_connection_reuse.py index c4845e8d..adc726a7 100644 --- a/tests/test_imap_connection_reuse.py +++ b/tests/test_imap_connection_reuse.py @@ -194,6 +194,32 @@ def test_imap_connection_created_only_once(self, mock_token, mock_imap_cls): self.assertEqual(mock_imap_cls.call_count, 1) self.assertEqual(mock_conn.authenticate.call_count, 1) + @patch("outlook_web.services.imap.imaplib.IMAP4_SSL") + @patch("outlook_web.services.imap.get_access_token_imap_result") + def test_batch_fetch_response_is_reordered_to_requested_latest_first(self, mock_token, mock_imap_cls): + from outlook_web.services.imap import fetch_and_detail_imap_with_server + + mock_token.return_value = self._mock_token_result(True) + raw_old = _build_rfc822_bytes("Old code", "Your code is 990595") + raw_middle = _build_rfc822_bytes("Middle code", "Your code is 118658") + raw_new = _build_rfc822_bytes("New code", "Your code is 701280") + mock_conn = self._setup_imap_mock(mock_imap_cls, search_ids=[b"1", b"2", b"3", b"4", b"5"]) + mock_conn.fetch.return_value = ( + "OK", + [ + ((b"3 (RFC822)", raw_old), b")"), + ((b"4 (RFC822)", raw_middle), b")"), + ((b"5 (RFC822)", raw_new), b")"), + ], + ) + + result = fetch_and_detail_imap_with_server("user@test.com", "cid", "rt", folder="inbox", top=3) + + self.assertTrue(result.get("success")) + self.assertEqual([item["id"] for item in result.get("emails", [])], ["5", "4", "3"]) + self.assertEqual(result.get("detail", {}).get("id"), "5") + self.assertIn("701280", result.get("detail", {}).get("body", "")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_oauth_tool.py b/tests/test_oauth_tool.py index 9e62c664..cb43a67e 100644 --- a/tests/test_oauth_tool.py +++ b/tests/test_oauth_tool.py @@ -898,7 +898,31 @@ def test_save_uses_consumers_and_imap_scope_for_validation(self, mock_test_rt): ) @patch("outlook_web.services.graph.test_refresh_token_with_rotation") - def test_save_maps_legacy_graph_scope_to_imap_validation_scope(self, mock_test_rt): + def test_save_uses_explicit_token_scope_for_validation(self, mock_test_rt): + mock_test_rt.return_value = (True, None, None) + with self.app.test_client() as client: + self._login(client) + resp = client.post( + "/api/token-tool/save", + json={ + "mode": "create", + "email": "explicit-scope@oauth-test.com", + "client_id": "new-cid", + "refresh_token": "new-rt", + "scope": "Mail.Read User.Read offline_access openid profile", + }, + ) + self.assertEqual(resp.status_code, 200) + + _args, kwargs = mock_test_rt.call_args + self.assertEqual(kwargs.get("tenant"), "consumers") + self.assertEqual( + kwargs.get("scope"), + "Mail.Read User.Read offline_access openid profile", + ) + + @patch("outlook_web.services.graph.test_refresh_token_with_rotation") + def test_save_preserves_explicit_graph_default_scope_for_validation(self, mock_test_rt): mock_test_rt.return_value = (True, None, None) with self.app.test_client() as client: self._login(client) @@ -918,7 +942,7 @@ def test_save_maps_legacy_graph_scope_to_imap_validation_scope(self, mock_test_r self.assertEqual(kwargs.get("tenant"), "consumers") self.assertEqual( kwargs.get("scope"), - "offline_access https://outlook.office.com/IMAP.AccessAsUser.All", + "offline_access https://graph.microsoft.com/.default", ) @patch("outlook_web.services.graph.test_refresh_token_with_rotation") diff --git a/tests/test_pool_flow_suite.py b/tests/test_pool_flow_suite.py index 17b70779..92594abe 100644 --- a/tests/test_pool_flow_suite.py +++ b/tests/test_pool_flow_suite.py @@ -295,6 +295,39 @@ def test_claim_response_includes_email_domain_and_claimed_at(self): self.assertIn("claimed_at", data["data"]) self.assertIsNotNone(data["data"]["claimed_at"]) + def test_claim_domain_requires_email_domain(self): + resp = self.client.post( + "/api/external/pool/claim-domain", + headers=self._auth_headers(), + json={"caller_id": "domain_bot", "task_id": "domain_required"}, + ) + self.assertEqual(resp.status_code, 400) + data = json.loads(resp.data) + self.assertFalse(data["success"]) + self.assertEqual(data["code"], "EMAIL_DOMAIN_REQUIRED") + + def test_claim_domain_filters_to_requested_domain(self): + requested_domain = f"requested_{uuid.uuid4().hex[:8]}.test" + other_domain = f"other_{uuid.uuid4().hex[:8]}.test" + requested = self._make_pool_account(email_domain=requested_domain) + other = self._make_pool_account(email_domain=other_domain) + + resp = self.client.post( + "/api/external/pool/claim-domain", + headers=self._auth_headers(), + json={ + "caller_id": "domain_bot", + "task_id": "domain_claim", + "email_domain": requested_domain.upper(), + }, + ) + self.assertEqual(resp.status_code, 200) + data = json.loads(resp.data) + self.assertTrue(data["success"]) + self.assertEqual(data["data"]["account_id"], requested["id"]) + self.assertEqual(data["data"]["email_domain"], requested_domain) + self.assertNotEqual(data["data"]["account_id"], other["id"]) + def test_claim_with_project_key_prevents_same_project_reuse_without_manual_status_reset(self): """同 caller_id + project_key 下,新语义应原生阻止再次领取,无需手工改状态。""" # 使用唯一的 email_domain 隔离测试数据 diff --git a/tests/test_verification_extract_log.py b/tests/test_verification_extract_log.py index 1641f3a0..3596322e 100644 --- a/tests/test_verification_extract_log.py +++ b/tests/test_verification_extract_log.py @@ -327,3 +327,73 @@ def test_log_channel_is_ai_fallback_when_ai_is_used(self): self.assertIn("_log_channel", result) self.assertEqual(result["_log_channel"], "ai_fallback") + + def test_imap_detail_mismatch_refetches_latest_message_detail(self): + """IMAP 连接复用返回的 detail 与最新邮件不一致时,应按 latest.id 重新取详情。""" + with self.app.app_context(): + from outlook_web.services import verification_channel_routing as vcr + + fake_account = { + "id": 3, + "email": "imap@outlook.com", + "account_type": "outlook", + "provider": "outlook", + "group_id": None, + "preferred_verification_channel": "imap_new", + "client_id": "cid", + "refresh_token": "rt", + } + fake_channel_result = { + "success": True, + "emails": [ + { + "id": "3", + "subject": "Old code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:01:15 +0000", + }, + { + "id": "5", + "subject": "New code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:38:27 +0000", + }, + ], + "detail": { + "id": "3", + "subject": "Old code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:01:15 +0000", + "body": "Your code is 990595", + }, + } + latest_detail = { + "id": "5", + "subject": "New code", + "from": "OpenAI", + "date": "Tue, 19 May 2026 10:38:27 +0000", + "body": "Your code is 701280", + } + + with ( + patch.object(vcr, "build_verification_channel_plan", return_value=["imap_new"]), + patch.object(vcr, "fetch_emails_and_detail_for_channel", return_value=fake_channel_result), + patch.object(vcr, "fetch_email_detail_for_channel", return_value=latest_detail) as mock_fetch_detail, + patch( + "outlook_web.services.graph.get_access_token_graph_result", + return_value={"success": False}, + ), + patch("outlook_web.repositories.accounts.update_preferred_verification_channel"), + ): + result = vcr.extract_verification_for_outlook( + account=fake_account, + resolved_policy={"code_regex": r"(?