Files
ai-mail-bot/tests/test_unsub_keyboard.py
T
earthjasonlin f65e67c2f2 fix(unsub): List-Unsubscribe 头兜底 + mailto 退订按钮放行
两封推广邮件无退订按钮,两个独立根因:
1. Cloudflare 邮件 (msg_id=1364):程序只喂纯文本给 AI,纯文本里
   'Unsubscribe' 无 URL;邮件头 List-Unsubscribe 的权威退订链接被丢弃。
   -> 解析 List-Unsubscribe 头 (RFC 2369) 存入 Email.list_unsubscribe,
      注入 AI 提示词;显示层新增 _ensure_unsub_link 兜底:AI 漏提取时
      直接用头部 http(s)/mailto 链接补退订按钮(不依赖 AI)。
2. InferX 邮件 (msg_id=1363):AI 提取到 mailto:team@inferx.net?subject=Unsubscribe,
   但 _link_buttons 只放行 http 协议把它过滤掉。
   -> URL 校验放行 mailto:(退订场景常见)。

send_summary / tg_send_and_mark 均应用兜底并写回 summary_data.links,
回调恢复视图也能显示退订按钮。新增 6 个测试,共 14 个全部通过。
2026-09-04 08:03:45 +08:00

149 lines
6.1 KiB
Python

"""测试摘要键盘渲染:推广邮件的退订链接按钮必须显示且排最前。"""
from src.tg_bot import (
_ensure_unsub_link,
_is_unsub_link,
_link_buttons,
_summary_keyboard,
)
def _rows_of(kb: dict) -> list[list[dict]]:
return kb["inline_keyboard"]
def test_promotion_with_unsub_link_shows_button_first():
"""推广邮件:AI 提取到退订链接时必须显示,且排在第一行"""
links = [
{"text": "Manage Preferences", "url": "https://e.example.com/pref"},
{"text": "推广活动详情", "url": "https://e.example.com/deal"},
]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
rows = _rows_of(kb)
first = rows[0][0]
assert first["url"] == "https://e.example.com/pref"
assert "🔕" in first["text"]
# 其余按钮仍然存在
texts = [b["text"] for r in rows for b in r]
assert "告诉我更多" in texts
assert "查看原文" in texts
def test_promotion_without_links_keeps_default_buttons():
"""推广邮件:没有链接时保持原有按钮"""
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=None)
rows = _rows_of(kb)
assert len(rows) == 2
assert rows[0][0]["text"] == "告诉我更多"
assert rows[1][0]["text"] == "查看原文"
def test_promotion_with_only_non_unsub_link():
"""推广邮件:只有非退订链接时也正常渲染(🔗),不冒充退订按钮"""
links = [{"text": "查看活动", "url": "https://e.example.com/event"}]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
rows = _rows_of(kb)
assert rows[0][0]["text"] == "🔗 查看活动"
assert rows[1][0]["text"] == "告诉我更多"
assert rows[2][0]["text"] == "查看原文"
def test_normal_email_links_still_rendered():
"""普通邮件:链接按钮行为不变"""
links = [{"text": "查看账单", "url": "https://e.example.com/bill"}]
kb = _summary_keyboard(can_reply=True, is_promotion=False, links=links)
rows = _rows_of(kb)
texts = [b["text"] for r in rows for b in r]
assert "🔗 查看账单" in texts
def test_unsub_detection_variants():
"""退订链接识别:文字与 URL 关键词变体"""
assert _is_unsub_link({"text": "Unsubscribe", "url": "https://x.com"})
assert _is_unsub_link({"text": "退订", "url": "https://x.com"})
assert _is_unsub_link({"text": "点击这里取消订阅", "url": "https://x.com"})
assert _is_unsub_link({"text": "管理偏好设置", "url": "https://x.com"})
assert _is_unsub_link({"text": "更多", "url": "https://x.com/unsubscribe"})
assert _is_unsub_link({"text": "Preferences", "url": "https://x.com/email-pref"})
assert not _is_unsub_link({"text": "查看详情", "url": "https://x.com/deal"})
def test_unsub_link_sorted_before_other_links():
"""退订链接即使不在 links 第一位也会被排到最前"""
links = [
{"text": "查看详情", "url": "https://x.com/a"},
{"text": "unsubscribe", "url": "https://x.com/b"},
]
rows = _link_buttons(links)
assert rows[0][0]["url"] == "https://x.com/b"
assert rows[1][0]["url"] == "https://x.com/a"
def test_invalid_url_filtered():
"""非法 URL(非 http、多重协议头)被过滤"""
links = [
{"text": "退订", "url": "javascript:alert(1)"},
{"text": "退订", "url": "https://x.com/https://evil.com"},
{"text": "退订", "url": "https://ok.example.com/unsub"},
]
rows = _link_buttons(links)
assert len(rows) == 1
assert rows[0][0]["url"] == "https://ok.example.com/unsub"
def test_long_unsub_url_rendered():
"""长 tracking 退订链接(如 OpenAI 的 >700 字符)必须渲染,不能被长度过滤误杀"""
url = "https://r.openai.com/asm/unsubscribe/?user_id=108370056&data=" + "x" * 700
assert len(url) > 200
links = [{"text": "取消订阅", "url": url}]
rows = _link_buttons(links)
assert len(rows) == 1
assert rows[0][0]["url"] == url
assert "🔕" in rows[0][0]["text"]
def test_oversized_url_filtered():
"""超过 2048 的 URL 仍被过滤"""
url = "https://example.com/" + "x" * 2100
links = [{"text": "退订", "url": url}]
rows = _link_buttons(links)
assert rows == []
def test_mailto_unsub_link_rendered():
"""mailto: 退订链接(如 InferX 的 mailto:team@inferx.net?subject=Unsubscribe)必须渲染"""
links = [{"text": "退订", "url": "mailto:team@inferx.net?subject=Unsubscribe"}]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
rows = kb["inline_keyboard"]
assert rows[0][0]["url"] == "mailto:team@inferx.net?subject=Unsubscribe"
assert "🔕" in rows[0][0]["text"]
assert _is_unsub_link(links[0])
def test_ensure_unsub_link_http_header_fallback():
"""AI 没提取到退订链接时,用 List-Unsubscribe 头的 http 链接兜底"""
header = "https://links.iterable.com/s/uh/tKjpy4SHCKvbzE2votNyZEJDvlMdeodVyWFExNs310ugJ_RvZtoZq4gGLQ0BXMSL2lf3kEZsmr1XJmrODIFHpEwcypo3LR096pZ7jsfBynZBgGWz171Wc1E6ZIIC5Lu0e-Stq6p5-_Ad3CnNlUWrW1tnrLSeiGsOiQG_zMYKVbtfQZgvfm3QYfD9lQ/Cey29KU8yOzIUltaEJA8EMRFf-FKMZQy/24"
links = _ensure_unsub_link([], header)
assert links == [{"text": "退订", "url": header}]
kb = _summary_keyboard(can_reply=False, is_promotion=True, links=links)
assert kb["inline_keyboard"][0][0]["url"] == header
def test_ensure_unsub_link_mailto_header_fallback():
"""List-Unsubscribe 头只有 mailto 时也兜底"""
header = "mailto:unsubscribe+19693436+25822730@unsubscribe.iterable.com"
links = _ensure_unsub_link([], header)
assert links == [{"text": "退订", "url": header}]
def test_ensure_unsub_link_no_duplicate_when_ai_extracted():
"""AI 已提取退订链接时,不重复添加头部链接"""
ai_links = [{"text": "取消订阅", "url": "https://r.openai.com/asm/unsubscribe/?x=1"}]
links = _ensure_unsub_link(ai_links, "https://links.example.com/unsub")
assert links == ai_links
def test_ensure_unsub_link_ignores_invalid_header():
"""头部无有效链接时不兜底"""
assert _ensure_unsub_link([], "") == []
assert _ensure_unsub_link([], "javascript:alert(1)") == []