From fc9b31ecfe5750d97254eab0ce9cc9f98fc28f93 Mon Sep 17 00:00:00 2001 From: ThearchyHelios Date: Fri, 16 Oct 2026 17:11:54 +0800 Subject: [PATCH] =?UTF-8?q?W3-d=20=E6=94=B6=E5=B0=BE=EF=BC=9A=E5=85=B3?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=97=B6=E4=B8=8D=E5=8F=91=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E5=92=8C=E5=88=B0=E6=9C=9F=E6=8F=90=E9=86=92=E3=80=81=E5=88=86?= =?UTF-8?q?=E4=BA=AB=E9=A1=B5=E4=B8=8D=E4=B8=8B=E5=8F=91=E5=85=A5=E5=8F=A3?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E3=80=81=E9=82=AE=E4=BB=B6=E6=A0=87=E9=A2=98?= =?UTF-8?q?=E5=8E=BB=E6=8D=A2=E8=A1=8C=EF=BC=9B=E6=B5=8B=E8=AF=95=E4=BA=92?= =?UTF-8?q?=E4=B8=8D=E5=B9=B2=E6=89=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notify/digest:关登录时「派给我的」认不出是谁、访问密钥也不生效,待办汇总和密钥到期提醒一概不发,也不认领当天 - resolve_page 的分享旁路不下发入口的表单(分享链接里不能发起) - 邮件标题里的换行合成空格:应用名、节点名是人起的,邮件头里有换行就是头注入 - 邮件、汇总的测试前后各清一遍渠道、投递记录和 notify 一组,对外访问地址的缓存跟着清;汇总测试只删自己建的待办。 全量并行跑时不再被同一进程里别的测试留下的行带偏 --- backend/app/apps/resolve.py | 3 ++- backend/app/notify/digest.py | 13 +++++++--- backend/app/notify/mail.py | 3 ++- backend/tests/test_notify_digest.py | 40 ++++++++++++++++++++++++++--- backend/tests/test_notify_mail.py | 17 ++++++++++++ 5 files changed, 67 insertions(+), 9 deletions(-) diff --git a/backend/app/apps/resolve.py b/backend/app/apps/resolve.py index 4cb36c59..fb96901d 100644 --- a/backend/app/apps/resolve.py +++ b/backend/app/apps/resolve.py @@ -701,7 +701,8 @@ async def resolve_page(session: AsyncSession, app: Any, *, page_id: str, princip # 会有(Source.message),不进 problems——进了,页面顶上挂一块「无法显示」,使用者以为应用坏了 for source in ctx.sources.values(): await _load(session, source) - for entry in spec.get("entries") or []: + # 分享链接里不能发起:入口的表单不下发 + for entry in (spec.get("entries") or []) if share is None else []: form = resolve_form(spec, entry.get("form")) if form is not None and entry.get("id"): ctx.forms[str(entry["id"])] = form diff --git a/backend/app/notify/digest.py b/backend/app/notify/digest.py index 574bb2ca..538ec3ec 100644 --- a/backend/app/notify/digest.py +++ b/backend/app/notify/digest.py @@ -8,13 +8,15 @@ notify_key_expiring),推送放在后台(service.spawn):接收端挂住 一条(没有待办的人不发); - 按日期在 settings 表里认领一行(键 STATE_PREFIX + "digest:<日期>",主键冲突就是已经认领过):同一天只发一次,重启了也不 重发。认领之后推送失败不补发——通知本来就是至多一次,投递记录里照实记失败。停机错过的那一天不补; -- 认领行只留最近 KEEP_DAYS 天。 +- 认领行只留最近 KEEP_DAYS 天; +- 关登录时不发:「派给我的」要认得出是谁(关登录时人人等同管理员,待办箱里是全部待办),个人渠道本来也只挂在认证过的账号上。 访问密钥快到期(key.expiring): - 到期前 7 天、1 天各推一次(THRESHOLDS):个人访问密钥推给拥有者的个人渠道,服务账号的推给订阅了的全局渠道; - 去重靠审计:推之前先记一条 access_key.expiring(data.days 是哪一档),记过的不再推(进程里另记一份,省得每拍查审计); - 不推的:建的时候离到期已经不到这一档(自己设了短有效期,知道它快到期);轮换下来、24 小时后到期的旧密钥(新的那把已经 - 在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了)。 + 在用);撤销了的;拥有者停用了的;个人密钥开关关着时的个人密钥(本来就不认了);关登录时的一概不推(访问密钥只在开了登录时 + 生效)。 这两种状态(认领行、自动建过邮件渠道的人)存在 settings 表里,键都以 STATE_PREFIX 开头:它们是通知自己记的账,不是用户设置, GET /api/settings 不列、PUT 不收(api/settings.py)。 @@ -85,12 +87,13 @@ async def _claim(day: str, now: datetime) -> bool: async def digest_due(now: datetime | None = None) -> list[str]: """到点了就认领今天、给订阅了汇总的人各发一条(后台推)。交回这一拍要发给谁(用户 id),没到点、已经发过是空的。""" from app.api.settings import NOTIFY_GROUP + from app.auth import current_mode from app.notify import service now = now or utcnow() local = now.astimezone() day = local.date().isoformat() - if day in _claimed: + if day in _claimed or current_mode() == "off": return [] async with SessionLocal() as session: row = await session.get(Setting, NOTIFY_GROUP) @@ -123,13 +126,15 @@ async def _digest_one(user_id: str) -> None: async def keys_due(now: datetime | None = None) -> list[str]: """访问密钥快到期了的,按档推一次(见模块说明)。交回这一拍推了哪几把(密钥 id)。""" - from app.auth import personal_keys_enabled + from app.auth import current_mode, personal_keys_enabled from app.auth.audit import record from app.auth.keys import SERVICE_KIND from app.db.models import AuditEvent from app.db.models_identity import AccessKey from app.notify import service + if current_mode() == "off": + return [] now = now or utcnow() horizon = now + timedelta(days=max(THRESHOLDS)) async with SessionLocal() as session: diff --git a/backend/app/notify/mail.py b/backend/app/notify/mail.py index 2318bfe7..fdf3642b 100644 --- a/backend/app/notify/mail.py +++ b/backend/app/notify/mail.py @@ -253,7 +253,8 @@ _FOOTER = "这封邮件由 AgentLab 自动发出,请勿直接回复。不想 def build(cfg: MailConfig, to: list[str], msg: Message, *, unsubscribe: str | None) -> EmailMessage: """一封邮件:纯文本一份、HTML 一份(全部转义)。unsubscribe 是「设置 → 通知」的完整地址,没有就不带退订头。""" em = EmailMessage() - em["Subject"] = f"【AgentLab】{msg.title}"[:200] + # 标题里的换行(应用名、节点名是人起的)合成空格:邮件头里有换行就是头注入,标准库也会直接拒收 + em["Subject"] = f"【AgentLab】{' '.join(msg.title.split())}"[:200] em["From"] = formataddr((cfg.from_name or DEFAULTS["from_name"], cfg.from_addr or "")) em["To"] = ", ".join(to) em["Date"] = formatdate(localtime=True) diff --git a/backend/tests/test_notify_digest.py b/backend/tests/test_notify_digest.py index b6465971..dc005e12 100644 --- a/backend/tests/test_notify_digest.py +++ b/backend/tests/test_notify_digest.py @@ -51,21 +51,38 @@ async def login_on(received): """开着账号密码登录(个人渠道挂在认证过的账号上)。依赖 received:收尾时等推送推完,假的接收端还在。""" await auth_env.reset() digest.forget() - await auth_env.turn_on_local() async with SessionLocal() as session: + # 别的测试可能留下了 notify 一组、渠道和投递记录:整组换成这里要的,渠道和记录先清掉 + await session.execute(delete(Setting).where(Setting.key == "notify")) + await session.execute(delete(NotifyDelivery)) + await session.execute(delete(NotifyChannel)) session.add(Setting(key="notify", value={"public_base_url": "https://agentlab.example.com", "digest_at": "09:00"})) await session.commit() - yield + await auth_env.turn_on_local() + try: + yield + finally: + await _cleanup() + + +async def _cleanup() -> None: await service.drain() async with SessionLocal() as session: - for model in (NotifyDelivery, NotifyChannel, TaskAssignment, Task): + mine = select(Run.id).where(Run.workflow_name == "汇总测试") + tasks = select(Task.id).where(Task.run_id.in_(mine)) + await session.execute(delete(TaskAssignment).where(TaskAssignment.task_id.in_(tasks))) + await session.execute(delete(Task).where(Task.run_id.in_(mine))) + for model in (NotifyDelivery, NotifyChannel): await session.execute(delete(model)) await session.execute(delete(Run).where(Run.workflow_name == "汇总测试")) await session.execute(delete(App).where(App.name == "报销审批")) await session.execute(delete(Setting).where(Setting.key.like(digest.STATE_PREFIX + "%"))) await session.execute(delete(Setting).where(Setting.key == "notify")) await session.commit() + from app.core import public_url + + public_url.remember(None) digest.forget() await auth_env.reset() @@ -151,6 +168,23 @@ async def test_digest_follows_the_configured_time_and_skips_people_with_nothing_ assert received == [], "没有待办不发「0 条」" +async def test_nothing_is_sent_when_login_is_off(received): + """关登录时「派给我的」认不出是谁(人人等同管理员),访问密钥也不生效:汇总、到期提醒都不发,也不认领今天。""" + ana = await auth_env.make_user("ana", "user") + await personal(ana, ["task.digest"]) + await tasks_for("ana", 2) + svc = await auth_env.make_service("svc-off") + key, _ = await auth_env.make_key(svc, days=3) + await _age(key, 30) + from app.auth import remember_settings + + remember_settings({}) + assert await digest.digest_due(local(9, 0)) == [] + assert await digest.keys_due(utcnow()) == [] + async with SessionLocal() as session: + assert await session.get(Setting, digest.DIGEST_CLAIM + local(9, 0).astimezone().date().isoformat()) is None + + async def test_claim_rows_are_not_listed_as_settings(received): await digest.digest_due(local(9, 0)) async with auth_env.client() as c: diff --git a/backend/tests/test_notify_mail.py b/backend/tests/test_notify_mail.py index d2146d6a..47f22d7c 100644 --- a/backend/tests/test_notify_mail.py +++ b/backend/tests/test_notify_mail.py @@ -77,14 +77,23 @@ def smtp(monkeypatch): @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: @@ -100,6 +109,9 @@ async def configure(**over) -> None: 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() @@ -241,6 +253,11 @@ async def test_partially_refused_recipients_are_said_plainly(smtp, monkeypatch): 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"}))