diff --git a/outlook_web/services/imap.py b/outlook_web/services/imap.py index 2df6d17..43d6e5e 100644 --- a/outlook_web/services/imap.py +++ b/outlook_web/services/imap.py @@ -472,7 +472,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 b624340..767b065 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/tests/test_imap_connection_reuse.py b/tests/test_imap_connection_reuse.py index c4845e8..adc726a 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_verification_extract_log.py b/tests/test_verification_extract_log.py index 1641f3a..97d0b23 100644 --- a/tests/test_verification_extract_log.py +++ b/tests/test_verification_extract_log.py @@ -327,3 +327,74 @@ 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"(?