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"}))