Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)之前,先发布一个范围受控、可回滚的稳定旧前端版本,合入近期关键缺陷修复与验证码能力增强。
Expand Down
2 changes: 1 addition & 1 deletion outlook_web/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "2.8.0"
__version__ = "2.8.1"

# Python 3.13 兼容:Path.glob 返回可迭代对象(如 map),
# 这里统一转为 list,保证与项目内既有用法(可拼接、可重复遍历)一致。
Expand Down
82 changes: 70 additions & 12 deletions outlook_web/services/webhook_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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="<redacted>",
)
return normalized

Expand Down Expand Up @@ -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 "<redacted>"
if ":" in hostname and not hostname.startswith("["):
hostname = f"[{hostname}]"
port = f":{parsed.port}" if parsed.port is not None else ""
except ValueError:
return "<redacted>"
return f"{parsed.scheme.lower()}://{hostname}{port}/<redacted>"


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(
Expand All @@ -104,38 +154,46 @@ 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(
"WEBHOOK_SEND_FAILED",
"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(
"WEBHOOK_SEND_FAILED",
"Webhook 发送失败",
message_en="Failed to send webhook message",
status=502,
details=str(exc),
details=_request_error_details(exc),
)

logger.warning(
Expand Down
129 changes: 128 additions & 1 deletion tests/test_webhook_push.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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

Expand Down Expand Up @@ -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/<redacted>", 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/<redacted>")

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

Expand Down
Loading