Nmail Nmail v0.4.7

Nmail 架构

本文档随代码同步维护:接口/模块/数据表有变化时,同一提交内更新此处。

总体形态

单机单进程:python run.py(源码开发)/ 控制台脚本 nmail(PyPI 安装)/ 单文件可执行(PyInstaller 冻结)三种入口启动 FastAPI(仅绑定 127.0.0.1),托管 /api/* 与前端静态产物(含 SPA 历史路由回退)。前端目录按运行形态解析:wheel 内 app/static → 冻结资源 _MEIPASS/app/static → 源码 frontend/dist。浏览器是唯一 UI,无原生 GUI、无系统托盘、无 OS 独有 API。

浏览器 (React SPA)
   │  REST /api/* + SSE (text/event-stream)
   ▼
FastAPI (uvicorn, 127.0.0.1:8720)
   ├─ api/          路由层(pydantic 校验,错误转中文 HTTPException)
   ├─ core/         邮件核心(IMAP/SMTP/同步/流水线/HTML 消毒)
   ├─ ai/           LLM 任务(OpenAI 兼容,base_url 可指向本地模型)+ Agent 上下文管理(context.py)
   ├─ scheduler/    APScheduler 后台线程(60s tick:轮询收信 + 每日摘要)
   └─ db/           SQLite(WAL) + FTS5(trigram) + 版本化迁移
   ▼
本地数据目录(platformdirs;Windows: %LOCALAPPDATA%/Nmail)
   ├─ nmail.db      全部业务数据
   ├─ secrets.json  AI key、各账号授权码、OAuth 客户端配置与令牌(AI key 明文回显设置界面供所见即所存;账号授权码不回传)
   ├─ accounts/<id>/attachments/  附件落盘
   └─ drafts/<id>/               写信台草稿附件落盘(发送/删稿即清)

nmail-cli/(独立包,REDESIGN_PLAN §19)

对外 API 的命令行客户端(独立 PyPI 包名 nmail-cli,uvx nmail-cli@latest 零安装;requests 为其运行依赖、测试走 ASGI 传输不依赖):JSON envelope + exit code 契约、本机自动配对(经内部 /api/extkeys)、发送两阶段确认(exit 8 + summary)、watch NDJSON 新邮件流(--timeout/--max-emails 可自动退出,agent 调用防挂起);配置 ~/.config/nmail-cli/config.json(0600),环境变量 NMAIL_BASE_URL/NMAIL_API_KEY 可覆盖。面向 agent 的技能说明:仓库根 skills/SKILL.md。版本线(2026-09-15 起):与 app 同一条版本线——scripts/sync_version.py 从根 pyproject 统一写入 nmail-cli/pyproject + __init__.__version__ + SKILL.md(release.sh 自动调用,test_version_sync.py CI 兜底),CLI 版本协商 _notice.update 由此恢复正确语义。

后端模块(backend/app/)

模块职责要点
main.py入口、lifespan(迁移+调度器启停)、SPAStaticFiles 回退、本机来源校验中间件(/api/ext/* 豁免改持 API Key + ext 调用日志)路由先于静态挂载注册;中间件出口统一 SPA 缓存策略(S-0921:index.html=text/html 响应 no-cache 每次回源验证,/assets/* 哈希文件名 immutable 一年强缓存——修「发新版后浏览器仍跑旧界面」,按响应类型判定对 Mount 内部生成的 / 响应无死角)
api/deps.pyAPI 层公共错误翻译mail_error_to_http(MailError→4xx/502 翻译表)、ai_config_or_400(AI 未配置/停用→400)、ai_result_or_http(AI 调用统一 400/502)——端点零样板
api/system.py/api/health、/api/update-check(24h 节流,force 可立即检查)、/api/system/paths(数据/安装目录实时解析:get_data_dir() 含 NMAIL_DATA_DIR 重定向;安装目录按运行形态——冻结=可执行目录、源码=仓库根(与版本号同一判定)、wheel=app 包所在目录)、/api/desktop-shortcut GET/POST/DELETE(桌面图标状态/安装/移除;GET 附 banner 布尔=未安装且未看过首跑横幅)+ /banner-seen POST(§7.2 显示即记,一次为准)、/api/update-apply GET/POST + /restart?port=(自更新状态/触发/一键重启,UPDATE_AND_DESKTOP.md §2–§3)、/api/quit POST(延迟 0.8s 强退,设置页退出按钮,UPDATE_AND_DESKTOP.md §6)只读展示,体现软件本地性,零硬编码;重启端点 port 由前端按 location 传入,新进程 --wait-port 接管同端口;quit 同款延迟强退节奏(SQLite WAL 崩溃安全)
api/settings.py通用设置 KV 读写 + AI 端点测试(2026-09-13 增:desktop_notifications_enabled 桌面通知总开关、notify_types 按类型细分(new_mail/ai_draft/digest/account_error,读侧与默认合并缺省视为开)、auto_insert_signature 自动签名——前端 NotificationBell 弹系统通知前按两键过滤,ComposeContext 开写信标签时注入签名一次);2026-09-17 增:shortcuts_enabled 键盘快捷键总开关(关=设置页清单内键位全停,MailBrowser/EmailReader/写信台键盘链各自守卫);2026-09-18 增:idle_exit_enabled 空闲自动退出开关(§7,只约束图标启动)含 ui_font/body_font 档位校验;GET 附带只读 detected_proxy(系统代理探测)与 effective_proxy(实际生效通道,前端 3s 轮询实时展示);AI 配置已移至 profiles;/api/ai/test 字段省略时回退激活档案
api/accounts.py账号 CRUD/测试/探测/后台同步触发/服务商预设/文风提示词授权码存 secrets.json(key=account_pwd:{id});POST /accounts/probe 未收录域名自动探测;代理跟随系统自动生效(core/netproxy,无账号级字段无开关);OAuth 账号拒绝改密(400 指引重新授权)、删除账号时一并清 OAuth 令牌与 folders 缓存 ;列表/详情回传 password 明文(授权码所见即所存,同 AI key;仅本机 API——对外 ext 有独立窄 DTO 不含密码)
api/folders.py文件夹:缓存列表/强制刷新/创建/重命名/删除(v0.4 P2,自 accounts.py 迁入并扩展)树数据源走 folders 表缓存(空/refresh=1 才连服务器 LIST);重命名/删除的文件夹名走查询参数(名内含分隔符);系统文件夹(INBOX+SPECIAL-USE 识别)不可改删(folder_guard);RENAME 本地缓存/邮件/断点跟随(UID 不变),DELETE 清邮件行/断点/附件目录
api/oauth.pyGmail/Outlook OAuth2:GET /api/oauth/status(三态:configured 自建 / builtin_available / can_authorize + 生效客户端掩码与回调地址)、PUT /api/oauth/config(自建 client_id/secret/回调路径登记,configured 语义=自建已配置)、POST /api/oauth/authorize(发起授权返 auth_url,内置凭证免预检)、GET /api/oauth/flow/{state}(前端轮询结果)、GET /oauth/callback + GET /(回环回调,直出自关闭 HTML;失败页附高级区自建降级引导)回调地址=http://localhost:{进程端口}{生效客户端登记路径}(端口随 run.py 动态,端口被占会顺延——桌面型 OAuth 客户端对 localhost 回环不校验端口,路径不豁免);GET / 按 state 参数与 SPA 首页分流(授权重定向必带 state;不带则回退前端 index.html),仅在精确 / 拦截、其余路径仍归 SPA;回调里完成换令牌→建/转账号→触发首同步(存令牌带签发 client_id);流程状态进程内 10 分钟 TTL,state 防伪、verifier 用后即弃;回调页不携带任何令牌内容
api/emails.py列表/搜索/详情/操作/附件下载/归档迁移搜索:≥3 字走 FTS5 trigram,<3 字回退 LIKE;详情返回消毒后 HTML(?images=1 放行远程图+内联 cid)。v0.4 归档语义(§4.6):archive=移到账号服务器端归档文件夹(accounts.archive_folder 缺省 Archived,惰性创建),unarchive=移回收件箱,批量归档走 imap_batch job;GET /emails/archived_pending+POST archived_migrate/dismiss=存量本地归档一次性迁移流(迁移前管线清扫挂起,KV archive_migrate_done)
api/contacts.py通讯录(2026-09-12 改版,REDESIGN_PLAN §5.3/§5.4):聚合列表/搜索(source/group_id/ungrouped 过滤 + 四视图计数)、联系组 CRUD 与成员增删、/{id} 详情(聚合行+各账号明细)、手动新增、按 email 作用域的改/删、/suggest 写信联想同邮箱多账号聚合为一行(编辑/删除作用于该邮箱全部行,改邮箱连带更新组成员表);手动新增=全局作用域 source=manual(不被采集覆盖);改名即转 manual;组端点移除成员走 POST /members/remove(DELETE+body 兼容性)
api/user_drafts.py草稿统一 API(v0.4 P3):CRUD + 附件上传/删除 + 定时/取消 + 发送 + AI 待审流转(丢弃/恢复/带指令重写/按邮件拟稿)+ 按状态批量清空(DELETE /api/user-drafts?status=,仅开放 sent/discarded 终态)status 全集 editing/scheduled/pending_review/sent/discarded,origin 区分 ai/human;列表 LEFT JOIN in_reply_to 原邮件作上下文;发送走 core/outbox.send_user_draft 唯一通路(pending_review 同样适用);/regenerate-for-email 供邮件视图一键拟稿;原 api/drafts.py 已退役删除(路由 /api/drafts 不复存在);S-0921:/{id}/precheck 发送前检查(规则层+AI 深审合并返回,人工弹卡可越过)、/{id}/copy-template-attachments 模板附件复制进草稿(同名/源缺失跳过)
api/meta.py前端元数据:分类枚举下发 + frontend_build 构建指纹(vite 构建写 dist/build-id.json、define 注入前端,前端 NewBuildBar 30s 轮询比对——不一致提示一键刷新,修「后端更新前端后页面不自知」;每请求现读,不重启只 build 也感知) GET /api/meta:分类枚举下发(key/label/color/badge_cls)真源 ai/categories.py;前端 useMeta 拉取一次长期缓存,徽章/图表色不再手写
api/compose_extras.py写信台模板/签名 KV(Markdown 文本整存整取)+ POST /markdown Markdown→消毒 HTML 转换 + 模板附件上传/删除条目级端点(S-0921:模板可带默认主题与附件——KV 存 subject/attachments 元数据,老模板无字段行为不变;文件落 data_dir/compose_template_files/<id>/,上传要求模板已保存、单模板 50MB 上限;update_extras 整存 diff 清理被删模板/清空附件的落盘文件)存 settings KV(compose_templates/compose_signatures)
api/ai.py单邮件问答、总管家问答(会话持久化)、Agent 对话(§17 v2)、写作辅助、用量、AI 整理问答/总管家均有 /stream SSE 版本;Agent:POST /api/ai/agent/stream(SSE 事件 run_started/text_delta/text/tool_call/tool_result/approval_required/paused/error/done,分段轨迹落会话 segments_json)、/agent/resume(续跑:审批决定/步数预算触顶/ask_user 澄清回答〔可选 answer〕后,决定结果回写前一条消息 waiting step)、/agent/runs(运行列表,跨刷新恢复「继续」入口——A6)+ /agent/runs/{id}(详情:状态/pending/ai_logs 步级 token 耗时——A8)、/agent/action/{id}/decide(批准/拒绝+权限复核+参数可改)、/agent/action/{id}/undo、/agent/actions(审计;DELETE 单条删 / `?scope=old
api/jobs.pyGET /api/jobs/active(观测口)、GET /api/jobs/{id}(前端 useJob 轮询)任务体注册在业务模块(pipeline/batch_ops),core/jobs 只管调度与登记
api/notifications.py通知中心—
api/sender_lists.py白/黑名单(邮箱或 @域名)管线中零成本先过滤
api/chats.py总管家会话持久化(chat_sessions/messages):列表/消息/置顶/重命名/删除列表 置顶>updated_at 倒序;删除级联;append_message 支持 segments_json(v22 结构化分段),read_chat 原样返回;旧消息 segments=NULL 前端回落纯文本;memory_json(v23)由 agent 上下文管理读写(任务简报+动作台账,§17.8)
api/profiles.pyAI 配置档案 CRUD/激活/模型列表代理(POST /api/ai/models,显式 URL/Key 优先、回退档案已存值)+ AI 总开关(PUT /api/ai/enabled)+ context_window 上下文窗口字段(§17.8,0=恢复默认)密钥语义:响应回显 api_key 明文(本地单用户应用,所见即所存,清空保存=清除);ai_enabled=false 即传统邮件模式,档案保留(/models 属配置辅助不受开关限制)
api/digest.py每日摘要查看/手动生成/重要邮件清除(POST /api/digest/important/{email_id}/dismiss)清除=快照 JSON 落 dismissed_important 记录,GET 过滤展示;同日重新生成经 build_digest 沿袭不清单(不复活),跨天随新摘要自然重置
api/ext.py对外 API(v0.4 P7,REDESIGN_PLAN §7):/api/ext/v1/*——health(免认证,含 version 供 CLI 版本协商 _notice.update——B2)/accounts/emails(列表/搜索〔P1:sender/recipient/after/before/has_attachments 过滤〕/详情/附件/emails/recent 游标轮询〔P1 watch〕)/emails/actions(批量,慢动作返回 job_id 经 /jobs 轮询)/drafts(列表/单条〔P2,CLI 发送摘要用〕/创建〔P1 正文三选一 html/md/text〕/replyforward 回复转发〔P1,对齐写信台语义〕/approve 发送/{id}/attachments 上传〔P1〕)/folders(+/sync 按需同步)/contacts/digest/agent(chat 非流式+SSE/decide 审批)
api/extkeys.py对外 API 密钥管理(仅内部设置页,走常规来源校验)明文存 secrets.json(ext_api_key:{id},所见即所存回显),表内只留 sha256 哈希;生成/改名/scope/每日上限(0=不限)/重置(旧串立即失效)/吊销(行保留供日志对账)+已吊销行彻底删除(DELETE 两段语义:活跃=吊销、已吊销=删行,日志回退「已删」,2026-09-15 用户反馈补);/enabled 总开关+日志开关;/calls 调用日志(30 天保留)
core/providers.py20 个服务商预设(含中文授权码提示)+ 未收录域名自动探测按域名自动匹配;probe_server():autoconfig 标准接口 → 常见主机名 993/465 并发试连(只收加密端口)
core/imap_client.pyIMAP/SMTP 封装连接/读写超时 60s;fetch_uids_parsed 按 UID 集合拉取解析(稠密窗口区间 FETCH、稀疏窗口逐 UID 精确拉取——移入型文件夹如「已删除」日期与 UID 不单调,QQ 会把任何多 UID 集合按 min:max 连续展开);iter_new_mail 分块产出新增邮件(SEARCH UID 清单 → 稠密窗口区间 FETCH、稀疏窗口逐 UID 精确拉取——移入型文件夹如「已删除」日期与 UID 不单调,QQ 会把任何多 UID 集合按 min:max 连续展开);网易系需 IMAP ID 命令;SMTP 端口 465=SSL/587=STARTTLS;append_sent 发送后归档;MailConfig.access_token 非空时 IMAP 走 xoauth2、SMTP 走 AUTH XOAUTH2(不广播时回退裸 docmd)
core/oauth.pyGmail/Outlook OAuth2 授权与令牌管理(PKCE 授权码流程、XOAUTH2 编码、令牌刷新)服务商参数内置(Gmail scope 仅 https://mail.google.com/、Outlook 用 outlook.office.com 资源 + offline_access;端口 465=SSL/587=STARTTLS)。客户端来源(v0.4 P5,D1=A):BUILTIN_CLIENTS 内置公开桌面客户端凭证开箱即用(redirect_path=”/“,来源 Thunderbird 公开源码,免责见模块 docstring);用户自建(secrets oauth_client:{provider},redirect_path 缺省 /oauth/callback)永远优先,get_client() 回退链带 source 标记。令牌存 oauth_token:{account_id}(expires_at 预扣 120s 余量、微软轮换覆盖、client_id=签发客户端);client_for_refresh 按签发者选边刷新(内置/自建混用不互相污染);ensure_access_token 按账号加锁防并发重复刷新;令牌交换跟随全局代理(netproxy.httpx_proxy_arg);教程见 docs/OAuth2 使用指南.md(快速授权)与自建教程(高级)
core/netproxy.py网络代理(被墙服务商场景):跟随系统、零开关零配置的建连层(语义=浏览器)地址解析:自动检测系统代理(urllib.getproxies:macOS 系统代理/Windows 注册表/env,每次连接现读;socks:// 归一 socks5),无手动地址无开关(network_proxy 设置已整体移除);所有账号 IMAP/SMTP/OAuth 统一生效(无账号级字段,accounts.use_proxy 已废弃不读);PySocks 套接字 + 标准库注入(IMAP4_SSL 覆盖 _create_socket、smtplib 覆盖 _get_socket,httpx 显式传 proxy),不全局替换 socket(保本地回环与并发隔离);socks5 rdns=True 防污染;坏配置静默直连;本机回环永不代理(Proton Bridge)
core/precheck.py发送前检查规则层(S-0921):零成本零 AI 依赖,人工/定时/AI 三个发送入口共用rule_issues(subject, body_html, has_attachments) 返回 {code,severity,message} 列表:空主题 blocker;占位符残留 blocker({x}/[x]/___/X 序列/中文占位词,方括号内纯数字时间编号白名单、X 避开邮箱网址);附件意图核对(提到没带=blocker,带了没提=warn)。draft_rule_issues(draft_id, row) 查附件数后跑同检查。人工发送前端先调 precheck 端点弹卡可越过;定时发送与 AI 发送在后端强制——blocker 不发出、退回来源态并通知。AI 深审叠加在 ai.tasks.review_send_draft(开关 ai_send_review,未配置/失败自动降级仅规则层)
core/mailbox.py账号凭据/连接统一入口(全项目唯一 MailConfig 构造点)load_account(账号行+密钥 → AccountHandle,缺一抛 MailError;OAuth 账号先 oauth.ensure_access_token 刷新令牌再装配)、has_credentials、open_imap;API 层 _imap_for 等拼装点逐步迁移至此(IMPROVEMENT_PLAN §3.1);添加账号入库前的表单直连预检除外
core/folders.py服务器文件夹缓存与 CRUD(v0.4 P2,REDESIGN_PLAN §4)folders 表只做缓存(服务器为真);SPECIAL-USE 标记优先、名称启发式兜底(sent/drafts/junk/trash/all;不做 archive 启发式防误标);folder_guard 系统文件夹守卫;ensure_archive_with_mb 归档文件夹惰性创建;删除/服务器消失的文件夹清理本地邮件行/断点/缓存/附件目录
core/contacts.py通讯录采集与联想(v0.4 P4,REDESIGN_PLAN §5.3)upsert_contact SELECT-then-UPDATE/INSERT(表达式索引不支持 upsert 冲突目标且作用域含 NULL);source=manual 只计数不覆盖姓名;collect_sender(sync 新邮件)/collect_addresses(发送 To/Cc/Bcc,支持「Name a@x」)受 contacts_auto_collect 开关门控(关=不入册,已有数据保留);suggest 全局去重取最优行、use_count×最近加权;管理侧 list_contacts_agg/list_groups/set_members(成员按 email 记,删除联系人后清孤儿行)
core/sync.pyUID 增量同步分块断点续拉(每块入库+断点同一事务提交,中断从断点续传);start_sync 后台线程执行(防重入),进度写账号 status=syncing+status_detail;网络异常自动重试一次;首翻全量(B1:最新一页立即可用+独立回补线程从新到旧补齐全部文件夹历史,断点可续传,回补不进 AI 流水线/不发通知/不采通讯录,Gmail All Mail 除外);UIDVALIDITY 变化自愈;登录失败→auth_error+一次性通知;增量拉取后 FLAGS 对账(UID SEARCH UNSEEN/FLAGGED ↔ 本地 is_read/starred,只翻差异行——外部客户端已读/星标变化的入网点,SEARCH 失败跳过不阻塞同步);新邮件按服务器 FLAGS 初始化已读/星标;缺凭据的 OAuth 账号置 auth_error+一次性通知(不再静默跳过),授权恢复后自动回 ok;同步完成后触发 AI 流水线
core/outbox.py草稿发送唯一实现(API 与调度器共用)+ 旧数据迁移send_user_draft:状态校验(editing/scheduled/pending_review)→地址解析→AI 草稿自动补默认签名(origin=‘ai’ 且 auto_insert_signature 开启;幂等,B6)→消毒+纯文本派生→mailbox.send_message→标记 sent/清附件/回复原邮件补标已读;失败抛 MailError;draft_dir 为草稿附件目录唯一出处;template_files_dir(S-0921)为模板附件目录唯一出处——放 core 因 ai/tools 的 apply_template 也要用(ai 层禁 import api 层)。migrate_legacy_ai_drafts:v0.4 P3 启动期一次性把旧 drafts 表(AI 待审)并入 user_drafts(Markdown→HTML 与原 approve 同源、Re: 主题、收件人=原发件人、状态映射 pending→pending_review;KV legacy_drafts_migrated 门控,门控与数据同一事务原子提交);旧 drafts 表保留只读
core/jobs.py轻量任务执行器(ThreadPool 2 线程)@runner(kind) 注册表 + submit(dedupe 防双击)+ report(进度/阶段入 jobs 表)+ 失败进表;GET /api/jobs/* 供前端 useJob 1s 轮询
core/batch_ops.py批量 IMAP 动作任务体(trash/move/archive/unarchive 异步化)按账号分组共用连接、进度按账号上报;R2 语义(拿不到新 UID 删行交增量重建)与「服务器成功才动本地」保持;archive 逐账号惰性建归档夹再移动;打标类在端点同步执行;成功归档/删除后回写 core/rule_proposals.observe(§18.5 规则提议观察,sender 移动前快照)。v0.4.1:移动类发生删行后就地复用同一 IMAP 连接增量同步目标文件夹(sync.resync_folder_with_mb,删行重建不再等下次轮询——动作后立即可读),并按 message_id 找回新 id 随 job 结果返回 rebuilt: {旧id: 新id}(QQ 等不回 COPYUID 的服务商上旧 id 会失效);trash 不做重建(删除即本地消失是既有语义)
core/rule_proposals.py手动整理观察 → 黑名单规则提议(§18.5)rule_observations 按 email_id 去重记录;同发件人 14 天 ≥3 次 → pending 提议(agent_proposals;已在名单/pending/rejected 不再提、pending 上限 5);提议生成幂等(GET 列表懒触发兜底);批准/拒绝在 api 层(批准转调 add_sender_list 既有管线)
core/pipeline.py白/黑名单 → AI 批量分类 → 营销自动归档 → 生成草稿 → 通知AI 未配置诚实降级;批量 20 封/请求。v0.4:营销/黑名单归档经 _sweep_server_archive 落服务器归档文件夹——archived_local 只是移动前暂存标记(失败保留、下次管线自动重试);存量迁移决策未做(KV=0)时清扫挂起;AI 拟稿直接写 user_drafts(pending_review, origin=ai)(P3 起与手写同表同通路)
core/mail_html.pynh3 白名单消毒 + 远程图片拦截 + <style> 放行 + cid 内联 + Markdown→HTML三层防护:nh3 消毒在前;图片控制在后(放行时追踪像素=声明尺寸≤2px 置 display:none、缺 alt 的远程图补空 alt 防裂图);<style> 标签 nh3 默认连内容剥离且不可放行(tag 与 clean_content_tags 同现 panic),先摘出 CSS 按图片口径清洗再注回,拦截口径同步剥 style 属性远程 url() 与 @import(data: 保留);另供发件方向 sanitize_outgoing_html(放行 data: 内嵌图)与 html_to_plain_text/wrap_email_body_html
core/update_check.py应用内更新检查:GitHub Releases 对比 + 通知中心提醒仅匿名 GET api.github.com(UA=Nmail/版本),24h 缓存;开关 update_check_enabled;按 ref_id=版本去重,升级后自动清理旧提醒;notify_ready() 把就位版本的通知改写为「重启即更新」
core/channel.py安装渠道识别:binary(Release 直装)/ brew / winget(冻结二进制按安装路径特征)/ pip / uvx(uv 缓存 archive-v0 特征)自更新与桌面集成的分流依据;can_self_update()(binary/pip)与 upgrade_hint()(不可自更新渠道的升级命令)
core/desktop.py桌面图标一键安装(UPDATE_AND_DESKTOP.md §2/§2.1):Windows 桌面+开始菜单 .lnk(PowerShell COM;uvx 渠道指 wscript 跑 UTF-16 .vbs 隐藏启动器、pip 指 pythonw、binary 直指 exe)、macOS /Applications/Nmail.app(无权限回退 ~/Applications;Info.plist+icns+编译型存根 nmail-stub 注册 NSApplication——纯脚本 bundle 不被 LaunchServices 注册即无 Dock 图标,2026-09-15 用户实测后修复;服务为存根子进程,Quit 信号链式停服;uvx 渠道 server 脚本跑 uvx 命令)、Linux .desktop+hicolor;nmail install-shortcut/uninstall-shortcut 子命令同源只写用户目录;uvx 渠道图标指向 uvx --from nmail-app nmail --idle-exit 命令(uvx 绝对路径安装时定死)——不依赖 uv 缓存环境,升级/prune 不死链、每次双击即最新版(2026-09-18 §2.1);其余渠道优先同环境 console script,源码直跑注入 backend 路径 -c 内联;所有图标启动带 --idle-exit(§7);状态记数据目录 desktop-shortcut.json,路径失效=不健康提示重装;图标资产随包分发(backend/app/assets + nmail.spec datas)
core/idle_exit.py空闲自动退出(UPDATE_AND_DESKTOP.md §7,2026-09-18 拍板):cli --idle-exit(裸 flag=读设置项;N=显式秒数/0 关)定模式;main.py 挂 ActivityMiddleware(纯 ASGI,请求全程计数+刷活动时刻)+ lifespan 起 watchdog 协程(每 tick 重读 idle_exit_enabled 设置项——开关即时生效;无在途请求且空闲超阈值→注入钩子置 uvicorn should_exit 优雅退出)阈值 90s(60s 会被浏览器后台标签节流误杀);活动信号=HTTP 请求(前端常驻轮询即心跳,无需长连接);仅图标启动启用(终端裸跑无 flag 恒不退,自动化零影响);关闭前未完成同步靠 UID 增量下次启动续传
core/update_apply.py应用内自更新执行(UPDATE_AND_DESKTOP.md §3):下载(进度节流落 KV)→ SHA256(Release 资产 digest)→ 换身(运行中可执行文件可 rename 不可覆写:本体 rename *.old 后原路径原子落位,失败回滚还原)→ ready + 通知;restart_app() spawn --wait-port 接管同端口后本进程退出;auto_update_tick() 自动心跳(检查开启+自动安装开启+渠道可自更新+确有新版本才触发)换身仅冻结 binary 渠道;pip 渠道 pip install --upgrade 原地升级;cli 启动早段 finish_pending_swap() 收尾半程下载(全函数零抛错——首启 KV 表未建也不炸);状态 KV update_apply_state,下载暂存 <DATA_DIR>/update/nmail.new
ai/llm.pyOpenAI 兼容客户端(流式 iter_deltas / 非流式)+ Agent 单步 chat_step/iter_chat_step(原生 function calling,tools+tool_calls 解析,流式分片按 index 聚合,§17.1)connect 5s / 读写 15s 快速失败
ai/profiles.pyAI 配置档案存储/解析(settings JSON + secrets 分离)+ 总开关解析顺序:显式 profile_id > 激活档案 > 第一个;resolve_config() 在总开关停用时直接抛 ProfileNotConfigured(全任务统一拒绝);不预建档案(全新安装为空列表);旧单配置首读迁移为以模型名命名的档案,历史自动生成的「默认」档案一次性按模型名重命名;列表双写备份(ai_profiles_backup)——主值缺失/损坏时自愈恢复,绝不静默走旧配置重建;孤儿密钥对账:不被档案引用的 ai_profile_key:* 即读即清
ai/tasks.py分类/草稿/问答/写作 + 用量日志 + 发送前 AI 深审(S-0921 review_send_draft:JSON 输出 blockers/warns,主题匹配/模板痕迹/称呼日期硬伤/错别字)所有任务接受 profile_id 并经 _ai_config() 按档案解析;所有调用写 ai_logs(深审 task_type=send_review)
ai/digest.py每日摘要:统计(零成本 SQL,不调 LLM);AI 摘要正文经 store_brief 落同日行(§18.6)统计当天已生成则复用(保留同日 AI 摘要正文与✕清除记录)
ai/prompts.py全部提示词模板结构化输出要求纯 JSON,_extract_json 容错解析;分类枚举段由 ai/categories.py 生成
ai/categories.py分类枚举单一来源(key/label/color/badge_cls/判定说明)prompts 分类段、tasks 校验、pipeline 自动归档、digest 分桶、/api/meta 下发全部消费此处——加分类只改本文件
ai/context.pyAgent 上下文管理(§17.8):token 估算器(CJK≈1/字 ASCII≈/4,真实 usage.prompt_tokens 校准滑动比率)+ 五段式摘要模板/解析 + 确定性纪要与折叠边界 + 会话记忆读写(memory_json:任务简报+动作台账,上限 40 行)窗口默认 1,000,000(用户拍板),档案 context_window 按模型实际值指定(<8192 视为未设);阈值 FOLD 55% / COMPACT 80%;摘要/纪要一律带防注入标注(其中指令均来自邮件内容)
ai/tools.py总管家工具集(§17.3 对齐扩充)39 个工具(11 读 + 1 meta + 27 写;B6 人人对等扩充:list_templates/list_signatures/list_contact_groups 读,apply_template(draft)/apply_signature/manage_contact_group/set_settings/trigger_sync 写——set_settings 仅白名单键(通知/自动签名/通讯录采集/轮询间隔),高风险键 agent_brief_enabled/digest_time/allow_remote_images/read_email_max_chars 由 _approval_reason 硬规则强制审批(自动模式也降)),每个带 JSON Schema(原生协议);ask_user(A5 澄清中断,kind=meta 不入审计、scheduler 白名单天然排除)与 read_skill(A7 内置工作流技能全文);search_emails 支持分类/发件人/未读/日期过滤且默认覆盖全部文件夹(修 v1 锁死 INBOX),空结果带结构化 hint;新增 set_category/rename_folder/delete_folder/update_draft/schedule_draft/discard_draft/upsert_contact/delete_contact/add_sender_list/remove_sender_list/save_memory/list_memory/delete_memory(§18.5 跨会话记忆:evidence 必填=用户原话引用,同文去重,上限 100 条);薄壳转调既有能力;无任意 HTTP/文件系统/命令类工具,也无账号/凭据/授权位/密钥类工具(§17.3 豁免防注入自我扩权)
ai/agent.py总管家 Agent 循环 v2(§17,可恢复)原生 function calling 优先(探测降级 JSON 协议并按 base_url+model 缓存 KV;_extract_json 首个平衡对象止血);时间预算优先(180s 超限 tool_choice=none 强制文本收尾,仍调工具则暂停)+ MAX_STEPS=25 兜底;审批不断链:写类出卡后 run 置 waiting_approval(agent_runs v22 持久化 messages/步数/预算),批准/拒绝经 decide 后由 /agent/resume 续跑(拒绝同样回灌让模型改道),步数/预算触顶前端「继续」=新的一段预算;工具结果分工具预算紧凑回灌(read_email 4000 其余 1200 字符);上下文管理 §17.8 五层:L2 微压缩(步数/token 双门,早期结果→确定性摘要行,只缩不删保配对)+ L3 会话记忆(chat_sessions.memory_json 注入 system+动作台账增量回写,服务端自取历史)+ L5 AutoCompact(水位≥窗口 80% 调一次 LLM 压五段式摘要,原文归档 archived_json,KV agent_autocompact=0 可停)+ 溢出自愈(context overflow 报错紧急压缩重试一次);每步流式 text_delta;_feedback_text 邮件列表转行格式;调度定时运行(§18.6 AI 摘要)走 SCHEDULER_ALLOWED 工具白名单(schema 过滤+执行层双拦、allowed_json 随 run 持久化);自动模式约束与 ai_actions 审计/undo 全保留(v1 见 §6);§20 扩展:触顶先强制小结再 paused(A1)+ 写类执行失败由代码在最终回答末尾附事实回执(2026-09-17 移除 A2 正则断言门:相信模型转述+失败回执,零语义猜测)+ 工具结果保头尾截断(A3)+ 同批全只读并行执行≤4 workers 原序回灌(A4)+ ask_user 澄清 waiting_input 暂停经 resume(answer) 续跑(A5)+ 系统提示词注入内置技能索引(A7,skills_builtin)
db/database.py连接(WAL)+ MIGRATIONS 版本化迁移(v25:rule_observations/agent_proposals/agent_runs.allowed_json;v24:agent_memory;v23:chat_sessions.memory_json、agent_runs.archived_json/summary_json)+ KV 设置迁移只追加不改历史
scheduler.py每 60s tick:到期账号增量同步 + 摘要到点生成 + 定时草稿派发 + AI 摘要分流(§18.6)「检查到期」而非每账号注册任务,改设置无需重建调度;定时草稿到期调 core.outbox.send_user_draft(不依赖 API 层),成功/失败写通知中心,失败退回 editing;轮询拉 INBOX + 归档文件夹(仅当该账号文件夹缓存里已有归档夹;v0.4.1——归档是真实服务器移动,归档夹不进轮询会让移动后删行重建的邮件迟迟不可见),其余文件夹按需同步(树选中触发)且不进 AI 管线(sync.py 仅 INBOX 新邮件进管线);agent_brief_enabled 开启时 digest_time 到点改跑 AI 摘要(独立线程,origin=scheduler + SCHEDULER_ALLOWED 工具白名单);定时/摘要页手动(origin=manual)共用 start_daily_brief 入口(进程内互斥,运行标志重启自清),触发即盖 KV agent_brief_last_run(手动跑过当天定时不重跑);AI 摘要文本经 digest.store_brief 写入当日摘要页(失败/无产出回退统计版 build_digest,统计重建保留同日正文);每小时 update_tick 兜底自动更新心跳(检查侧 24h 缓存节流,不可自更新/开关关闭时内部直返)

前端(frontend/src/)

部分内容
pages/MailPage(邮件基座:FolderTree + 视图组合——聚合收件箱/账号收件箱/服务器文件夹走 MailBrowser、草稿=DraftsHubPage(v0.4 P3:AI 待审+手写合并,分段筛选 待审/编辑中/定时中/已发送/已丢弃,origin 徽章,批准发送/编辑后发送/重写/丢弃/恢复/取消定时);选中非 INBOX 文件夹触发按需同步;拖拽移动走 batchAction job;内置存量本地归档一次性迁移弹窗;视图初值取 URL,旧路由 /drafts /mydrafts → /?view=drafts、/archived → /)、DigestPage(ECharts 摘要)、ManagerPage(AI 总管家 2.x §17:对话 Agent——消息按 segments 渲染(流式 Markdown 文本 + 连续步骤合并的可折叠「执行过程」块:运行中自动展开/完成自动收起、行点开看参数明细、中文工具名映射 + 撤销钮;审批卡独立醒目带影响明细,批准/拒绝后自动续跑)、步数/预算触顶「继续」按钮、Stop 中断(AbortSignal)、错误内联红条;审批/自动模式开关、账号范围/档案选择不变)、SettingsPage(侧边栏分类:通用/邮箱账号(含 AI 权限面板=5 授权位+AI 专属邮箱)/通讯录(2026-09-12 改版:左树=智能视图+联系组右键管理、自动采集开关,右列表/详情双态,详情可写信(openNew 带初始收件人)/编辑/加入移出组)/AI 配置/AI 用量(含 AI 操作记录查看器)/API(v0.4 P7 对外 API:总开关+日志开关+密钥生成/重置/吊销+明文遮蔽回显/调用日志,ExtApiSection 组件)/关于)
components/Layout(v0.4 无侧栏:顶部标签条=邮件基座页签(唯一常驻)+ 可开启页面页签(AI/摘要/设置,经右侧图标按钮点击产生,记忆 localStorage)+ 写信页签覆盖层(写信激活时底层 display:none keep-alive)+ 右侧图标按钮区 通知/AI 总管家/每日摘要/设置/新邮件)、FolderTree(v0.4 P2 完整版:智能视图 + 账号折叠组(状态点/展开记忆/懒加载 folders 缓存);服务器文件夹层级树(INBOX→Archived→系统→自定义,分隔符组树);右键菜单=新建(子)文件夹/重命名/删除/刷新(系统文件夹守卫);拖拽落点(同账号邮件移入,跨账号忽略);All Mail 守卫置灰不可点)、ContextMenu(通用右键菜单:定位夹紧/Esc 关闭)、MailBrowser(三态:列表/分屏/全屏;账号/文件夹由树经 initialAccountId/initialFolder 下发换 key 重挂;行可拖拽(多选集合一起拖,application/x-nmail-ids);键盘 j/k/x/o/e/#/c///;筛选栏留星标+分类+面包屑)、EmailReader(消毒 iframe+操作栏+AI 面板)、HtmlMail(sandbox=allow-same-origin+allow-popups,外链新标签打开,ResizeObserver 高度自适应+zoom 注入+正文基础样式兜底)、compose/(写信工作台:ComposeContext 多标签状态中枢挂 App 级 + 草稿缓存恢复、ComposeWorkbench 当前标签表单、ComposeForm 字段+附件上传落盘+定时+自动保存、RichEditor=TipTap v3 富文本、AiWriteDialog 指令生成/快捷改写→预览→替换或插入、InsertDialogs 模板/签名菜单与管理弹窗、quote.ts 回复/转发引用、ui.tsx Dropdown/Modal)、AddAccountModal、AiPanel、NotificationBell(浏览器通知)、Markdown(react-markdown+gfm)
api/client.ts(REST 封装,FormData 不设 JSON 头)、stream.ts(SSE 解析,错误可见)、useAI.ts(AI 总开关 hook,与设置页共享 [‘ai-profiles’] 缓存;停用时全应用隐藏 AI 入口——含树「待审草稿」节点与 AI/摘要图标)
字号系统index.css 三档 CSS 变量(--fs-xs/sm/md/lg+行高),<html data-font> 切换(FontApplier 读设置应用);t-* 是唯一字号入口——scripts/lint-font.mjs 门禁(npm run build 前置)禁止 Tailwind 裸字号类与 text-[Npx](白名单:Markdown/HtmlMail 富文本渲染),裸类不随档位缩放是历史「字号不一」根因;正文字号独立经 iframe zoom 注入

数据表(nmail.db)

settings(KV) · notifications(保留 500 条) · accounts(含 ai_permission/ai_grants v17 五授权位 JSON/is_ai_mailbox AI 专属邮箱/style_prompt/archive_folder) · emails(含分类/needs_reply/archived_local——v0.4 起为「待服务器归档」暂存标记) · attachments · folders(v15 文件夹缓存) · contacts(v16 通讯录,v21 加 phone) · contact_groups/contact_group_members(v21 自定义联系组,成员按 email 记) · ai_actions(v17 Agent 动作审计:tool/params/mode/origin/status/undo_json,pending 24h 过期语义;§18.3 保留期 30/90 天、发送类永久) · agent_memory(v24 跨会话用户偏好:content + evidence 必填佐证/source——§18.5,注入系统提示词尾部) · rule_observations(v25 手动整理观察:email_id 去重/sender/action) · agent_proposals(v25 黑名单规则提议:pattern/evidence_count/pending|approved|rejected) · api_keys(v20 对外 API 密钥:sha256 哈希/scopes JSON/daily_limit/revoked,明文在 secrets.json) · api_calls(v20 调用日志,保留 30 天) · sync_state(uid/uidvalidity/v26 backfill_uid+backfill_done 回补断点) · emails_fts(trigram) · drafts(v19 起退役,只读保留) · user_drafts(唯一草稿存储:editing/scheduled/pending_review/sent/discarded + origin + instruction + send_at) · user_draft_attachments · ai_logs(全量 AI 用量,保留 90 天) · sender_lists · digest_history · chat_sessions/chat_messages · jobs(后台任务进度/结果)

迁移版本:v1 基础表 → v2 邮件核心+FTS → v3 AI 层 → v4 摘要+ToneDNA → v5 会话持久化 → v7 date_sort 排序修复 → v8 user_drafts → v9 草稿附件+send_at → v10 语气学习退役→文风提示词(tone_dna 数据转存 style_prompt 后删列)+ AI 总开关(settings KV ai_enabled) → v11 清语气学习残留用量日志(ai_logs task_type=‘tone_dna’) → v12 jobs 后台任务表 → v15 folders 缓存表 + accounts.archive_folder + 存量归档迁移标记(KV archive_migrate_done,有 archived_local 存量才落 0) → v16 contacts 通讯录表 → v17 AI 2.0:accounts.ai_grants 五授权位 JSON(按 ai_permission 映射回填)+ is_ai_mailbox + ai_actions 审计表 → v19 草稿体系合并:user_drafts 加 origin/instruction(数据并入走启动期 outbox.migrate_legacy_ai_drafts,KV legacy_drafts_migrated 门控) → v20 对外 API:api_keys(sha256 哈希/scopes/daily_limit/revoked)+ api_calls 调用日志(18 跳号——原预留 API key 改走 v20) → v21 通讯录改版:contacts 加 phone + contact_groups/contact_group_members(成员按 email 记,匹配「同邮箱聚合一行」口径) → v22 Agent 化:agent_runs(可恢复运行)+ chat_messages.segments_json → v23 §17.8 上下文管理:chat_sessions.memory_json + agent_runs.archived_json/summary_json → v24 §18.5 跨会话记忆:agent_memory(content/evidence 必填/source) → v25 §18.5/§18.6 规则提议与晨报:rule_observations + agent_proposals + agent_runs.allowed_json

关键流程

同步管线(每次轮询/手动收信)

UID 增量拉取 → 落库+附件落盘 → FLAGS 对账(UID SEARCH UNSEEN/FLAGGED ↔ 本地 is_read/starred)
→ 白名单(留收件箱)/黑名单(直接归档)
→ AI 批量分类(category/importance/needs_reply/reason)
→ 营销(promo)自动本地归档 → 需回复且账号权限≥draft_review → 生成草稿+通知

安全模型

  • HTML 邮件:nh3 白名单(http(s) 链接强制 target=_blank + rel=noopener)→ 远程图默认拦截(计数)→ 前端 sandbox iframe(allow-same-origin+allow-popups-to-escape-sandbox,无脚本;外链点击在新标签由浏览器正常打开,不在 iframe 内导航);srcdoc 注入 body 基础样式(sans 字体栈+行高,仅兜底继承),邮件自带 <style> 随消毒放行(拦截口径下其 CSS 远程资源已剥)
  • 发信:multipart/alternative(写信工作台:TipTap HTML 经 sanitize_outgoing_html 白名单消毒 → decorate_outgoing_html 收件端兜底内联化(编辑器排版来自本地 CSS,收件人客户端没有——发送前把同参数样式写进内联 style:段距/标题/引用/代码块/表格边框等,用户已有内联样式不覆盖;与 index.css 编辑器样式保持同参)→ wrap_email_body_html 基础样式外层 + 派生纯文本;AI 草稿 approve 同走 imap_client 发送);草稿 approve 带 In-Reply-To 并归档 Sent
  • 密钥:本地 secrets.json(POSIX chmod 600,原子写:临时文件+os.replace;进程内 threading.Lock 串行化读改写——整文件覆盖模型下无锁并发会丢键,曾致 OAuth 令牌全失);AI key 按档案存放(ai_profile_key:{id})明文回显(所见即所存);OAuth 令牌按账号存放(oauth_token:{id},含 refresh_token)与客户端配置(oauth_client:{provider})不回传前端;服务仅 127.0.0.1
  • 来源校验(main.py 中间件):Host 必须为本机主机名(端口与实际监听一致才严格比对);浏览器附带 Origin 时必须为本机源——挡恶意网页对 127.0.0.1 的 drive-by POST 与 DNS rebinding;携带 Cloudflare 边缘头(CF-,只会经公网隧道注入——cloudflared 默认 Host 重写为本机地址 + 脚本客户端无 Origin,公网隧道下前两道双双失效)的请求一律 403(2026-09-15 审计 A1;SSH 无附加头、Tailscale serve 仅 X-Forwarded- 均不受影响)。例外:/api/ext/*(对外 API,v0.4 P7)在来源守卫之前放行、改持 X-Api-Key 认证(外部脚本经自建隧道到达时 Host/Origin 本非本机;浏览器跨站带不上自定义头——预检不通,drive-by 由密钥兜住);全部 ext 调用(含被拒的)落 api_calls(/health 除外)
  • 对外 API 密钥:api_keys 表存 sha256 哈希(唯一索引)+ scopes JSON + 每日上限;明文存 secrets.json(ext_api_key:{id})所见即所存;限流 60 次/分钟/密钥(内存滑动窗);api_enabled 总开关默认关

分发与打包

产物路径说明
源码开发python run.py注入 backend/ 路径后调 app.cli:main
PyPI 包pyproject.toml发行名 nmail-app(“nmail” 在 PyPI 已被占用),console script nmail = app.cli:main;版本单一来源(T5):app/config.py 运行时解析链——源码直跑读仓库根 pyproject.toml(tomllib)、冻结包读 nmail.spec 打入的随包副本、wheel/uvx 读包元数据,兜底 "0.0.0";前端产物经 scripts/sync_frontend.sh 同步进 backend/app/static 打入 wheel
三平台单文件nmail.specPyInstaller onefile;hiddenimports 显式声明 uvicorn 延迟导入子模块;前端资源随包
发布流水线.github/workflows/release.yml打 tag v* → wheel 发 PyPI(uvx --from nmail-app nmail)+ Windows/macOS/Linux 二进制挂 GitHub Release +(可选 secret HOMEBREW_TAP_TOKEN)自动同步 Homebrew tap
wingetwinget-pkgs PRwinget install pan-nie.Nmail / winget upgrade;portable 型,SHA256 对齐 Release 资产
发版脚本scripts/release.sh一条命令:sync_version.py 统一四处版本号(根 pyproject / nmail-cli / SKILL.md)→ 提交打 tag → 盯 CI → 自动提 winget 版本 PR;手册与踩坑见 docs/RELEASE.md

已知分发注意点:Windows SmartScreen 对无签名 exe 会警告(缓解:onedir/误报申诉/买签名证书);macOS 未公证二进制需右键打开或 xattr -cr(公证需 Apple Developer 账号)。入口保护(2026-09-15 修复):cli.py 必须带 if __name__ == "__main__": main()——PyInstaller 以脚本原样执行入口,无保护则 exe 定义完函数即静默退出(v0.4.1 及之前的单文件二进制存在此隐患,873ce06 修复);应用内自更新与桌面图标见 UPDATE_AND_DESKTOP.md(冻结 exe 换身 + --wait-port 端口接管,v0.4.2 起生效)。Windows 无窗口化(UPDATE_AND_DESKTOP.md §6):nmail.spec console=(sys.platform != "win32")——Windows 双击不弹黑窗;配套 cli.py 日志落盘(<DATA_DIR>/nmail.log RotatingFileHandler,uvicorn 经 log_config 注入)+ 启动失败 traceback 落盘/Windows 原生 MessageBoxW 兜底;/api/quit + 设置页退出卡片为无窗口平台的显式停服入口。空闲自动退出(UPDATE_AND_DESKTOP.md §7,2026-09-18):图标启动带 --idle-exit,cli 以 uvicorn.Server 对象形态注入 should_exit 钩子(替代 uvicorn.run,等价);标签页全关约 90s 后优雅退出。

发现问题?直接到 主仓 docs/ 改这篇文档(本页随构建自动更新)。