Files
AgentLab/backend/app/notify/__init__.py
T
ThearchyHelios 86d2840e41 W3-d 邮件渠道、待办汇总、访问密钥到期提醒(后端)
邮件(04 细案 B2,集成-I4 / I5,改写应用平台 Q6):
- notify/mail.py:标准库 smtplib 放进 asyncio.to_thread,单次超时 10 秒;4xx 和连不上按 1/2/4 秒重试,5xx 不重试;
  报错只写主机名和服务器回话,口令一律抹掉;纯文本 + 转义过的 HTML,带 List-Unsubscribe 和 Auto-Submitted;测试可换假的
  SMTP 工厂。模块说明写清改写 Q6 的理由:SMTP 是可选配置、退信不追踪照实说「已交给邮件服务器」
- 通知渠道多一种格式「邮件」(core.CHANNEL_FORMATS;定时任务的 Webhook 只会发 HTTP,FORMATS 不加,免得定时任务能选
  邮件):个人邮件渠道不收地址,只发到名录里的账号邮箱,没有邮箱记一条失败投递并写明去「设置 → 身份」填;全局、应用的填
  固定地址,最多 20 个;邮件服务没启用时不收新的邮件渠道;渠道列表另给 mail(启没启用、本人的账号邮箱),投递记录带格式
- 设置的 mail 一组(只有管理员能改):口令用 core/crypto 加密,读的时候只说配没配;启用时要配全;发件人名称、主机不许
  换行;「发测试邮件」只发到管理员自己的账号邮箱,关登录、没有邮箱时 422 说清怎么办
- mail.default_for_assignees(缺省关):开了之后第一次派到待办的人自动有一个个人邮件渠道(订阅待办打开、动作结果未知、
  审核有结果),本人删了不再建回来;关登录时不建

待办汇总与密钥到期(04 细案 B3):
- notify/digest.py 接进调度循环的步骤列表:每天到 notify.digest_at(缺省 09:00,服务端时区)给订阅了 task.digest 的
  个人渠道发汇总(条数、最早时限、最多 5 条节点名、去待办箱的链接,没有待办不发);按日期在 settings 表里插一行认领,
  重启不重发;认领行和自动建邮件渠道的名单是通知自己记的账,设置接口不列不收
- key.expiring:到期前 7 天、1 天各推一次,个人密钥推本人的个人渠道、服务账号的推全局渠道,推之前记审计、按审计去重;
  轮换下来的旧密钥、撤销了的、建的时候就在这一档里的不推
- 两个事件推送实现了,才在 notify/service.EVENTS 打开它们的订阅范围

设置的 notify 一组加 digest_at;只给一部分时补上存着的另几项;环境变量锁住对外访问地址时只核对那一项,汇总时间照常能改。
apps 一组加 share_links(分享链接的开关,缺省关,下一个提交用)。测试:假 SMTP 工厂下的收件人、失败投递、重试、口令、
设置与权限、自动建渠道;汇总按时、只发一次、重启不重发;密钥两档、谁收、不该推的几种。
2026-10-16 16:21:41 +08:00

41 lines
2.2 KiB
Python

"""通知的发送核心(附录 C,BE-2 提供)。从 scheduler/webhook.py 抽出:四种格式、签名、发送、脱敏(core.py);
webhook.py 照原名再导出,定时任务的通知存储不迁移(F14)。新对象(待办、实例、动作)的订阅和投递记录在
service.py:渠道(notify_channels)、投递记录(notify_deliveries)。渠道的第五种格式「邮件」走 mail.py(SMTP),订阅、
投递记录、消息结构都和 Webhook 同一套,只是在这里按格式分到另一条发送路上。"""
from __future__ import annotations
from typing import Any
from app.notify.core import Message, Outcome
async def send(channel: Any, message: dict[str, Any]) -> Any:
"""按渠道的格式发一条通知。message = {title, status, lines: [...], links: [(文字, 地址)]},不含报告里的具体数字。
channel 是一行 NotifyChannel(地址、密钥是密文)。返回 core.Outcome。"""
from app.core.crypto import decrypt
from app.notify import core
from app.notify.service import generic_body
url = decrypt(channel.url) or ""
secret = decrypt(channel.secret) if channel.secret else None
msg = Message(title=str(message.get("title") or ""),
lines=[(str(k), str(v)) for k, v in message.get("lines") or []],
links=[(str(k), str(v)) for k, v in message.get("links") or []],
ok=bool(message.get("ok")))
if channel.format == "email":
from app.notify import mail
return await mail.deliver(channel, message, msg)
if not url:
return Outcome(False, None, 0, "渠道的地址无法解密(加密密钥可能已更换),请重新填写地址")
if getattr(channel, "scope", None) == "user":
from app.notify.service import personal_url_problem
if problem := await personal_url_problem(url):
return Outcome(False, None, 0, problem)
return await core.post(channel.format, url, secret,
lambda: core.build_request(channel.format, url, secret, msg,
generic=generic_body(message, msg)),
label=f"通知渠道「{channel.name}」", retry=bool(message.get("retry", True)))