Files
AgentLab/backend/tests/test_notify_mail.py
T
ThearchyHelios e5003a1d46 W3-d 复核 L2:使用者列通知渠道时拿不到全局、应用邮件渠道的收件地址
复核发现:GET /api/notify/channels 对使用者照样列全局、应用渠道(应用的通知里要挑渠道,只用名字和 id),而邮件渠道的
mail_to 给的是全部收件地址原文;channel_out 的说明却写着「搭建者以上才看得到」。Webhook 渠道只给主机名,邮件渠道多露了
一层:任何使用者都能拿到内部的收件名单。

做法:channel_out 加 recipients 参数,list_channels 按 _can_edit 的同一条规矩判(关登录时谁都能改,开了登录要搭建者
以上):改得了的照原样给 mail_to(改地址要整份重填),改不了的只给条数 mail_count。个人渠道照旧写发到自己的账号邮箱。
建、改渠道的返回本来就只给能改的人,照旧。说明同步改;前端 typesApp 的 NotifyChannelRow 加 mail_count(设置页的
通知只给使用者列个人渠道,界面上用不上,不加入口)。

测试(tests/test_notify_mail.py):开登录,使用者列渠道时全局、应用的邮件渠道照样在、没有 mail_to、只有条数,自己的个人
渠道照常;搭建者看得到地址。去掉修复回跑,这一项失败。
2026-10-16 18:20:52 +08:00

471 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""邮件:通知渠道的第五种格式(notify/mail.py,docs/design/next-round/04-对外集成与触达.md B2)。
全程用假的 SMTP 工厂(mail._smtp_factory),不连任何邮件服务器。守的是:
- 个人邮件渠道只发到拥有者在名录里的账号邮箱;账号没有邮箱时记一条失败的投递、写明原因;个人渠道不收地址;
- 全局、应用的邮件渠道填固定的收件地址(最多 20 个,格式不对的拒收);邮件服务没启用时不收新的邮件渠道;
- 4xx(临时错误)和连不上按 1/2/4 秒重试,5xx 不重试;报错里绝不出现口令;
- 邮件里只有标题、状态、链接,纯文本 + 转义过的 HTML,带 List-Unsubscribe;投递记录成功写「已交给邮件服务器」(不说已送达);
- 设置的 mail 一组:口令加密存、读的时候只说配没配;启用时要配全;发件人名称不许换行;只有管理员能改;
- 发测试邮件只发到管理员自己的账号邮箱:关登录、账号没有邮箱时 422 说清怎么办;
- 管理员开了「默认发邮件给待办处理人」:第一次派到待办的人自动有一个个人邮件渠道(默认订阅三项),本人删了不再建回来;
开关缺省关时不建。
人名、地址、主机都是虚构的。
"""
from __future__ import annotations
import json
import smtplib
from types import SimpleNamespace
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import delete, select
from app.core.crypto import encrypt
from app.db.base import SessionLocal
from app.db.models import NotifyChannel, NotifyDelivery, Setting, User
from app.notify import mail, service
from app.notify.core import Message
PASSWORD = "Sm7p-Pa55word-Zq"
class FakeSMTP:
"""假的 SMTP 连接:记下登录、发出的信;box.failures 里排着的异常按顺序在 send_message 上抛。"""
def __init__(self, box: SimpleNamespace) -> None:
self.box = box
def starttls(self, context=None) -> None:
self.box.tls += 1
def login(self, username: str, password: str) -> None:
self.box.logins.append((username, password))
if self.box.auth_error:
# 有的服务器会把收到的内容回显在错误里:报错里也不能出现口令
raise smtplib.SMTPAuthenticationError(535, f"5.7.8 bad credentials {password}".encode())
def send_message(self, em, from_addr=None, to_addrs=None):
if self.box.failures:
raise self.box.failures.pop(0)
self.box.sent.append(SimpleNamespace(from_addr=from_addr, to=list(to_addrs or []), em=em))
return {}
def quit(self) -> None:
self.box.quits += 1
def close(self) -> None:
pass
@pytest.fixture
def smtp(monkeypatch):
box = SimpleNamespace(sent=[], logins=[], failures=[], connects=[], refuse=0, tls=0, quits=0, auth_error=False)
def factory(host, port, security, timeout):
box.connects.append((host, port, security, timeout))
if box.refuse:
box.refuse -= 1
raise ConnectionRefusedError("refused")
return FakeSMTP(box)
monkeypatch.setattr(mail, "_smtp_factory", factory)
monkeypatch.setattr(mail, "RETRY_DELAYS", (0, 0, 0))
return box
@pytest.fixture(autouse=True)
async def clean():
"""前后各清一遍:同一个进程里别的测试留下的渠道、投递记录不算进来。"""
await _clean()
yield
await service.drain()
await _clean()
async def _clean() -> None:
async with SessionLocal() as session:
await session.execute(delete(NotifyDelivery))
await session.execute(delete(NotifyChannel))
await session.execute(delete(Setting).where(Setting.key.in_([mail.GROUP, "notify", service.MAIL_AUTO_KEY])))
await session.execute(delete(User).where(User.username.like("mail-%")))
await session.commit()
from app.core import public_url
public_url.remember(None) # 发信时按库里的对外访问地址对齐了缓存:删了那一行,缓存也清掉
async def configure(**over) -> None:
value = mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587, "security": "starttls",
"username": "agentlab", "password": PASSWORD, "from_addr": "agentlab@example.com",
"from_name": "AgentLab", **over})
async with SessionLocal() as session:
row = await session.get(Setting, mail.GROUP)
if row is None:
session.add(Setting(key=mail.GROUP, value=value))
else:
row.value = value
notify = await session.get(Setting, "notify")
if notify is None:
session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com"}))
else:
# 别的测试留下的 notify 一组:对外访问地址换成这里要的(退订头按它拼)
notify.value = {**(notify.value or {}), "public_base_url": "https://agentlab.example.com"}
await session.commit()
async def person(name: str, email: str | None) -> User:
async with SessionLocal() as session:
user = User(username=f"mail-{name}", display_name=name, role="user", email=email)
session.add(user)
await session.commit()
await session.refresh(user)
return user
async def channel(scope: str, owner_id: str | None = None, to: str = mail.PERSONAL_PLACEHOLDER,
events: list[str] | None = None) -> NotifyChannel:
async with SessionLocal() as session:
row = NotifyChannel(name=f"邮件-{scope}", scope=scope, owner_id=owner_id, format="email", url=encrypt(to),
events=events or ["task.opened"])
session.add(row)
await session.commit()
await session.refresh(row)
return row
MESSAGE = {"event": "task.opened", "status": "pending", "title": "新的待办:审批 <b>付款</b>",
"lines": [("状态", "待处理"), ("所在", "应用「报销 & 付款」")],
"links": [("去处理", "https://agentlab.example.com/apps/inbox/t1")]}
async def _deliveries() -> list[NotifyDelivery]:
async with SessionLocal() as session:
return list((await session.execute(select(NotifyDelivery))).scalars())
# --------------------------------------------------------------------------
# 发到谁
# --------------------------------------------------------------------------
async def test_personal_mail_goes_to_the_account_email_only(smtp):
await configure()
ana = await person("ana", "ana@example.com")
row = await channel("user", ana.id)
sent = await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened",
run_id=None, task_id="t1")
assert sent == 1
[mail_out] = smtp.sent
assert mail_out.to == ["ana@example.com"] and mail_out.from_addr == "agentlab@example.com"
assert smtp.logins == [("agentlab", PASSWORD)] and smtp.tls == 1
em = mail_out.em
assert em["Subject"] == "【AgentLab】新的待办:审批 <b>付款</b>"
assert em["List-Unsubscribe"] == "<https://agentlab.example.com/settings/notify>"
assert em["Auto-Submitted"] == "auto-generated"
text = em.get_body(("plain",)).get_content()
html = em.get_body(("html",)).get_content()
assert "状态:待处理" in text and "https://agentlab.example.com/apps/inbox/t1" in text
assert "&lt;b&gt;付款&lt;/b&gt;" in html and "<b>付款</b>" not in html, "HTML 里的内容一律转义"
assert "报销 &amp; 付款" in html
[delivery] = await _deliveries()
assert (delivery.status, delivery.error, delivery.http_status) == ("ok", None, None)
async def test_personal_mail_without_account_email_is_a_failed_delivery_with_the_reason(smtp):
await configure()
bo = await person("bo", None)
row = await channel("user", bo.id)
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
assert smtp.sent == [] and smtp.connects == [], "没有邮箱就不连服务器"
[delivery] = await _deliveries()
assert delivery.status == "failed" and delivery.error == mail.NO_EMAIL
assert "设置 → 身份" in delivery.error
async def test_global_mail_goes_to_the_fixed_addresses(smtp):
await configure()
row = await channel("global", to="ops@example.com, lead@example.com")
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
assert smtp.sent[0].to == ["ops@example.com", "lead@example.com"]
async def test_disabled_mail_service_records_why(smtp):
await configure(enabled=False)
row = await channel("global", to="ops@example.com")
await service._push([row], MESSAGE, subject_kind="task", subject_id="t1", event="task.opened", run_id=None,
task_id="t1")
[delivery] = await _deliveries()
assert delivery.status == "failed" and delivery.error == mail.NOT_ENABLED and smtp.connects == []
# --------------------------------------------------------------------------
# 重试与报错
# --------------------------------------------------------------------------
def _cfg() -> mail.MailConfig:
return mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 587,
"security": "starttls", "username": "agentlab", "password": PASSWORD,
"from_addr": "agentlab@example.com"}))
async def test_temporary_errors_and_refused_connections_are_retried(smtp):
smtp.failures = [smtplib.SMTPDataError(451, b"4.3.0 try again later")]
smtp.refuse = 1
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is True and out.attempts == 3, "连不上一次、451 一次,第三次交给了邮件服务器"
assert len(smtp.sent) == 1
async def test_permanent_errors_are_not_retried(smtp):
smtp.failures = [smtplib.SMTPDataError(550, b"5.7.1 message rejected")]
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 1 and out.retryable is False
assert "550" in out.error and "smtp.example.com" in out.error
async def test_retries_stop_after_three(smtp):
smtp.refuse = 10
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 4 and len(smtp.connects) == 4
assert out.error == "无法连接邮件服务器 smtp.example.com:587"
async def test_errors_never_contain_the_password(smtp, caplog):
smtp.auth_error = True
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert out.ok is False and out.attempts == 1, "认证失败(5xx)不重试"
assert PASSWORD not in (out.error or "") and "535" in out.error
assert PASSWORD not in caplog.text
smtp.auth_error = False
smtp.failures = [smtplib.SMTPSenderRefused(553, f"sender {PASSWORD} refused".encode(), "agentlab@example.com")]
out = await mail.send(_cfg(), ["ana@example.com"], Message(title="t"), label="测试")
assert PASSWORD not in (out.error or "")
async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch):
monkeypatch.setattr(FakeSMTP, "send_message", lambda self, em, from_addr=None, to_addrs=None: {"x@example.com": (550, b"no")})
out = await mail.send(_cfg(), ["ana@example.com", "x@example.com"], Message(title="t"), label="测试")
assert out.ok is True and out.error == "已交给邮件服务器,其中 1 个收件地址被拒收"
def test_line_breaks_in_titles_never_reach_the_headers():
em = mail.build(_cfg(), ["ana@example.com"], Message(title="新的待办:审批\r\nBcc: x@example.com"), unsubscribe=None)
assert em["Subject"] == "【AgentLab】新的待办:审批 Bcc: x@example.com" and em["Bcc"] is None
def test_ssl_security_uses_no_starttls(smtp):
cfg = mail.config_of(mail.stored_value({"enabled": True, "host": "smtp.example.com", "port": 465,
"security": "ssl", "from_addr": "agentlab@example.com"}))
out = mail._send_once(cfg, ["ana@example.com"], mail.build(cfg, ["ana@example.com"], Message(title="t"),
unsubscribe=None))
assert out.ok and smtp.tls == 0 and smtp.logins == [], "没填用户名不登录"
assert smtp.connects[0][:3] == ("smtp.example.com", 465, "ssl") and smtp.connects[0][3] == mail.TIMEOUT_S
# --------------------------------------------------------------------------
# 渠道接口
# --------------------------------------------------------------------------
@pytest.fixture
async def client():
from app.main import app
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
async def test_mail_channels_need_the_mail_service_and_valid_addresses(client, smtp):
body = {"name": "运维邮件", "scope": "global", "format": "email", "url": "ops@example.com", "events": ["task.opened"]}
refused = await client.post("/api/notify/channels", json=body)
assert refused.status_code == 422 and refused.json()["detail"] == mail.NOT_ENABLED
await configure()
too_many = ", ".join(f"p{i}@example.com" for i in range(21))
r = await client.post("/api/notify/channels", json={**body, "url": too_many})
assert r.status_code == 422 and "最多 20 个" in r.json()["detail"]
r = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com\nnot-an-address"})
assert r.status_code == 422 and "not-an-address" in r.json()["detail"]
made = await client.post("/api/notify/channels", json={**body, "url": "ops@example.com;lead@example.com"})
assert made.status_code == 201, made.text
row = made.json()
assert row["format"] == "email" and row["mail_to"] == ["ops@example.com", "lead@example.com"]
assert row["url_host"] == "" and row["signed"] is False
listed = (await client.get("/api/notify/channels")).json()
assert listed["mail"] == {"enabled": True, "account_email": None}, "关登录时没有账号邮箱"
tested = (await client.post(f"/api/notify/channels/{row['id']}/test")).json()
assert tested["ok"] is True and tested["message"].startswith("已交给邮件服务器")
assert "是否送达以邮件服务器为准" in tested["message"]
deliveries = (await client.get(f"/api/notify/deliveries?channel_id={row['id']}")).json()["items"]
assert [(d["event"], d["status"], d["format"]) for d in deliveries] == [("test", "ok", "email")]
# 只改订阅、不传地址:照旧
kept = await client.patch(f"/api/notify/channels/{row['id']}", json={"events": ["task.opened", "action.unknown"]})
assert kept.status_code == 200 and kept.json()["mail_to"] == ["ops@example.com", "lead@example.com"]
# 换成 Webhook 要填 Webhook 地址
back = await client.patch(f"/api/notify/channels/{row['id']}", json={"format": "generic"})
assert back.status_code == 422 and "Webhook 地址" in back.json()["detail"]
async def test_personal_mail_channel_takes_no_address(smtp):
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
await auth_env.turn_on_local()
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
uma = await auth_env.login("mail-uma")
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"url": "someone@example.com", "events": ["task.opened"]})
assert r.status_code == 422 and "账号邮箱" in r.json()["detail"], "个人渠道不能填任意地址(不当中继)"
r = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"events": ["task.opened", "task.digest"]})
assert r.status_code == 201, r.text
assert r.json()["mail_to"] == ["uma@example.com"]
listed = (await uma.get("/api/notify/channels")).json()
assert listed["mail"] == {"enabled": True, "account_email": "uma@example.com"}
tested = (await uma.post(f"/api/notify/channels/{r.json()['id']}/test")).json()
assert tested["ok"] is True and smtp.sent[-1].to == ["uma@example.com"]
finally:
await auth_env.reset()
async def test_users_listing_channels_do_not_get_global_recipients(smtp):
"""使用者列渠道照样看得到全局、应用渠道(应用的通知里要挑一个,只用名字和 id),但邮件渠道的收件地址只给能改它的人
(搭建者以上):只给条数,不给地址。Webhook 只给主机名,邮件地址不能比它多露(复核 L2)。"""
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
await auth_env.turn_on_local()
await auth_env.make_user("mail-uma", "user", email="uma@example.com")
await auth_env.make_user("mail-bo", "builder")
everyone = "finance-lead@example.com, ops@example.com"
made = await channel("global", to=everyone)
await channel("app", to="team@example.com")
uma = await auth_env.login("mail-uma")
mine = await uma.post("/api/notify/channels", json={"name": "我的邮件", "scope": "user", "format": "email",
"events": ["task.opened"]})
assert mine.status_code == 201, mine.text
listed = (await uma.get("/api/notify/channels")).json()["items"]
by = {row["scope"]: row for row in listed}
assert set(by) == {"global", "app", "user"}, "全局、应用渠道照样列(应用的通知里要挑)"
assert "finance-lead" not in json.dumps(listed) and "team@" not in json.dumps(listed), "收件地址不给使用者"
assert "mail_to" not in by["global"] and by["global"]["mail_count"] == 2
assert "mail_to" not in by["app"] and by["app"]["mail_count"] == 1
assert by["user"]["mail_to"] == ["uma@example.com"], "自己的个人渠道照常写发到哪"
bo = await auth_env.login("mail-bo")
shown = {row["id"]: row for row in (await bo.get("/api/notify/channels")).json()["items"]}
assert shown[made.id]["mail_to"] == ["finance-lead@example.com", "ops@example.com"], "能改的人看得到,改地址要整份重填"
finally:
await auth_env.reset()
# --------------------------------------------------------------------------
# 设置
# --------------------------------------------------------------------------
async def test_mail_settings_keep_the_password_encrypted_and_hidden(client):
put = await client.put("/api/settings", json={"values": {"mail": {
"enabled": True, "host": "smtp.example.com", "port": 465, "security": "ssl", "username": "agentlab",
"password": PASSWORD, "from_addr": "agentlab@example.com", "from_name": "流程助手"}}})
assert put.status_code == 200, put.text
shown = put.json()["mail"]
assert shown["password"] is None and shown["password_set"] is True and PASSWORD not in json.dumps(put.json())
async with SessionLocal() as session:
stored = (await session.get(Setting, "mail")).value
assert stored["password"] != PASSWORD and PASSWORD not in json.dumps(stored)
assert mail.config_of(stored).password == PASSWORD
# 整组回写(口令是 null、带着 password_set):口令照旧
again = await client.put("/api/settings", json={"values": {"mail": {**shown, "from_name": "AgentLab"}}})
assert again.status_code == 200 and again.json()["mail"]["password_set"] is True
async with SessionLocal() as session:
assert mail.config_of((await session.get(Setting, "mail")).value).password == PASSWORD
# 空串表示去掉
cleared = await client.put("/api/settings", json={"values": {"mail": {"password": ""}}})
assert cleared.json()["mail"]["password_set"] is False
assert cleared.json()["mail"]["host"] == "smtp.example.com", "只给一项的不冲掉别的"
@pytest.mark.parametrize("value, fragment", [
({"enabled": True}, "SMTP 服务器"),
({"enabled": True, "host": "smtp.example.com"}, "发件地址"),
({"host": "smtp://smtp.example.com"}, "主机名"),
({"port": 70000}, "端口"),
({"security": "tls"}, "加密方式"),
({"from_addr": "not-an-address"}, "发件地址"),
({"from_name": "AgentLab\r\nBcc: x@example.com"}, "换行"),
({"relay": True}, "不支持「relay」"),
])
async def test_mail_settings_are_validated(client, value, fragment):
put = await client.put("/api/settings", json={"values": {"mail": value}})
assert put.status_code == 422, put.text
assert fragment in put.json()["detail"]
async def test_mail_settings_are_admin_only_and_test_mail_goes_to_the_admin(smtp):
from tests.fakes import auth_env
await configure()
await auth_env.reset()
try:
async with auth_env.client() as off:
r = await off.post("/api/settings/mail/test")
assert r.status_code == 422 and r.json()["code"] == "mail_test_no_address", "关登录时没有账号邮箱"
root = await auth_env.turn_on_local()
r = await root.post("/api/settings/mail/test")
assert r.status_code == 422 and "没有邮箱地址" in r.json()["detail"]
async with SessionLocal() as session:
admin = (await session.execute(select(User).where(User.username == "root"))).scalar_one()
admin.email = "root@example.com"
await session.commit()
r = await root.post("/api/settings/mail/test")
assert r.status_code == 200 and r.json()["ok"] is True, r.text
assert "已交给邮件服务器" in r.json()["message"] and smtp.sent[-1].to == ["root@example.com"]
await auth_env.make_user("mail-bea", "builder")
bea = await auth_env.login("mail-bea")
assert (await bea.get("/api/settings")).json()["mail"]["password"] is None, "搭建者读得到,看不到口令"
assert (await bea.put("/api/settings", json={"values": {"mail": {"enabled": False}}})).status_code == 403
assert (await bea.post("/api/settings/mail/test")).status_code == 403
finally:
await auth_env.reset()
# --------------------------------------------------------------------------
# 默认发邮件给待办处理人
# --------------------------------------------------------------------------
async def test_assignees_get_a_personal_mail_channel_only_when_the_admin_turns_it_on(smtp):
from tests.fakes import auth_env
await auth_env.reset()
try:
await auth_env.turn_on_local()
cy = await person("cy", "cy@example.com")
await configure()
assert await service.ensure_assignee_mail([cy.username]) == 0, "缺省关:不建"
await configure(default_for_assignees=True)
assert await service.ensure_assignee_mail([cy.username]) == 1
async with SessionLocal() as session:
[row] = (await session.execute(select(NotifyChannel).where(NotifyChannel.owner_id == cy.id))).scalars()
assert (row.scope, row.format, row.enabled) == ("user", "email", True)
assert row.events == ["task.opened", "action.unknown", "review.done"]
await session.delete(row)
await session.commit()
assert await service.ensure_assignee_mail([cy.username]) == 0, "本人删了就是不要:不再建回来"
finally:
await auth_env.reset()
async def test_assignee_mail_is_not_created_when_login_is_off(smtp):
dee = await person("dee", "dee@example.com")
await configure(default_for_assignees=True)
assert await service.ensure_assignee_mail([dee.username]) == 0, "关登录没有认证过的账号,个人渠道无从挂起"