diff --git a/CHANGELOG.md b/CHANGELOG.md index 36efbe84..2326a34c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to OutlookMail Plus are documented in this file. ## [Unreleased] +## [v2.8.1] - 2026-08-11 + +### 修复 / Bug Fixes + +- **飞书/Lark Webhook v2 兼容**:自动识别官方 `/open-apis/bot/v2/hook/` 地址,改用 `application/json; charset=utf-8` 与 `{"msg_type":"text","content":{"text":"..."}}` 发送;其他通用 Webhook 继续使用 `text/plain`。 +- **Webhook 日志脱敏**:日志和测试接口不再暴露 URL 凭证、路径、查询参数或片段;请求异常与非 2xx 响应仅保留错误类型、HTTP 状态及飞书错误码。 + +### 重要变更 / Important Changes + +- **版本升级**:`outlook_web.__version__` 从 `2.8.0` 升级为 `2.8.1`。 +- **配置兼容**:现有 Webhook URL 与可选 `X-Webhook-Token` 设置保持不变,无数据库迁移。 + +### 测试/验证 / Testing & Verification + +- 扩展 `tests/test_webhook_push.py`,覆盖飞书/Lark v2 JSON payload、通用纯文本兼容、相似域名防误判、URL/响应/异常脱敏和重试行为。 + ## [v2.8.0] - 2026-07-26 旧前端紧急版本(Issue #115)。在 SPA 新前端迁移(#109)之前,先发布一个范围受控、可回滚的稳定旧前端版本,合入近期关键缺陷修复与验证码能力增强。 diff --git a/outlook_web/__init__.py b/outlook_web/__init__.py index 91eeda71..71fc25b6 100644 --- a/outlook_web/__init__.py +++ b/outlook_web/__init__.py @@ -1,4 +1,4 @@ -__version__ = "2.8.0" +__version__ = "2.8.1" # Python 3.13 兼容:Path.glob 返回可迭代对象(如 map), # 这里统一转为 list,保证与项目内既有用法(可拼接、可重复遍历)一致。 diff --git a/outlook_web/services/webhook_push.py b/outlook_web/services/webhook_push.py index e1d6a062..ef071cb7 100644 --- a/outlook_web/services/webhook_push.py +++ b/outlook_web/services/webhook_push.py @@ -12,6 +12,8 @@ logger = logging.getLogger(__name__) MAX_WEBHOOK_BODY_LENGTH = 4000 +FEISHU_V2_WEBHOOK_HOSTS = frozenset({"open.feishu.cn", "open.larksuite.com"}) +FEISHU_V2_WEBHOOK_PATH_PREFIX = "/open-apis/bot/v2/hook/" class WebhookPushError(Exception): @@ -49,7 +51,7 @@ def validate_webhook_url(url: str) -> str: "Webhook URL 必须以 http:// 或 https:// 开头", message_en="Webhook URL must start with http:// or https://", status=400, - details=normalized, + details="", ) return normalized @@ -90,9 +92,57 @@ def build_business_webhook_text(source: dict[str, Any], message: dict[str, Any]) ) +def _is_feishu_v2_webhook(url: str) -> bool: + try: + parsed = urlparse(url) + hostname = (parsed.hostname or "").lower() + except ValueError: + return False + + if hostname not in FEISHU_V2_WEBHOOK_HOSTS: + return False + path = parsed.path or "" + if not path.startswith(FEISHU_V2_WEBHOOK_PATH_PREFIX): + return False + hook_id = path[len(FEISHU_V2_WEBHOOK_PATH_PREFIX) :].strip("/") + return bool(hook_id) and "/" not in hook_id + + def _safe_url_for_log(url: str) -> str: - parsed = urlparse(url) - return f"{parsed.scheme}://{parsed.netloc}{parsed.path or '/'}" + try: + parsed = urlparse(str(url or "")) + hostname = parsed.hostname + if not parsed.scheme or not hostname: + return "" + if ":" in hostname and not hostname.startswith("["): + hostname = f"[{hostname}]" + port = f":{parsed.port}" if parsed.port is not None else "" + except ValueError: + return "" + return f"{parsed.scheme.lower()}://{hostname}{port}/" + + +def _response_error_details(response: requests.Response, *, is_feishu_v2: bool) -> str: + details = f"status={response.status_code}" + if not is_feishu_v2: + return details + + try: + payload = response.json() + except (TypeError, ValueError): + return details + if not isinstance(payload, dict) or payload.get("code") is None: + return details + code = str(payload["code"]).strip().replace("\n", " ")[:32] + return f"{details} code={code}" if code else details + + +def _request_error_details(exc: requests.RequestException) -> str: + if isinstance(exc, requests.Timeout): + return "request_timeout" + if isinstance(exc, requests.ConnectionError): + return "connection_error" + return type(exc).__name__ def send_webhook_message( @@ -104,22 +154,30 @@ def send_webhook_message( retry: int = 1, ) -> None: target_url = validate_webhook_url(url) + is_feishu_v2 = _is_feishu_v2_webhook(target_url) headers = { - "Content-Type": "text/plain; charset=utf-8", + "Content-Type": "application/json; charset=utf-8" if is_feishu_v2 else "text/plain; charset=utf-8", } if str(token or "").strip(): headers["X-Webhook-Token"] = str(token).strip() + request_kwargs: dict[str, Any] = { + "headers": headers, + "timeout": timeout_sec, + } + if is_feishu_v2: + request_kwargs["json"] = { + "msg_type": "text", + "content": {"text": text_body}, + } + else: + request_kwargs["data"] = text_body.encode("utf-8") + attempts = max(0, int(retry)) + 1 last_error: Exception | None = None for _ in range(attempts): try: - response = requests.post( - target_url, - data=text_body.encode("utf-8"), - headers=headers, - timeout=timeout_sec, - ) + response = requests.post(target_url, **request_kwargs) if 200 <= response.status_code < 300: return last_error = WebhookPushError( @@ -127,7 +185,7 @@ def send_webhook_message( "Webhook 发送失败", message_en="Failed to send webhook message", status=502, - details=f"status={response.status_code} body={(response.text or '')[:200]}", + details=_response_error_details(response, is_feishu_v2=is_feishu_v2), ) except requests.RequestException as exc: last_error = WebhookPushError( @@ -135,7 +193,7 @@ def send_webhook_message( "Webhook 发送失败", message_en="Failed to send webhook message", status=502, - details=str(exc), + details=_request_error_details(exc), ) logger.warning( diff --git a/tests/test_webhook_push.py b/tests/test_webhook_push.py index beacf8bf..b3ca27de 100644 --- a/tests/test_webhook_push.py +++ b/tests/test_webhook_push.py @@ -23,10 +23,14 @@ def setUp(self): settings_repo.set_setting("webhook_notification_url", "") settings_repo.set_setting("webhook_notification_token", "") - def _resp(self, status_code: int, text: str = ""): + def _resp(self, status_code: int, text: str = "", json_data=None): resp = Mock() resp.status_code = status_code resp.text = text + if json_data is None: + resp.json.side_effect = ValueError("not json") + else: + resp.json.return_value = json_data return resp def test_send_webhook_message_success_on_2xx(self): @@ -48,8 +52,65 @@ def test_send_webhook_message_success_on_2xx(self): kwargs = post_mock.call_args.kwargs self.assertEqual(kwargs.get("timeout"), 10) self.assertEqual(kwargs.get("headers", {}).get("Content-Type"), "text/plain; charset=utf-8") + self.assertEqual(kwargs.get("data"), b"hello") + self.assertNotIn("json", kwargs) self.assertNotIn("X-Webhook-Token", kwargs.get("headers", {})) + def test_send_feishu_v2_webhook_uses_json_payload(self): + from outlook_web.services import webhook_push + + with patch( + "outlook_web.services.webhook_push.requests.post", + return_value=self._resp(200, json_data={"code": 0, "msg": "success"}), + ) as post_mock: + webhook_push.send_webhook_message( + url="https://open.feishu.cn/open-apis/bot/v2/hook/secret-hook-id", + token="", + text_body="hello 飞书", + ) + + kwargs = post_mock.call_args.kwargs + self.assertEqual(kwargs.get("headers", {}).get("Content-Type"), "application/json; charset=utf-8") + self.assertEqual( + kwargs.get("json"), + {"msg_type": "text", "content": {"text": "hello 飞书"}}, + ) + self.assertNotIn("data", kwargs) + + def test_send_lark_v2_webhook_uses_json_payload(self): + from outlook_web.services import webhook_push + + with patch( + "outlook_web.services.webhook_push.requests.post", + return_value=self._resp(200, json_data={"code": 0}), + ) as post_mock: + webhook_push.send_webhook_message( + url="https://open.larksuite.com/open-apis/bot/v2/hook/secret-hook-id", + token="", + text_body="hello Lark", + ) + + self.assertIn("json", post_mock.call_args.kwargs) + self.assertNotIn("data", post_mock.call_args.kwargs) + + def test_feishu_lookalike_host_keeps_generic_plain_text_protocol(self): + from outlook_web.services import webhook_push + + with patch( + "outlook_web.services.webhook_push.requests.post", + return_value=self._resp(200), + ) as post_mock: + webhook_push.send_webhook_message( + url="https://open.feishu.cn.example.com/open-apis/bot/v2/hook/not-feishu", + token="", + text_body="hello", + ) + + kwargs = post_mock.call_args.kwargs + self.assertEqual(kwargs.get("headers", {}).get("Content-Type"), "text/plain; charset=utf-8") + self.assertEqual(kwargs.get("data"), b"hello") + self.assertNotIn("json", kwargs) + def test_send_webhook_message_retries_once_then_success(self): from outlook_web.services import webhook_push @@ -86,6 +147,72 @@ def test_send_webhook_message_retries_once_then_fail(self): self.assertEqual(post_mock.call_count, 2) self.assertEqual(ctx.exception.code, "WEBHOOK_SEND_FAILED") + def test_feishu_failure_log_redacts_url_and_response_body(self): + from outlook_web.services import webhook_push + + secret = "secret-hook-id" + response = self._resp( + 400, + text=f"bad request for {secret}", + json_data={"code": 9499, "msg": f"bad request for {secret}"}, + ) + with patch( + "outlook_web.services.webhook_push.requests.post", + return_value=response, + ), patch("outlook_web.services.webhook_push.logger.warning") as warning_mock: + with self.assertRaises(webhook_push.WebhookPushError) as ctx: + webhook_push.send_webhook_message( + url=f"https://open.feishu.cn/open-apis/bot/v2/hook/{secret}?debug={secret}", + token="", + text_body="hello", + retry=0, + ) + + formatted_log = warning_mock.call_args.args[0] % warning_mock.call_args.args[1:] + self.assertNotIn(secret, formatted_log) + self.assertNotIn(secret, str(ctx.exception.details)) + self.assertIn("https://open.feishu.cn/", formatted_log) + self.assertIn("status=400 code=9499", formatted_log) + + def test_request_exception_details_do_not_expose_webhook_secret(self): + from outlook_web.services import webhook_push + + secret = "secret-hook-id" + with patch( + "outlook_web.services.webhook_push.requests.post", + side_effect=requests.ConnectionError(f"failed url=/open-apis/bot/v2/hook/{secret}"), + ), patch("outlook_web.services.webhook_push.logger.warning") as warning_mock: + with self.assertRaises(webhook_push.WebhookPushError) as ctx: + webhook_push.send_webhook_message( + url=f"https://open.feishu.cn/open-apis/bot/v2/hook/{secret}", + token="", + text_body="hello", + retry=0, + ) + + formatted_log = warning_mock.call_args.args[0] % warning_mock.call_args.args[1:] + self.assertEqual(ctx.exception.details, "connection_error") + self.assertNotIn(secret, formatted_log) + + def test_safe_url_for_log_strips_credentials_path_query_and_fragment(self): + from outlook_web.services import webhook_push + + safe_url = webhook_push._safe_url_for_log( + "https://user:password@open.feishu.cn:443/open-apis/bot/v2/hook/secret?token=secret#secret" + ) + + self.assertEqual(safe_url, "https://open.feishu.cn:443/") + + def test_invalid_webhook_url_error_does_not_echo_input(self): + from outlook_web.services import webhook_push + + secret = "secret-hook-id" + with self.assertRaises(webhook_push.WebhookPushError) as ctx: + webhook_push.validate_webhook_url(f"ftp://example.com/hook/{secret}") + + self.assertEqual(ctx.exception.code, "WEBHOOK_URL_INVALID") + self.assertNotIn(secret, str(ctx.exception.details)) + def test_send_webhook_message_timeout_uses_10_seconds(self): from outlook_web.services import webhook_push