diff --git a/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md b/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md index 97c5ee5..3882ee0 100644 --- a/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md +++ b/docs/LOG/LOG-00008-对外验证码与邮件读取开放API-执行日志.md @@ -118,3 +118,33 @@ python -m flask --app web_outlook_app run --host 127.0.0.1 --port 5055 - 写入对外 API Key:`PUT /api/settings {"external_api_key":"abc123"}` → 200 - 读取设置脱敏:`GET /api/settings` → `external_api_key_set=true` 且 `external_api_key_masked` 非明文 - 外部健康检查:`GET /api/external/health`(Header `X-API-Key: abc123`)→ 200 `code=OK` + +## 执行记录(2026-03-10) + +### 8) 对齐 PRD 语义偏差并补强回归用例 + +- 修复 `verification-code` / `verification-link` + - 未显式传入 `since_minutes` 时,默认按 PRD 使用最近 `10` 分钟窗口 + - 避免命中过期历史验证码/历史验证链接 +- 修复 `wait-message` + - 由“存在匹配邮件即返回”改为“仅当调用开始后出现的新邮件才返回” + - 保留超时上限 `120` 秒与同步轮询实现 +- 修复 Graph 链路 RAW 内容 + - 新增 Graph MIME 读取函数,优先使用 `/me/messages/{id}/$value` + - `messages/{id}` 与 `messages/{id}/raw` 在 Graph 成功时返回真实 MIME 内容,而非仅正文 +- 补齐审计日志闭环 + - 新增 `/api/external/messages/{id}/raw` + - 新增 `/api/external/health` + - 新增 `/api/external/capabilities` + - 新增 `/api/external/account-status` +- 补充回归测试 + - `messages/latest` 筛选与最新命中 + - `verification-*` 默认 10 分钟窗口 + - `wait-message` 仅返回新邮件 + - Graph RAW 内容与外部审计日志写入 + +验证: +- `python -m unittest tests.test_external_api -v` ✅ +- `python -m unittest tests.test_settings_external_api_key -v` ✅ +- `python -m unittest tests.test_verification_extractor_options -v` ✅ +- `python -m unittest discover -s tests -v` ✅(256 tests) diff --git a/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md b/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md index 20218e5..8a3d5ac 100644 --- a/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md +++ b/docs/TEST/TEST-00008-对外验证码与邮件读取开放API-测试文档.md @@ -764,3 +764,23 @@ curl -H "X-API-Key: your-api-key" "http://localhost:5000/api/external/verificati 3. 鉴权与配置链路闭环 4. 核心错误码与 OpenAPI 一致 5. 手动联调可完成“配置 → 健康检查 → 查邮件 → 取验证码/链接”全流程 + +--- + +## 15. 2026-03-10 增补回归重点 + +本轮修复后,PRD-00008 相关回归必须额外覆盖以下场景: + +1. `verification-code` / `verification-link` 在未传 `since_minutes` 时,默认仅扫描最近 `10` 分钟邮件。 +2. `wait-message` 只能返回调用开始后到达的新邮件,历史旧邮件不能立即命中。 +3. Graph 成功链路下,`/api/external/messages/{message_id}` 与 `/raw` 返回的 `raw_content` 应优先为 MIME RAW,而不是正文字符串。 +4. `/api/external/messages/{message_id}/raw`、`/health`、`/capabilities`、`/account-status` 都必须写入 `audit_logs(resource_type='external_api')`。 + +建议执行顺序: + +```bash +python -m unittest tests.test_external_api -v +python -m unittest tests.test_settings_external_api_key -v +python -m unittest tests.test_verification_extractor_options -v +python -m unittest discover -s tests -v +``` diff --git a/outlook_web/controllers/emails.py b/outlook_web/controllers/emails.py index 6da9696..027b8e4 100644 --- a/outlook_web/controllers/emails.py +++ b/outlook_web/controllers/emails.py @@ -581,7 +581,7 @@ def api_extract_verification(email_addr: str) -> Any: # ==================== External Emails API ==================== -def _parse_external_common_args() -> dict: +def _parse_external_common_args(*, default_since_minutes: int | None = None) -> dict: """解析 external API 通用 query 参数(按 TDD-00008 做基础校验)。""" email_addr = (request.args.get("email") or "").strip() if not email_addr or "@" not in email_addr: @@ -608,7 +608,7 @@ def _parse_external_common_args() -> dict: raise external_api_service.InvalidParamError("top 参数无效") since_minutes_raw = request.args.get("since_minutes", None) - since_minutes = None + since_minutes = default_since_minutes if since_minutes_raw not in (None, ""): try: since_minutes = int(since_minutes_raw) @@ -763,6 +763,13 @@ def api_external_get_message_raw(message_id: str) -> Any: message_id=message_id, folder=args["folder"], ) + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=args["email"] or "", + endpoint="/api/external/messages/{message_id}/raw", + status="ok", + details={"method": detail.get("method")}, + ) return jsonify( external_api_service.ok( { @@ -774,15 +781,29 @@ def api_external_get_message_raw(message_id: str) -> Any: ) ) except external_api_service.ExternalApiError as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/{message_id}/raw", + status="error", + details={"code": exc.code}, + ) return _external_error_response(exc) - except Exception: + except Exception as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=(request.args.get("email") or "").strip(), + endpoint="/api/external/messages/{message_id}/raw", + status="error", + details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, + ) return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 @api_key_required def api_external_get_verification_code() -> Any: try: - args = _parse_external_common_args() + args = _parse_external_common_args(default_since_minutes=10) code_length = (request.args.get("code_length") or "").strip() or None code_regex = (request.args.get("code_regex") or "").strip() or None code_source = (request.args.get("code_source") or "all").strip().lower() @@ -828,7 +849,7 @@ def api_external_get_verification_code() -> Any: @api_key_required def api_external_get_verification_link() -> Any: try: - args = _parse_external_common_args() + args = _parse_external_common_args(default_since_minutes=10) result = external_api_service.get_verification_result( email_addr=args["email"], folder=args["folder"], diff --git a/outlook_web/controllers/system.py b/outlook_web/controllers/system.py index 1af837d..9ab81b4 100644 --- a/outlook_web/controllers/system.py +++ b/outlook_web/controllers/system.py @@ -272,7 +272,23 @@ def api_external_health() -> Any: "server_time_utc": utcnow().isoformat() + "Z", "database": "ok" if db_ok else "error", } + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr="", + endpoint="/api/external/health", + status="ok", + details={"database": data["database"]}, + ) return jsonify(external_api_service.ok(data)) + except Exception as exc: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr="", + endpoint="/api/external/health", + status="error", + details={"code": "INTERNAL_ERROR", "err": type(exc).__name__}, + ) + return jsonify(external_api_service.fail("INTERNAL_ERROR", "服务内部错误")), 500 finally: conn.close() @@ -292,6 +308,13 @@ def api_external_capabilities() -> Any: "wait_message", ], } + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr="", + endpoint="/api/external/capabilities", + status="ok", + details={"feature_count": len(data["features"])}, + ) return jsonify(external_api_service.ok(data)) @@ -300,10 +323,24 @@ def api_external_account_status() -> Any: """对外账号状态检查""" email_addr = (request.args.get("email") or "").strip() if not email_addr or "@" not in email_addr: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=email_addr, + endpoint="/api/external/account-status", + status="error", + details={"code": "INVALID_PARAM"}, + ) return jsonify(external_api_service.fail("INVALID_PARAM", "email 参数不合法")), 400 account = accounts_repo.get_account_by_email(email_addr) if not account: + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=email_addr, + endpoint="/api/external/account-status", + status="error", + details={"code": "ACCOUNT_NOT_FOUND"}, + ) return jsonify(external_api_service.fail("ACCOUNT_NOT_FOUND", "账号不存在", data={"email": email_addr})), 404 account_type = (account.get("account_type") or "outlook").strip().lower() @@ -317,18 +354,22 @@ def api_external_account_status() -> Any: else: can_read = bool((account.get("client_id") or "").strip()) and bool((account.get("refresh_token") or "").strip()) - return jsonify( - external_api_service.ok( - { - "email": email_addr, - "exists": True, - "account_type": account_type, - "provider": provider, - "group_id": account.get("group_id"), - "status": account.get("status"), - "last_refresh_at": account.get("last_refresh_at"), - "preferred_method": preferred_method, - "can_read": can_read, - } - ) + data = { + "email": email_addr, + "exists": True, + "account_type": account_type, + "provider": provider, + "group_id": account.get("group_id"), + "status": account.get("status"), + "last_refresh_at": account.get("last_refresh_at"), + "preferred_method": preferred_method, + "can_read": can_read, + } + external_api_service.audit_external_api_access( + action="external_api_access", + email_addr=email_addr, + endpoint="/api/external/account-status", + status="ok", + details={"preferred_method": preferred_method, "can_read": can_read}, ) + return jsonify(external_api_service.ok(data)) diff --git a/outlook_web/services/external_api.py b/outlook_web/services/external_api.py index c67631f..bc88a82 100644 --- a/outlook_web/services/external_api.py +++ b/outlook_web/services/external_api.py @@ -384,6 +384,14 @@ def get_message_detail_for_external( proxy_url, ) method_label = "Graph API" + graph_raw_content = None + if detail: + graph_raw_content = graph_service.get_email_raw_graph( + account.get("client_id") or "", + account.get("refresh_token") or "", + message_id, + proxy_url, + ) if not detail: detail = imap_service.get_email_detail_imap_with_server( email_addr, @@ -420,7 +428,7 @@ def get_message_detail_for_external( html_content = body_content if body_type == "html" else "" content = body_content if body_type == "text" else extract_email_text({"body_html": html_content}) - raw_content = body_content + raw_content = str(graph_raw_content or body_content) from_address = (detail.get("from") or {}).get("emailAddress", {}).get("address", "") to_address = ",".join([r.get("emailAddress", {}).get("address", "") for r in (detail.get("toRecipients") or [])]) @@ -519,17 +527,20 @@ def wait_for_message( if poll_interval <= 0 or poll_interval > timeout_seconds: raise InvalidParamError("poll_interval 参数无效") + baseline_timestamp = int(time.time()) start = time.time() last_error: Optional[ExternalApiError] = None while True: try: - return get_latest_message_for_external( + latest_message = get_latest_message_for_external( email_addr=email_addr, folder=folder, from_contains=from_contains, subject_contains=subject_contains, since_minutes=since_minutes, ) + if int(latest_message.get("timestamp") or 0) >= baseline_timestamp: + return latest_message except MailNotFoundError as exc: last_error = exc diff --git a/outlook_web/services/graph.py b/outlook_web/services/graph.py index 29a230e..b17a19e 100644 --- a/outlook_web/services/graph.py +++ b/outlook_web/services/graph.py @@ -183,6 +183,35 @@ def get_email_detail_graph( return None +def get_email_raw_graph( + client_id: str, + refresh_token: str, + message_id: str, + proxy_url: str = None, +) -> Optional[str]: + """使用 Graph API 获取邮件 MIME RAW 内容。""" + access_token = get_access_token_graph(client_id, refresh_token, proxy_url) + if not access_token: + return None + + try: + url = f"https://graph.microsoft.com/v1.0/me/messages/{message_id}/$value" + headers = { + "Authorization": f"Bearer {access_token}", + } + + proxies = build_proxies(proxy_url) + res = requests.get(url, headers=headers, timeout=30, proxies=proxies) + + if res.status_code != 200: + return None + + res.encoding = res.encoding or "utf-8" + return res.text + except Exception: + return None + + def test_refresh_token(client_id: str, refresh_token: str, proxy_url: str = None) -> tuple[bool, str | None]: """测试 refresh token 是否有效,返回 (是否成功, 错误信息)""" ok, err, _new_refresh_token = test_refresh_token_with_rotation(client_id, refresh_token, proxy_url) diff --git a/tests/test_external_api.py b/tests/test_external_api.py index 3d8010d..91f723c 100644 --- a/tests/test_external_api.py +++ b/tests/test_external_api.py @@ -1,6 +1,7 @@ import json import unittest import uuid +from datetime import datetime, timedelta, timezone from unittest.mock import patch from tests._import_app import clear_login_attempts, import_web_app_module @@ -74,30 +75,60 @@ class ExternalApiBaseTest(unittest.TestCase): self.assertTrue(resp.get_json().get("success")) @staticmethod - def _graph_email(message_id: str = "msg-1", subject: str = "Your verification code", sender: str = "noreply@example.com"): + def _utc_iso(minutes_delta: int = 0) -> str: + dt = datetime.now(timezone.utc).replace(microsecond=0) + timedelta(minutes=minutes_delta) + return dt.isoformat().replace("+00:00", "Z") + + @classmethod + def _graph_email( + cls, + message_id: str = "msg-1", + subject: str = "Your verification code", + sender: str = "noreply@example.com", + received_at: str | None = None, + ): return { "id": message_id, "subject": subject, "from": {"emailAddress": {"address": sender}}, - "receivedDateTime": "2026-03-08T12:00:00Z", + "receivedDateTime": received_at or cls._utc_iso(), "isRead": False, "hasAttachments": False, "bodyPreview": "Your code is 123456", } - @staticmethod + @classmethod def _graph_detail( - message_id: str = "msg-1", body_text: str = "Your code is 123456", html_text: str = "

Your code is 123456

" + cls, + message_id: str = "msg-1", + body_text: str = "Your code is 123456", + html_text: str = "

Your code is 123456

", + received_at: str | None = None, ): return { "id": message_id, "subject": "Your verification code", "from": {"emailAddress": {"address": "noreply@example.com"}}, "toRecipients": [{"emailAddress": {"address": "user@outlook.com"}}], - "receivedDateTime": "2026-03-08T12:00:00Z", + "receivedDateTime": received_at or cls._utc_iso(), "body": {"content": body_text if body_text else html_text, "contentType": "text" if body_text else "html"}, } + def _external_audit_logs(self): + with self.app.app_context(): + from outlook_web.db import get_db + + db = get_db() + rows = db.execute( + """ + SELECT action, resource_id, details + FROM audit_logs + WHERE resource_type = 'external_api' + ORDER BY id ASC + """ + ).fetchall() + return [dict(row) for row in rows] + class ExternalApiAuthTests(ExternalApiBaseTest): def test_external_health_requires_api_key(self): @@ -132,6 +163,25 @@ class ExternalApiAuthTests(ExternalApiBaseTest): class ExternalApiMessageTests(ExternalApiBaseTest): + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_latest_message_returns_filtered_latest_email(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + newer = self._graph_email(message_id="msg-new", subject="Target mail", received_at=self._utc_iso()) + older = self._graph_email(message_id="msg-old", subject="Ignore mail", received_at=self._utc_iso(minutes_delta=-2)) + mock_get_emails_graph.return_value = {"success": True, "emails": [older, newer]} + + client = self.app.test_client() + resp = client.get( + f"/api/external/messages/latest?email={email_addr}&subject_contains=Target", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertTrue(data.get("success")) + self.assertEqual(data.get("data", {}).get("id"), "msg-new") + def test_external_messages_returns_account_not_found(self): client = self.app.test_client() self._set_external_api_key("abc123") @@ -194,11 +244,13 @@ class ExternalApiMessageTests(ExternalApiBaseTest): self.assertTrue(data.get("success")) self.assertEqual(len(data.get("data", {}).get("emails", [])), 1) + @patch("outlook_web.services.graph.get_email_raw_graph") @patch("outlook_web.services.graph.get_email_detail_graph") - def test_external_message_detail_returns_message_content(self, mock_get_email_detail_graph): + def test_external_message_detail_returns_message_content(self, mock_get_email_detail_graph, mock_get_email_raw_graph): email_addr = self._insert_outlook_account() self._set_external_api_key("abc123") 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( @@ -211,12 +263,15 @@ class ExternalApiMessageTests(ExternalApiBaseTest): self.assertTrue(data.get("success")) self.assertIn("content", data.get("data", {})) self.assertIn("raw_content", data.get("data", {})) + self.assertEqual(data.get("data", {}).get("raw_content"), "RAW MIME CONTENT") + @patch("outlook_web.services.graph.get_email_raw_graph") @patch("outlook_web.services.graph.get_email_detail_graph") - def test_external_message_raw_returns_raw_content(self, mock_get_email_detail_graph): + def test_external_message_raw_returns_raw_content_and_audits(self, mock_get_email_detail_graph, mock_get_email_raw_graph): email_addr = self._insert_outlook_account() self._set_external_api_key("abc123") mock_get_email_detail_graph.return_value = self._graph_detail(body_text="raw test") + mock_get_email_raw_graph.return_value = "MIME-Version: 1.0\r\nraw test" client = self.app.test_client() resp = client.get( @@ -227,17 +282,28 @@ class ExternalApiMessageTests(ExternalApiBaseTest): self.assertEqual(resp.status_code, 200) data = resp.get_json() self.assertTrue(data.get("success")) - self.assertIn("raw_content", data.get("data", {})) + self.assertEqual(data.get("data", {}).get("raw_content"), "MIME-Version: 1.0\r\nraw test") + + audit_logs = self._external_audit_logs() + self.assertEqual(len(audit_logs), 1) + self.assertIn("/api/external/messages/{message_id}/raw", audit_logs[0]["details"]) class ExternalApiVerificationTests(ExternalApiBaseTest): + @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_returns_code(self, mock_get_emails_graph, mock_get_email_detail_graph): + def test_external_verification_code_returns_code( + self, + mock_get_emails_graph, + mock_get_email_detail_graph, + mock_get_email_raw_graph, + ): email_addr = self._insert_outlook_account() 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(body_text="Your code is 123456") + mock_get_email_raw_graph.return_value = "RAW MIME CONTENT" client = self.app.test_client() resp = client.get( @@ -250,9 +316,33 @@ class ExternalApiVerificationTests(ExternalApiBaseTest): self.assertTrue(data.get("success")) self.assertEqual(data.get("data", {}).get("verification_code"), "123456") + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_code_defaults_to_recent_10_minutes(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email(received_at=self._utc_iso(minutes_delta=-20))], + } + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-code?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.get_json().get("code"), "MAIL_NOT_FOUND") + + @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_link_returns_preferred_link(self, mock_get_emails_graph, mock_get_email_detail_graph): + def test_external_verification_link_returns_preferred_link( + self, + mock_get_emails_graph, + mock_get_email_detail_graph, + mock_get_email_raw_graph, + ): email_addr = self._insert_outlook_account() self._set_external_api_key("abc123") mock_get_emails_graph.return_value = { @@ -262,6 +352,7 @@ class ExternalApiVerificationTests(ExternalApiBaseTest): mock_get_email_detail_graph.return_value = self._graph_detail( body_text="Click https://example.com/verify?token=abc to continue", ) + mock_get_email_raw_graph.return_value = "RAW MIME CONTENT" client = self.app.test_client() resp = client.get( @@ -274,6 +365,41 @@ class ExternalApiVerificationTests(ExternalApiBaseTest): self.assertTrue(data.get("success")) self.assertIn("verify", data.get("data", {}).get("verification_link", "")) + @patch("outlook_web.services.graph.get_emails_graph") + def test_external_verification_link_defaults_to_recent_10_minutes(self, mock_get_emails_graph): + email_addr = self._insert_outlook_account() + self._set_external_api_key("abc123") + mock_get_emails_graph.return_value = { + "success": True, + "emails": [self._graph_email(subject="Please verify your email", received_at=self._utc_iso(minutes_delta=-30))], + } + + client = self.app.test_client() + resp = client.get( + f"/api/external/verification-link?email={email_addr}", + headers=self._auth_headers(), + ) + + self.assertEqual(resp.status_code, 404) + self.assertEqual(resp.get_json().get("code"), "MAIL_NOT_FOUND") + + @patch("outlook_web.services.external_api.time.sleep") + @patch("outlook_web.services.external_api.time.time") + @patch("outlook_web.services.external_api.get_latest_message_for_external") + def test_wait_for_message_only_returns_new_messages(self, mock_get_latest_message, mock_time, mock_sleep): + from outlook_web.services import external_api as external_api_service + + mock_time.side_effect = [100, 100, 100] + mock_get_latest_message.side_effect = [ + {"id": "old", "timestamp": 99, "method": "Graph API"}, + {"id": "new", "timestamp": 101, "method": "Graph API"}, + ] + + result = external_api_service.wait_for_message(email_addr="user@example.com", timeout_seconds=30, poll_interval=5) + + self.assertEqual(result.get("id"), "new") + mock_sleep.assert_called_once_with(5) + def test_external_wait_message_rejects_too_large_timeout(self): email_addr = self._insert_outlook_account() self._set_external_api_key("abc123") @@ -289,7 +415,7 @@ class ExternalApiVerificationTests(ExternalApiBaseTest): class ExternalApiSystemTests(ExternalApiBaseTest): - def test_external_capabilities_returns_feature_list(self): + def test_external_capabilities_returns_feature_list_and_audits(self): client = self.app.test_client() self._set_external_api_key("abc123") @@ -302,7 +428,22 @@ class ExternalApiSystemTests(ExternalApiBaseTest): self.assertIn("version", data.get("data", {})) self.assertIn("features", data.get("data", {})) - def test_external_account_status_returns_account_data(self): + audit_logs = self._external_audit_logs() + self.assertEqual(len(audit_logs), 1) + self.assertIn("/api/external/capabilities", audit_logs[0]["details"]) + + def test_external_health_audits_access(self): + client = self.app.test_client() + self._set_external_api_key("abc123") + + resp = client.get("/api/external/health", headers=self._auth_headers()) + + self.assertEqual(resp.status_code, 200) + audit_logs = self._external_audit_logs() + self.assertEqual(len(audit_logs), 1) + self.assertIn("/api/external/health", audit_logs[0]["details"]) + + def test_external_account_status_returns_account_data_and_audits(self): email_addr = self._insert_outlook_account() self._set_external_api_key("abc123") client = self.app.test_client() @@ -323,6 +464,10 @@ class ExternalApiSystemTests(ExternalApiBaseTest): self.assertIn("last_refresh_at", data.get("data", {})) self.assertTrue(data.get("data", {}).get("can_read")) + audit_logs = self._external_audit_logs() + self.assertEqual(len(audit_logs), 1) + self.assertIn("/api/external/account-status", audit_logs[0]["details"]) + class ExternalApiRegressionTests(ExternalApiBaseTest): @patch("outlook_web.services.graph.get_emails_graph")