Compare commits

...

62 Commits

Author SHA1 Message Date
LanQin bce259ca8a feat(mail): 优化外部邮箱侧边栏交互
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 支持外部邮箱账号展开/收起,并在切换时自动保持当前账号展开。
- 增加外部邮箱显示主标题与副标题,提升账号信息可读性。
- 在外部 IMAP 关闭或账号列表变化时,清理已展开状态。
2026-06-25 22:50:32 +08:00
LanQin 707f11687d feat(external-imap): 新增外部 IMAP 全局开关与后台配置
- 支持在系统设置中启用/禁用外部 IMAP,并持久化相关密钥与 OAuth 配置。
- 在公共设置和前端页面中联动开关状态,未启用时隐藏外部 IMAP 相关功能并拦截后端接口。
- 补充外部 IMAP 默认关闭、配置说明与回调地址文档。
2026-06-25 22:44:32 +08:00
LanQin cc75382869 fix(external_imap): 修复 IMAP 文件夹列表兼容性
- 在测试外部 IMAP 账号时,将文件夹列表失败提示改为更准确的错误信息。
- 为 OAuth XOAUTH2 认证和文件夹列表补充更明确的错误包装,便于定位问题。
- 对不支持 `LIST RETURN (STATUS ...)` 的 Outlook/Exchange 服务降级为普通 `LIST`,再单独查询文件夹状态。
2026-06-25 22:25:30 +08:00
LanQin efcdc691de refactor(app): 优化后台任务退出与外部 IMAP 认证
- 统一用 `WaitGroup` 管理后台 worker,关闭时等待任务结束后再释放数据库连接。
- 将外部 IMAP 的 OAuth 认证切换为 `XOAUTH2` 客户端实现,并补充对应格式测试。
- 调整发送队列的故障恢复逻辑,超时中断后恢复为排队状态以便重新投递。
2026-06-25 21:36:59 +08:00
LanQin 081cfad02d feat(external_imap): 强化 OAuth 外部邮箱授权校验并完善 Outlook 文案
- OAuth 授权改为以服务商返回的真实邮箱为准,补充 ID Token 解析与邮箱一致性校验,避免默认落到本地邮箱。
- 为 Gmail 和 Microsoft 365 / Outlook OAuth 增加必要的 OIDC scope,并补充相关测试覆盖。
- Web 端新增 OAuth 授权弹窗,支持填写外部邮箱与存储模式;同步更新账号展示信息。
- 同步修正 README 与部署说明中的 Outlook 表述。
2026-06-25 21:17:04 +08:00
LanQin_ 39f5249008 feat(mail): 增强外部 IMAP 接入能力。
- 新增 Gmail/Outlook OAuth2 授权接入、附件分片下载、服务端搜索与单文件夹同步历史。
- 扩展外部 IMAP 账户与同步任务数据结构,补充相关路由、配置和前端管理界面。
- 更新部署示例与文档,说明 OAuth 回调地址及后续邮件系统规划。
2026-06-25 20:20:37 +08:00
LanQin_ 11734bf119 feat(mail): 支持外部 IMAP 账号接入。
- 新增外部 IMAP 账号的加密存储、同步任务与远端直连能力。
- Web 端个人邮箱页和邮件列表页支持查看、测试、同步和切换外部邮箱。
- 补充相关配置项、环境变量说明和使用文档。
2026-06-25 17:09:46 +08:00
LanQin_ 2558aa96ed refactor(mail): 统一右键菜单按钮样式
- 将邮件页右键菜单中的原生 `button` 替换为 `Button` 组件。
- 抽取通用按钮样式,统一打开、刷新、移动、标签等菜单项表现。
- 保持删除等危险操作的视觉强调一致。
2026-06-25 16:17:56 +08:00
LanQin_ 98e7190512 feat(mail): 增强发送队列筛选与投递追踪能力
- 发送队列支持按 Message-ID、收件人和时间范围筛选,并改为稳定游标分页。
- 邮件详情补充关联的发送队列信息,支持从邮件直接查看投递时间线。
- 前端同步接入新筛选条件,并在发送队列页提供清除筛选入口。
2026-06-25 16:13:44 +08:00
LanQin_ 4ab7815886 feat(mail): 支持删除自定义文件夹并回收邮件
- 后端新增文件夹删除接口,禁止删除系统文件夹,并将文件夹内邮件自动移回收件箱。
- 前端补充删除文件夹的 API 调用、侧边栏菜单入口和确认弹窗。
- 增加测试覆盖删除自定义文件夹后的邮件回收与系统文件夹保护。
2026-06-25 15:56:57 +08:00
LanQin_ 361daf8693 feat(mail): 为侧边栏添加右键菜单操作
- 支持对侧边栏条目打开、刷新和新建文件夹。
- 为自定义文件夹增加置顶、上移、下移、置底操作。
2026-06-25 15:50:39 +08:00
LanQin_ d2a762a426 feat(mail): 支持自定义邮件文件夹排序
- 后端为 `folders` 增加 `sort_order`,并提供文件夹重排接口。
- 前端邮件侧边栏支持自定义文件夹拖拽排序与乐观更新。
- 补充文件夹排序相关测试,并同步更新 API 类型定义。
2026-06-25 15:42:36 +08:00
LanQin_ 7799d5d5b2 feat(mail): 支持创建自定义文件夹与邮件右键操作
- 新增文件夹创建接口与服务端校验,支持创建自定义文件夹并在列表中使用。
- 为邮件列表补充右键菜单,支持打开、回复、转发、标记、归档、移动、打标签和删除。
- 规则编辑中的“移动到”动作支持自定义文件夹名称输入。
- 补充相关测试,覆盖文件夹创建与邮件移动流程。
2026-06-25 14:11:38 +08:00
LanQin_ 07054ca342 refactor(mail): 精简邮件通知与元信息展示
- 点击新邮件提示可直接打开首封新邮件,并兼容键盘回车与空格操作。
- 移除邮件详情中的认证结果展示及相关类型定义,简化消息元信息结构。
2026-06-25 11:42:11 +08:00
LanQin_ 84657f91f9 fix(toast): 修复 Toast 点击交互
- 为可点击的 Toast 添加指针样式,提升交互反馈。
- 阻止关闭按钮点击事件冒泡,避免触发外层点击逻辑。
2026-06-25 11:30:37 +08:00
LanQin a5f5adf752 chore: 合并 Maildir 修复与 README 更新 2026-06-24 19:55:43 +08:00
LanQin_ 947e3ad248 fix(app): 重建仅含 HTML 邮件摘要
- 在迁移流程中新增仅含 HTML 消息摘要重建,修正旧数据中摘要保留样式内容的问题。
- 扩展 `snippetFrom` 的清理逻辑,过滤 `style`、`script`、`head`、`title`、`noscript` 等非内容标签。
- 补充相关测试,覆盖样式内容被剔除后的摘要重建行为。
2026-06-24 17:34:41 +08:00
LanQin_ 2a572bf13a feat(mail): 增强邮件认证与审计能力
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 新增邮件认证结果解析、存储与前端展示,支持查看 SPF、DKIM、DMARC 及原始头信息。
- 扩展邮件规则条件,支持抄送、附件、大小、日期及嵌套条件组合。
- 增加邮箱容量统计与发送前配额校验,超限时返回明确错误。
- 新增管理端发送审计页面与接口,支持按邮箱、事件、Message-ID 和时间筛选。
2026-06-24 17:16:01 +08:00
LanQin_ 2b846ac671 feat(mail): 增加发送队列管理
- 新增发送队列与投递时间线接口,支持查看、筛选、重试和取消发送任务。
- 扩展邮箱页面,加入发送队列入口、状态筛选和时间线弹窗。
- 更新发送队列状态流转,支持 `canceled` 并补充相关测试与前端类型定义。
2026-06-24 16:39:40 +08:00
LanQin_ b3669f189e feat(imap): 补充邮件 IMAP 元数据支持
- 为文件夹和消息增加 `UID/ModSeq` 字段,并在迁移时回填与建索引。
- 让收发、移动、标记、删除、草稿等流程同步维护 `imap_uid` 和 `imap_modseq`。
- 扩展后端与前端类型,并补充相关测试覆盖。
2026-06-24 16:23:45 +08:00
LanQin_ d28ed4adcc feat(admin): 增加 Maildir 同步健康状态
- 新增后端健康检查接口与同步追踪,记录运行状态、最近结果、错误摘要和统计信息。
- 前端管理页在存储设置中展示 Maildir 同步健康卡片,并支持手动刷新。
- 补充相关类型定义与测试,覆盖未配置和同步成功两类场景。
2026-06-24 16:08:48 +08:00
LanQin_ b36adfdac2 fix(maildir): 同步 IMAP 移动、标记与删除状态
- 让 maildir 同步在消息移动到其他文件夹时更新 `folder_id` 和 `raw_path`。
- 根据文件名中的 maildir 标记同步已读、星标状态。
- 增加缺失消息清理,避免数据库残留已被删除的 maildir 邮件。
- 补充回归测试覆盖移动、重复副本、标记更新和删除场景。
2026-06-24 15:46:17 +08:00
LanQin_ 632a8a4896 fix(maildir): 同步邮件已读状态到 maildir 标记
- 读取邮件时同步更新 maildir 的 `S` 标记,避免数据库已读状态与文件名不同步。
- 写入邮件到 `maildir` 时保留已有非托管标记,并确保已读/星标标记正确拼接。
- 补充测试,校验已发送邮件和草稿的原始路径包含已读标记。
2026-06-24 15:33:56 +08:00
LanQin_ f8d058f7e4 fix(maildir): 同步消息标记到 Maildir
- 标记已读、加星时同步重写 `raw_path`,保持数据库与文件系统状态一致。
- 新增 Maildir 标记路径计算与迁移逻辑,支持读/星标变化后调整目录和文件名。
- 补充测试覆盖已读、未读、加星场景的 Maildir 路径变化。
2026-06-24 15:27:30 +08:00
LanQin_ 1788d49a59 feat(maildir): 支持邮件写入与同步联动
- 为已发送邮件、草稿、收件与规则移动增加 maildir 文件写入、重写和删除流程。
- 补充 SQLite 仅有消息的回填逻辑,并在同步时补齐现有 raw_path。
- 调整 Dovecot 共享邮箱行为为订阅方式,避免重复创建文件夹。
- 新增覆盖发送、草稿、移动、删除和回填场景的测试。
2026-06-24 15:18:06 +08:00
LanQin_ adcc822c9a fix(mail): 优化邮件 HTML 与样式保留
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 支持整页邮件文档的清洗与渲染,保留 `<html>`、`<head>`、`<body>` 和 `<style>` 等结构。
- 提取并校验安全的邮件样式块,阻止包含外链、脚本式内容的 CSS。
- 补充 `dompurify` 类型声明以支持 `WHOLE_DOCUMENT` 配置。
2026-06-24 14:19:31 +08:00
LanQin_ 18db36d937 feat(mail): 优化邮件正文渲染与样式保留
- 邮件正文改为在 `iframe` 中渲染,提升排版隔离和显示稳定性。
- 扩展前端清洗配置,保留邮件常见布局样式与表格属性。
- 收紧后端 HTML 策略的样式白名单,兼顾邮件样式保留与安全过滤。
- 补充测试,验证邮件布局样式可保留且危险内容仍会被拦截。
2026-06-24 13:55:09 +08:00
LanQin_ 295a34881d Merge pull request #5 from LanQin996/feature/lanqin-submission
Feature/lanqin submission
2026-06-24 13:37:09 +08:00
LanQin_ 65e4c4f6b5 fix(send_queue): 修复已投递标记恢复逻辑
- 新增投递标记文件持久化,避免重启后重复发送已投递的队列项。
- 在恢复卡住的发送任务时,识别已存在的投递标记并直接修复数据库状态。
- 同步补充回归测试,覆盖陈旧投递标记不应触发重发的场景。
2026-06-24 13:35:37 +08:00
LanQin_ d3bee62acd refactor(submission): 重构发件入库的去重流程。
- 将发件消息插入与附件保存改为事务内执行,避免去重键存在但消息缺失时产生脏数据。
- 抽出可注入的数据库执行器,便于在同一事务中复用消息与附件写入逻辑。
- 补充异常场景测试,覆盖“去重键存在但未命中消息”的失败行为。
2026-06-24 13:26:53 +08:00
LanQin_ d1876691dd fix(submission): 调整 SMTP 提交与发件授权逻辑。
- 启用 SMTP submission 时改为强制使用可读证书文件,并禁止使用容器自带测试证书。
- 收紧发件人校验,拒绝未授权的 From 地址,并支持别名多目标授权。
- 优化发送队列重入与重复消息处理,避免重复记录冲突。
- 让提交证书按需热加载,并稳定 MIME 头序列化顺序。
- 更新部署示例与文档,明确 submission 的证书配置要求。
2026-06-24 13:10:24 +08:00
LanQin_ 2476fe0c19 fix(send_queue): 修复重复消息 ID 的失败重排并清理 MIME
- 投递成功后清空 `mime_base64`,避免已投递队列项保留原始 MIME。
- 当同一 `message_id` 的旧队列项已达到最大失败次数时,允许复用并重新入队。
- 补充测试覆盖已投递项清理与终态失败重排场景。
2026-06-24 11:33:40 +08:00
LanQin_ 7b0717412c feat(app): 引入发送队列与可授权发件人
- Webmail/API 与第三方提交统一先写入已发送,再入发送队列异步投递。
- 新增队列重试、失败审计、陈旧任务恢复与发送状态追踪。
- 支持本人邮箱、启用别名和 `send_as_grants` 显式授权的 send-as。
- 更新 README 与部署文档,说明新的外发流程和排障方式。
2026-06-24 11:27:32 +08:00
LanQin_ 5c9bea075a chore(deploy): 优化 split stack 的 SMTP 配置
- 为 `docker-compose.stack.yml` 的 API 容器默认注入 `LANQIN_SMTP_HOST/PORT`,避免 Webmail 发信配置漂移。
- 补充 `.env.example` 和 `README`,说明 split stack 默认走 `postfix`,以及外部 SMTP 的覆盖方式。
- 新增 `sent_message_dedupe_keys` 表和相关读写逻辑,确保已发送消息去重可回收,并补充对应测试。
2026-06-24 11:03:01 +08:00
LanQin_ fa450c9f9b feat(mail): 支持 SMTP Submission 由 API 接管
- 新增 `465/587` 提交服务与 TLS 配置,支持 StartTLS 和隐式 TLS。
- 提交时校验认证邮箱、权限和 `From` 一致性,先写入 `Sent` 再转发到后端 SMTP。
- 增加 `Message-ID` 去重与失败回滚,避免重复保存发送副本。
- 调整部署与文档,移除 Postfix 的 submission 监听,改由 LanQin API 对外提供提交端口。
2026-06-24 10:47:16 +08:00
LanQin_ f2457c63ee refactor(mail): 统一邮件认证策略请求与返回格式。
- 将 `auth-policy` 调整为读取 `login`、`remote` 等字段,并返回数值型 `status`。
- 支持按邮箱定位用户与邮箱箱体,补充 IMAP/POP3 认证策略校验。
- 更新 Dovecot 与 Postfix 配置,移除原有的发件人 BCC 依赖。
- 补充相关测试并同步部署说明。
2026-06-23 21:52:05 +08:00
LanQin_ 8d434b9ca8 chore(app): 修正 maildir 目录配置读取
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
- 启动 maildir worker 时改为读取 `a.cfg.MaildirRoot`,避免使用未确认的入参值。
2026-06-23 15:32:12 +08:00
LanQin_ ce9d705df0 chore(deploy): 统一初始化 Dovecot 认证策略随机数
- 在部署入口脚本中生成并持久化 `auth_policy_hash_nonce`,避免每次启动变更。
- 同步更新 Dovecot 配置项为 `auth_policy_request_attributes`,并注入随机数值。
2026-06-23 15:07:15 +08:00
LanQin_ b309368225 feat(admin): 增加版本更新检查
- 在关于页检查 GitHub 最新发布版本。
- 发现新版本时提示并提供跳转查看链接。
2026-06-23 14:07:20 +08:00
LanQin_ 1373479973 chore(dovecot): 优化邮箱别名与默认文件夹创建
- 添加 `recipient_delimiter = +` 以支持加号别名。
- 为 Drafts、Sent、Trash、Archive、Spam 配置自动创建。
2026-06-23 11:32:14 +08:00
LanQin_ 23b04bd343 feat(mail): 增强邮件限流与配额配置
- 新增 IMAP/POP3 事件表与清理任务,并接入 Dovecot auth policy。
- 暴露默认权限限额接口,前端权限组页面改为动态读取。
- 为 Dovecot 配置用户配额、连接数限制和 auth policy。
2026-06-23 11:25:38 +08:00
LanQin_ 36b4c4c60f feat(权限组): 增加账号配额与邮件发送限制
- 为权限组与用户新增配额字段,支持附件大小、SMTP/IMAP/POP3 频率限制。
- 后端补充权限组配额校验、迁移与 SMTP 发送限流,邮件发送/定时发送时统一生效。
- 前端在管理页、邮件撰写页和个人资料页展示并编辑配额信息。
2026-06-23 11:09:36 +08:00
LanQin_ 34d35e8f04 Merge pull request #4 from killerprojecte/main
Docker Release / Check web and api (push) Waiting to run
Docker Release / Resolve release tag (push) Blocked by required conditions
Docker Release / Build and publish all-in-one (push) Blocked by required conditions
Docker Release / Build and publish api (push) Blocked by required conditions
Docker Release / Build and publish web (push) Blocked by required conditions
Docker Release / Build and publish dovecot (push) Blocked by required conditions
Docker Release / Build and publish postfix (push) Blocked by required conditions
Docker Release / Build and publish rspamd (push) Blocked by required conditions
Docker Release / Create GitHub release (push) Blocked by required conditions
fix: delete label without mailboxId caused 404 error on non-default mailbox
2026-06-22 21:11:23 +08:00
killerprojecte 2d7625a751 Merge branch 'main' of https://github.com/killerprojecte/LanQin-Email 2026-06-22 21:08:33 +08:00
killerprojecte 3eebc1c358 fix: delete label without mailboxId caused 404 error on non-default mailbox 2026-06-22 21:08:11 +08:00
LanQin b320fee622 docs(README): 重写项目说明与部署文档
- 更新技术栈徽标与项目简介,补充 TypeScript、Dovecot 等信息。
- 重构功能特性、目录结构、环境要求、快速开始和首次部署清单。
- 补充关键环境变量、架构说明、邮件流转、开发验证与生产注意事项。
2026-06-22 20:50:06 +08:00
LanQin_ 67ced8b3f8 Merge pull request #3 from killerprojecte/main
feat: better tag system & fix layout problem when width under 640px
2026-06-22 20:32:10 +08:00
AnserJim 0b4b4441c0 chore: use activeMailboxId instead of selectedMailboxId
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-06-22 20:23:20 +08:00
killerprojecte c8dff253cc chore: optimize code 2026-06-22 20:20:44 +08:00
killerprojecte 77187e76ce fix: wrong code syntax 2026-06-22 20:12:56 +08:00
killerprojecte 84cc47e3cf chore: revert some wrong changes 2026-06-22 20:04:11 +08:00
killerprojecte 8d29dbd126 chore: improve label status handling 2026-06-22 20:04:11 +08:00
killerprojecte 94ab94eceb chore: improve label error handling 2026-06-22 20:04:10 +08:00
killerprojecte 74f456ef7d chore: improve tag delete 2026-06-22 20:04:10 +08:00
killerprojecte 7720b6b404 feat: better tag system & fix layout problem when width under 640px 2026-06-22 20:04:09 +08:00
LanQin_ 0b982a5ff4 feat(mail): 重构邮箱权限体系并细化前台能力控制
- 新增邮箱前台权限与默认普通用户权限迁移,拆分读信、发信、草稿、定时、整理、标签、附件、联系人等能力。
- 收紧后端路由与处理逻辑,按权限限制邮箱前台、个人中心和定时发送相关接口。
- 前端根据权限动态隐藏或禁用邮件、个人中心中的对应功能入口与操作。
- 补充权限迁移与访问控制测试,覆盖普通用户邮箱前台、发信和定时发送的权限校验。
2026-06-22 15:54:15 +08:00
LanQin_ 5571dcc511 fix(app): 清理旧版系统权限组
- 将旧版默认权限组 ID 纳入清理列表,确保初始化时自动删除遗留数据。
- 补充测试验证旧版权限组会在默认权限组修复流程中被移除。
2026-06-22 15:02:25 +08:00
LanQin_ acdf298811 fix(permission): 调整默认权限组与用户权限继承
- 将“普通用户”权限组纳入默认权限继承,创建和更新用户时自动合并其权限。
- 放宽“普通用户”系统组的可编辑性,但仍禁止删除;继续限制超级管理员组被修改。
- 更新管理端权限组操作状态,避免对不可删除或不可编辑项提供错误入口。
- 补充相关测试,覆盖默认权限组编辑、删除与用户权限继承场景。
2026-06-22 14:42:37 +08:00
LanQin_ 8cbcc22a74 refactor(permissions): 精简默认权限组初始化逻辑
- 将固定默认权限组收敛为基础组,并新增对历史默认权限组的清理流程。
- 兼容保留已被用户引用的旧权限组,避免误删现有授权关系。
- 更新测试以覆盖新权限组创建与旧权限组迁移场景。
2026-06-22 14:34:57 +08:00
LanQin_ 31435c1e97 feat(admin): 引入细粒度权限组管理
- 新增权限目录、系统权限组与用户权限组分配,后台路由改为按权限粒度控制。
- 支持创建、编辑、查看和删除自定义权限组,并为用户绑定权限组。
- 前端后台界面按权限动态展示菜单与操作项,补充用户/权限组相关类型与交互。
2026-06-22 14:31:10 +08:00
LanQin_ e35b0985ee ci(.github/workflows): 扩展 AI PR Review 触发方式
- 新增 `issue_comment` 和 `workflow_dispatch` 触发入口,支持通过评论或手动指定 PR 运行审查。
- 调整并发键与权限配置,兼容多事件场景并补充 `issues: write`。
- 收紧执行条件,仅在仓库内 PR、手动触发或授权评论指令下执行,并按事件类型动态注入 `PR_NUMBER` 与 `PR_HEAD_SHA`。
2026-06-22 14:12:48 +08:00
LanQin 002c097def ci(.github): 自动生成并发布 GitHub Release
- 新增 `.github/release.yml`,统一分类发布说明中的变更类型。
- 扩展 Docker 发布工作流,生成基于 tag 的发布说明并创建或更新 GitHub Release。
- 同步补充镜像列表、使用文档和回退逻辑,提升发布信息完整性。
2026-06-21 02:49:06 +08:00
58 changed files with 15546 additions and 985 deletions
+3
View File
@@ -0,0 +1,3 @@
*.sh text eol=lf
deploy/**/entrypoint.sh text eol=lf
deploy/**/sync-dkim.sh text eol=lf
+34
View File
@@ -0,0 +1,34 @@
changelog:
exclude:
labels:
- skip-changelog
- ignore-for-release
categories:
- title: 🚨 破坏性变更
labels:
- breaking-change
- breaking change
- title: 🔒 安全更新
labels:
- security
- title: ✨ 新功能
labels:
- feature
- enhancement
- title: 🐛 问题修复
labels:
- bug
- fix
- title: 📚 文档
labels:
- documentation
- docs
- title: 🧰 工程维护
labels:
- chore
- ci
- dependencies
- refactor
- title: 其他更新
labels:
- "*"
+27 -3
View File
@@ -5,18 +5,42 @@ on:
branches:
- main
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to review'
required: true
type: number
permissions:
contents: read
issues: write
pull-requests: write
concurrency:
group: ai-pr-review-${{ github.event.pull_request.number }}
group: ai-pr-review-${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
cancel-in-progress: true
jobs:
ai-review:
name: AI PR Review
if: >
(
github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository
) ||
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(
contains(github.event.comment.body, '/ai-review') ||
contains(github.event.comment.body, '@ai-reviewer review')
) &&
contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
)
runs-on: ubuntu-latest
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -50,8 +74,8 @@ jobs:
OPENAI_RETRIES: ${{ vars.OPENAI_RETRIES || '2' }}
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
PR_NUMBER: ${{ github.event_name == 'issue_comment' && github.event.issue.number || github.event_name == 'workflow_dispatch' && inputs.pr_number || github.event.pull_request.number }}
PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
REVIEW_RULES: .github/ai-review.md,AGENTS.md
REVIEW_IGNORE: .ai-reviewignore
REVIEW_SEVERITY_THRESHOLD: P3
+94 -1
View File
@@ -6,8 +6,10 @@ on:
- "v*"
permissions:
contents: read
contents: write
packages: write
pull-requests: read
issues: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -161,3 +163,94 @@ jobs:
VITE_RELEASE_URL=${{ needs.release.outputs.release_url }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
github-release:
name: Create GitHub release
runs-on: ubuntu-latest
needs:
- release
- docker
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate release notes
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
version="${{ needs.release.outputs.version }}"
repo="${GITHUB_REPOSITORY}"
repo_url="https://github.com/${repo}"
image_base="${REGISTRY}/${repo}"
image_base="${image_base,,}"
current_commit="$(git rev-list -n 1 "${tag}")"
previous_tag="$(git describe --tags --abbrev=0 "${current_commit}^" 2>/dev/null || true)"
generate_args=(-f "tag_name=${tag}")
if [[ -n "${previous_tag}" ]]; then
generate_args+=(-f "previous_tag_name=${previous_tag}")
fi
if ! gh api -X POST "repos/${repo}/releases/generate-notes" "${generate_args[@]}" --jq '.body' > generated-release-notes.md; then
echo "GitHub 自动生成更新日志失败,已回退到提交列表。" > generated-release-notes.md
echo >> generated-release-notes.md
if [[ -n "${previous_tag}" ]]; then
git log --reverse --pretty=format:"- %s ([%h](${repo_url}/commit/%H))" "${previous_tag}..${tag}" >> generated-release-notes.md
echo >> generated-release-notes.md
echo >> generated-release-notes.md
echo "完整更新日志: [${previous_tag}...${tag}](${repo_url}/compare/${previous_tag}...${tag})" >> generated-release-notes.md
else
echo "- 首个公开版本。" >> generated-release-notes.md
echo >> generated-release-notes.md
echo "当前提交: [${GITHUB_SHA:0:7}](${repo_url}/commit/${GITHUB_SHA})" >> generated-release-notes.md
fi
fi
cat > release-notes.md <<EOF
# LanQin Email ${tag}
自建邮箱 Webmail 全栈方案,包含 Web、API、Postfix、Dovecot、Rspamd 等组件。
## 注意
如果需要公网正常收发邮件,请确保已正确配置 MX、SPF、DKIM、DMARC 以及 25 / 587 / 993 等端口。
## 使用文档
- [项目文档](${repo_url}#readme)
- [开源协议](${repo_url}/blob/main/LICENSE)
## Docker 镜像
| 组件 | 镜像 |
|------|------|
| All-in-one | \`${image_base}:${tag}\` |
| API | \`${image_base}-api:${tag}\` |
| Web | \`${image_base}-web:${tag}\` |
| Postfix | \`${image_base}-postfix:${tag}\` |
| Dovecot | \`${image_base}-dovecot:${tag}\` |
| Rspamd | \`${image_base}-rspamd:${tag}\` |
同时也会发布 \`${version}\`、\`latest\` 和 \`sha-*\` 标签。
EOF
{
echo
cat generated-release-notes.md
} >> release-notes.md
- name: Create or update GitHub release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ needs.release.outputs.tag }}"
title="LanQin Email ${tag}"
if gh release view "${tag}" >/dev/null 2>&1; then
gh release edit "${tag}" --title "${title}" --notes-file release-notes.md --latest
else
gh release create "${tag}" --verify-tag --title "${title}" --notes-file release-notes.md --latest
fi
+189 -57
View File
@@ -1,107 +1,239 @@
# LanQin Email
![Go](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)
![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go)
![React](https://img.shields.io/badge/React-18.3-61DAFB?logo=react)
![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178C6?logo=typescript)
![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker)
![Postfix](https://img.shields.io/badge/Postfix-5E3C2B?logo=maildotru)
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
自建邮箱 Webmail 全栈方案前端 React + shadcn/ui,后端 Go + SQLite默认单容器集成 Postfix、Dovecot、Rspamd。
LanQin Email 是一个自建邮箱 Webmail 全栈方案前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
## 特性
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
- **Webmail 客户端** — 文件夹管理、邮件读写、附件、搜索、标签、星标、规则过滤
- **多域名/多邮箱** — 域名管理、DKIM 签名、DNS 记录检测、邮箱别名转发
- **双因素认证** — TOTP 两步登录(兼容 Google Authenticator / Authy
- **管理员面板** — 用户/域名/邮箱/别名/邮件管理、系统设置、邮件模板
- **用户自助** — 开放注册、自助申请邮箱、黑名单、收件规则、联系人
- **单容器部署** — 一个容器跑通 API + Web + Nginx + Postfix + Dovecot + Rspamd
- **本地投递** — 开发环境系统内邮箱互发直接写入 Inbox,无需公网邮件栈
## 功能特性
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
- **邮件服务栈**Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
## 界面预览
| Webmail 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
| --- | --- |
| ![Webmail 邮件阅读与列表](./docs/screenshots/mail-preview.png) | ![写邮件富文本编辑工具栏](./docs/screenshots/compose-preview.png) |
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
| 管理后台 · 系统概览 | 第三方客户端配置 |
| ![管理后台系统概览](./docs/screenshots/admin-preview.png) | ![第三方客户端配置](./docs/screenshots/client-preview.png) |
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
## 目录结构
```text
.
├── apps/api # Go API、SQLite schema、邮件同步与业务逻辑
├── apps/web # React/Vite Webmail 与管理后台
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
└── .github/workflows # Docker 镜像发布流水线
```
## 环境要求
### 开发环境
- Go 1.25+
- Node.js 20+
- pnpm 10.28.2(可通过 corepack 启用)
### 部署环境
- Docker Engine
- Docker Compose v2
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
## 快速开始
### 开发
### 本地开发
后端:
```bash
# 后端
cd apps/api
go mod tidy
go mod download
go test ./...
go run ./cmd/server
```
# 前端(新终端)
前端(新终端)
```bash
cd apps/web
corepack enable
corepack prepare pnpm@10.28.2 --activate
pnpm install
pnpm run dev
```
默认管理员:`admin@lanqin.local`,密码通过 `LANQIN_ADMIN_PASSWORD` 设置(不设置则启动时随机生成并输出到日志)。
访问:
### 部署
- Web`http://localhost:5173`
- API`http://localhost:8080`
服务器只需要 Compose 文件和配置,不需要源码构建:
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
### Docker 部署(单容器)
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
```bash
cd deploy
cp .env.example .env
# 修改 .env 里的域名和管理员密码
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码
docker compose pull
docker compose up -d
```
单容器内部集成:API、Web、Nginx、Postfix、Dovecot、Rspamd。
常用命令:
```bash
# 查看日志
docker compose logs -f lanqin-email
# 更新镜像并重启
docker compose pull
docker compose up -d
# 停止服务
docker compose down
```
如需在完整源码仓库中本地构建镜像:
```bash
cd deploy
cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
更多部署细节见 [`deploy/README.md`](./deploy/README.md)。
## 首次部署清单
1. 编辑 `deploy/.env`:至少修改 `LANQIN_PUBLIC_HOSTNAME``LANQIN_PUBLIC_BASE_URL``LANQIN_ADMIN_EMAIL``LANQIN_ADMIN_PASSWORD`
2. 生产环境建议挂载真实 TLS 证书,并设置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`
3. 登录管理后台,添加邮件域名。
4. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
## 关键环境变量
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
| 变量 | 说明 | 默认/示例 |
|------|------|-----------|
| `LANQIN_IMAGE` | all-in-one 镜像 | `ghcr.io/lanqin996/lanqin-email:latest` |
| `LANQIN_PUBLIC_HOSTNAME` | 邮件服务器主机名,影响 Postfix/DNS 展示/链接 | `mail.example.com` |
| `LANQIN_PUBLIC_BASE_URL` | Webmail 对外访问地址 | `https://mail.example.com` |
| `LANQIN_ADMIN_EMAIL` | 初始管理员邮箱 | `admin@example.com` |
| `LANQIN_ADMIN_PASSWORD` | 初始管理员密码,生产必须修改 | `ChangeMe123!` |
| `LANQIN_DB_PATH` | SQLite 数据库路径 | `/data/lanqin.db` |
| `LANQIN_ALLOW_INSECURE_HTTP` | 是否允许非 HTTPS Cookie,本地调试可开 | `false` |
| `LANQIN_OPEN_REGISTRATION` | 是否开放注册 | `false` |
| `LANQIN_TWO_FACTOR_ENABLED` | 2FA 功能总开关 | `false` |
| `LANQIN_TURNSTILE_ENABLED` | 是否启用 Turnstile | `false` |
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail 发信 SMTP | `127.0.0.1` / `25` |
| `LANQIN_MAILDIR_ROOT` | Maildir 根目录 | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | 未注册收件地址是否进入全部邮件 | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | 是否允许用户自助申请邮箱 | `false` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | 是否启用外部 IMAP 接入;也可在后台“系统设置 > 外部 IMAP”配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | 外部 IMAP 密码加密密钥,启用接入前必须设置;也可在后台配置 | 随机长字符串 |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | 外部 IMAP 本地存储模式同步间隔;也可在后台配置 | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | 是否允许外部 IMAP 连接内网/localhost 主机;也可在后台配置 | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/gmail/callback` | 空 |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook 外部 IMAP OAuth2,回调为 `/api/external-imap-oauth/outlook/callback` | 空 |
## 架构
```
┌─────────────────────────────────────────────────┐
Docker 容器
┌──────┐ ┌────────┐ ┌──────┐ ┌──────────┐
│ API Web │ │Nginx │ │ Postfix │
│ │ Go │ │ React │ │反代 MTA │ │
└──┬───┘ └────────┘ └──────┘ └─────────┘ │
│ SQLite Maildir
└──────────────────────────────────┘
│ ┌────────┐ ┌──────────┐
│ │Dovecot │ │ Rspamd
│ │ IMAP │ │ 反垃圾 │
│ └──────── └──────────┘
└─────────────────────────────────────────────────┘
```text
┌────────────────────────────────────────────────────────────
lanqin-email 单容器
┌─────────┐ ┌────────────┐ ┌──────────────┐
│ │ Nginx │ ───▶ │ Go API ───▶ │ SQLite /data
│ Web 静态│ │ Webmail API│ └─────────────┘
└─────────┘ └─────┬──────┘
│ Maildir sync │ maps
│ ┌──────── ┌──────────┌──────▼───────┐
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │
│ │ DKIM/AS │ │ SMTP/MTA │ IMAP/POP3
│ └─────────┘ └────────────└──────────────┘
└────────────────────────────────────────────────────────────
```
数据流
1. **收件** → Postfix 接收 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示
2. **发件** → Webmail 编辑 → API 构造 MIME → Postfix 投递
3. **反垃圾** → Rspamd 在 Postfix 投递前评分,标记 Spam 文件夹
邮件流转
## 能力
1. **收件**Postfix 接收邮件 → Rspamd 评分/标记 → Dovecot 写入 Maildir → API worker 同步到 SQLite → Webmail 展示。
2. **发件**Webmail 调用 API → API 构造 MIME → SMTP 提交给 Postfix 或外部 SMTP → 投递到目标地址。
3. **本地投递**:开发环境中,系统内邮箱互发可直接写入对方 Inbox;未配置 `LANQIN_SMTP_HOST` 时不会真正投递外部收件人。
4. **第三方客户端**:可通过 SMTP 465/587、IMAP 993、POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
| 模块 | 功能 |
|------|------|
| 认证 | 登录/注册、会话管理、双因素 TOTP、Turnstile 人机验证 |
| 域名 | 多域名管理、DKIM 密钥生成、DNS 记录展示与检测 |
| 邮箱 | 邮箱账号管理、容量配额、密码同步 |
| Webmail | 文件夹、邮件列表、阅读、写信、附件、搜索、已读/未读、星标、移动、删除、标签 |
| 规则 | 收件规则(条件+动作)、发件人黑名单 |
| 联系人 | 个人通讯录管理 |
| 管理 | 用户/域名/邮箱/别名 CRUD、系统设置持久化、邮件模板编辑、SMTP 测试 |
| 清理 | 归档已读、清空回收站/垃圾邮件 |
## 开发与验证
## 收发说明
```bash
# API 测试
cd apps/api
go test ./...
- **开发环境**:系统内邮箱互发直接投递到对方 Inbox。未配置 `LANQIN_SMTP_HOST` 时外部收件人不会真正投递。
- **服务器部署**`.env` 默认 `LANQIN_SMTP_HOST=127.0.0.1`,发件交给同容器内 Postfix。
- **收件同步**Postfix/Dovecot 收到 Maildir 后,API 的 Maildir worker 同步到 SQLite 后展示。
- **公网收发**:需要正确配置 MX/SPF/DKIM/DMARC,并确认云厂商开放 25/587/993 端口。
# Web 检查与构建
cd apps/web
pnpm run check
## 要求
# 单容器源码构建验证
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
- Go 1.22+
- Node.js 20+
- Docker & Docker Compose(部署)
## 生产注意事项
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`、SQLite 数据库、Maildir DKIM 私钥。
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS。
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
## SMTP 提交
- 第三方客户端的 SMTP 提交 `465/587` 由 LanQin API 进程处理。
- 启用 SMTP 提交前必须配置 `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`API 不会用 localhost 自签证书对外提供 465/587。
- Postfix 只保留 `25` 端口,用于公网入站邮件和内部/外部 relay。
- Webmail/API 和第三方客户端发信都会先写入 Sent,再进入发送队列。
- 发送队列由 LanQin API 后台 worker relay 到 `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`,失败会记录审计并按退避策略重试。
- v1 支持本人邮箱发信;如需 send-as,可使用启用的别名转发 source 指向本人邮箱,或在数据库中配置 `send_as_grants`
- 如果客户端随后又通过 IMAP APPEND 写入自己的 Sent 副本,Maildir 同步会按 Sent 文件夹内的 `Message-ID` 去重。
## License
[MIT](./LICENSE)
## Star 趋势
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=LanQin996/LanQin-Email&type=date&legend=top-left" />
</picture>
</a>
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
+34
View File
@@ -7,9 +7,12 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
smtpserver "github.com/emersion/go-smtp"
"lanqin-email-api/internal/app"
)
@@ -29,6 +32,15 @@ func main() {
Handler: svc.Router(),
ReadHeaderTimeout: 10 * time.Second,
}
submissionServers := &app.SubmissionServers{}
if strings.TrimSpace(cfg.SubmissionAddr) != "" || strings.TrimSpace(cfg.SubmissionTLSAddr) != "" {
tlsConfig, err := app.LoadServerTLSConfig(cfg)
if err != nil {
logger.Error("failed to initialize TLS config", "error", err)
os.Exit(1)
}
submissionServers = svc.NewSubmissionServers(tlsConfig)
}
go func() {
logger.Info("LanQin API listening", "addr", cfg.Addr)
@@ -37,6 +49,24 @@ func main() {
os.Exit(1)
}
}()
if submissionServers.Plain != nil {
go func() {
logger.Info("LanQin SMTP submission listening", "addr", cfg.SubmissionAddr)
if err := submissionServers.Plain.ListenAndServe(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
if submissionServers.TLS != nil {
go func() {
logger.Info("LanQin SMTP implicit TLS submission listening", "addr", cfg.SubmissionTLSAddr)
if err := submissionServers.TLS.ListenAndServeTLS(); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
logger.Error("smtp tls submission server stopped unexpectedly", "error", err)
os.Exit(1)
}
}()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
@@ -48,5 +78,9 @@ func main() {
logger.Error("server shutdown failed", "error", err)
os.Exit(1)
}
if err := submissionServers.Shutdown(shutdownCtx); err != nil {
logger.Error("smtp submission shutdown failed", "error", err)
os.Exit(1)
}
logger.Info("server stopped")
}
+5
View File
@@ -13,6 +13,10 @@ require (
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emersion/go-imap/v2 v2.0.0-beta.8 // indirect
github.com/emersion/go-message v0.18.2 // indirect
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
github.com/emersion/go-smtp v0.24.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
@@ -20,6 +24,7 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.23.0 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
+41
View File
@@ -2,6 +2,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
@@ -22,21 +30,54 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+262 -18
View File
@@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
item.Mailboxes = splitCSV(mailboxCSV)
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
}
if err := rows.Close(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users")
return
}
for i := range items {
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
return
}
}
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Password string `json:"password"`
Disabled bool `json:"disabled"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
actor := currentUser(r)
email := normalizeEmail(req.Email)
if email == "" || !strings.Contains(email, "@") {
badRequest(w, errors.New("invalid email"))
@@ -105,6 +121,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" && (actor == nil || actor.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
if len(req.Password) < 8 {
badRequest(w, errors.New("password must be at least 8 characters"))
return
@@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
id := newID("usr")
now := a.now().UTC().Format(time.RFC3339Nano)
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
badRequest(w, err)
return
}
permissionGroupIDs := req.PermissionGroupIDs
if role == "admin" {
permissionGroupIDs = nil
}
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
badRequest(w, err)
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user")
return
}
user, err := a.adminUserByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load user")
@@ -134,9 +171,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
current := currentUser(r)
var req struct {
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled *bool `json:"disabled"`
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
@@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
disabled := false
existing, err := a.userByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
}
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
return
}
disabled := existing.Disabled
if req.Disabled != nil {
disabled = *req.Disabled
}
if current != nil && current.ID == id && (disabled || role != "admin") {
badRequest(w, errors.New("cannot remove your own admin access"))
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
badRequest(w, errors.New("default administrator must remain an active super administrator"))
return
}
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
badRequest(w, err)
return
}
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
var permissionGroupIDs []string
if role == "user" {
if req.PermissionGroupIDs != nil {
permissionGroupIDs = *req.PermissionGroupIDs
} else if existing.Role == "user" {
for _, groupID := range existing.PermissionGroupIDs {
if isAssignablePermissionGroupID(groupID) {
permissionGroupIDs = append(permissionGroupIDs, groupID)
}
}
}
}
if current != nil && current.ID == id {
next := *existing
next.Role = role
next.Disabled = disabled
if shouldUpdatePermissionGroups {
if role == "admin" {
next.Permissions = allPermissionKeys()
} else {
permissions, err := a.effectivePermissionsForUserGroups(r.Context(), nil, permissionGroupIDs)
if err != nil {
badRequest(w, err)
return
}
next.Permissions = permissions
}
}
if next.Disabled || !userHasAdminAccess(&next) {
badRequest(w, errors.New("cannot remove your own admin access"))
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to start transaction")
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
if shouldUpdatePermissionGroups {
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
badRequest(w, err)
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update user")
return
}
@@ -183,6 +279,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if target.Role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
return
}
}
var req struct {
Password string `json:"password"`
}
@@ -234,6 +340,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("cannot delete your own user"))
return
}
if target, err := a.userByID(r.Context(), id); err != nil {
respondError(w, http.StatusNotFound, "user not found")
return
} else if a.isDefaultAdminUser(target) {
badRequest(w, errors.New("default administrator cannot be deleted"))
return
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
return
}
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
badRequest(w, err)
return
@@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
badRequest(w, errors.New("invalid role"))
return
}
if role == "admin" {
current := currentUser(r)
if current == nil || current.Role != "admin" {
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
return
}
}
domain, err := a.domainByID(r.Context(), req.DomainID)
if err != nil {
@@ -592,7 +715,7 @@ func (a *App) handleDeleteMailbox(w http.ResponseWriter, r *http.Request) {
}
rows.Close()
for _, messageID := range messageIDs {
a.deleteMessageFiles(r.Context(), messageID)
a.deleteMessage(r.Context(), messageID)
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM mailboxes WHERE id=?`, id)
if err != nil {
@@ -663,7 +786,7 @@ func (a *App) handleAdminMessages(w http.ResponseWriter, r *http.Request) {
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
rows, err := a.db.QueryContext(r.Context(), `SELECT m.id,COALESCE(m.mailbox_id,''),COALESCE(mb.address,''),COALESCE(u.email,''),COALESCE(m.recipient_addr,''),COALESCE(m.folder_id,''),COALESCE(f.name,'Unregistered'),m.message_uid,m.imap_uid,m.imap_modseq,m.message_id,m.subject,m.from_addr,COALESCE(m.from_name,''),m.to_addrs,m.cc_addrs,m.bcc_addrs,m.sent_at,m.received_at,m.snippet,m.is_read,m.is_starred,m.has_attachments,m.size_bytes
FROM messages m
LEFT JOIN folders f ON f.id=m.folder_id
LEFT JOIN mailboxes mb ON mb.id=m.mailbox_id
@@ -710,6 +833,116 @@ func (a *App) handleAdminMessage(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, msg)
}
func (a *App) handleAdminSendAudit(w http.ResponseWriter, r *http.Request) {
mailboxID := strings.TrimSpace(r.URL.Query().Get("mailboxId"))
messageID := strings.TrimSpace(r.URL.Query().Get("messageId"))
event := strings.TrimSpace(r.URL.Query().Get("event"))
from, err := adminAuditTimeParam(r.URL.Query().Get("from"), false)
if err != nil {
badRequest(w, err)
return
}
to, err := adminAuditTimeParam(r.URL.Query().Get("to"), true)
if err != nil {
badRequest(w, err)
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if offset < 0 {
offset = 0
}
limit := 50
where := []string{"1=1"}
args := []any{}
if mailboxID != "" && mailboxID != "all" {
where = append(where, "sae.mailbox_id=?")
args = append(args, mailboxID)
}
if messageID != "" {
where = append(where, "(sq.message_id=? OR m.message_id=? OR sae.sent_message_id=?)")
args = append(args, messageID, messageID, messageID)
}
if event != "" && event != "all" {
if !isSendAuditEvent(event) {
badRequest(w, errors.New("invalid event"))
return
}
where = append(where, "sae.event=?")
args = append(args, event)
}
if from != "" {
where = append(where, "sae.created_at>=?")
args = append(args, from)
}
if to != "" {
where = append(where, "sae.created_at<=?")
args = append(args, to)
}
args = append(args, limit+1, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT sae.id,sae.queue_id,sae.mailbox_id,COALESCE(mb.address,''),sae.sent_message_id,COALESCE(sq.message_id,m.message_id,''),sae.source,sae.event,sae.status,sae.mail_from,sae.header_from,sae.recipients_json,sae.error,sae.created_at
FROM send_audit_events sae
LEFT JOIN mailboxes mb ON mb.id=sae.mailbox_id
LEFT JOIN send_queue sq ON sq.id=sae.queue_id
LEFT JOIN messages m ON m.id=sae.sent_message_id
WHERE `+strings.Join(where, " AND ")+`
ORDER BY sae.created_at DESC, sae.id DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
defer rows.Close()
items := []SendAuditEvent{}
for rows.Next() {
var item SendAuditEvent
var recipientsJSON, createdAt string
if err := rows.Scan(&item.ID, &item.QueueID, &item.MailboxID, &item.MailboxAddress, &item.SentMessageID, &item.MessageID, &item.Source, &item.Event, &item.Status, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &item.Error, &createdAt); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan send audit")
return
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
item.CreatedAt = parseTime(createdAt)
items = append(items, item)
}
if err := rows.Err(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send audit")
return
}
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(offset + limit)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
func adminAuditTimeParam(value string, endOfDay bool) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", nil
}
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t.UTC().Format(time.RFC3339Nano), nil
}
if t, err := time.Parse("2006-01-02", value); err == nil {
if endOfDay {
t = t.Add(24*time.Hour - time.Nanosecond)
}
return t.UTC().Format(time.RFC3339Nano), nil
}
return "", errors.New("invalid time filter")
}
func isSendAuditEvent(event string) bool {
switch event {
case sendAuditAccepted, sendAuditQueued, sendAuditRetry, sendAuditDelivered, sendAuditFailed, sendAuditCanceled:
return true
default:
return false
}
}
func (a *App) handleCreateAlias(w http.ResponseWriter, r *http.Request) {
var req struct {
DomainID string `json:"domainId"`
@@ -836,6 +1069,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error)
item.TwoFactorEnabled = intBool(twoFactorEnabled)
item.CreatedAt = parseTime(created)
item.Mailboxes = splitCSV(mailboxCSV)
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
return nil, err
}
return &item, nil
}
@@ -913,6 +1149,14 @@ func (a *App) ensureFolder(ctx context.Context, mailboxID, folder string) (strin
}
role := strings.ToLower(folder)
id = newID("fld")
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, id, mailboxID, folder, role, a.now().UTC().Format(time.RFC3339Nano))
sortOrder := 0
if !isSystemFolderName(folder) {
var err error
sortOrder, err = a.nextCustomFolderSortOrder(ctx, mailboxID)
if err != nil {
return "", err
}
}
_, err := a.db.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, sortOrder, a.newUIDValidity(), 1, 1, a.now().UTC().Format(time.RFC3339Nano))
return id, err
}
+400 -13
View File
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -22,12 +23,15 @@ import (
)
type App struct {
cfg Config
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
cfg Config
db *sql.DB
log *slog.Logger
now func() time.Time
policy *HTMLPolicy
workerCancel context.CancelFunc
workerWG sync.WaitGroup
maildirHealth *maildirSyncHealthTracker
externalIMAP externalIMAPClientFactory
}
func New(cfg Config, logger *slog.Logger) (*App, error) {
@@ -47,7 +51,8 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
}
db.SetMaxOpenConns(1)
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy()}
a := &App{cfg: cfg, db: db, log: logger, now: time.Now, policy: NewHTMLPolicy(), maildirHealth: newMaildirSyncHealthTracker()}
a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
return nil, err
@@ -70,13 +75,24 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
}
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
go a.scheduledSendWorker(workerCtx)
if strings.TrimSpace(cfg.MaildirRoot) != "" {
go a.maildirWorker(workerCtx)
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
a.startWorker(func() { a.sendQueueWorker(workerCtx) })
a.startWorker(func() { a.externalIMAPWorker(workerCtx) })
a.startWorker(func() { a.smtpEventsCleanupWorker(workerCtx) })
return a, nil
}
func (a *App) startWorker(fn func()) {
a.workerWG.Add(1)
go func() {
defer a.workerWG.Done()
fn()
}()
}
func (a *App) Close() error {
if a == nil || a.db == nil {
return nil
@@ -84,6 +100,7 @@ func (a *App) Close() error {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
return a.db.Close()
}
@@ -115,6 +132,23 @@ func (a *App) migrate(ctx context.Context) error {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS permission_groups (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
permissions_json TEXT NOT NULL DEFAULT '[]',
limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}',
system INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE TABLE IF NOT EXISTS user_permission_groups (
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_id TEXT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(user_id, group_id)
)`,
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_group ON user_permission_groups(group_id, user_id)`,
`CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -182,6 +216,10 @@ func (a *App) migrate(ctx context.Context) error {
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
role TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
uid_validity INTEGER NOT NULL DEFAULT 0,
uid_next INTEGER NOT NULL DEFAULT 1,
highest_modseq INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
UNIQUE(mailbox_id, name)
)`,
@@ -207,7 +245,14 @@ func (a *App) migrate(ctx context.Context) error {
is_starred INTEGER NOT NULL DEFAULT 0,
has_attachments INTEGER NOT NULL DEFAULT 0,
size_bytes INTEGER NOT NULL DEFAULT 0,
auth_results TEXT NOT NULL DEFAULT '',
auth_spf TEXT NOT NULL DEFAULT 'unknown',
auth_dkim TEXT NOT NULL DEFAULT 'unknown',
auth_dmarc TEXT NOT NULL DEFAULT 'unknown',
received_spf TEXT NOT NULL DEFAULT '',
raw_path TEXT NOT NULL DEFAULT '',
imap_uid INTEGER NOT NULL DEFAULT 0,
imap_modseq INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
@@ -215,6 +260,60 @@ func (a *App) migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS idx_messages_search ON messages(mailbox_id, subject, from_addr, from_name, snippet)`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_mailbox_raw_path ON messages(mailbox_id, raw_path) WHERE raw_path <> '' AND mailbox_id IS NOT NULL`,
`CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_unregistered_raw_path ON messages(raw_path) WHERE raw_path <> '' AND mailbox_id IS NULL`,
`CREATE TABLE IF NOT EXISTS sent_message_dedupe_keys (
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
folder_id TEXT NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
message_id TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY(mailbox_id, folder_id, message_id)
)`,
`CREATE TABLE IF NOT EXISTS send_as_grants (
id TEXT PRIMARY KEY,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
address TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(mailbox_id, address)
)`,
`CREATE TABLE IF NOT EXISTS send_queue (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
sent_message_id TEXT NOT NULL DEFAULT '',
message_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
mail_from TEXT NOT NULL,
header_from TEXT NOT NULL,
recipients_json TEXT NOT NULL,
mime_base64 TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
attempt_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
next_attempt_at TEXT NOT NULL,
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
delivered_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_send_queue_due ON send_queue(status, next_attempt_at, created_at)`,
`CREATE TABLE IF NOT EXISTS send_audit_events (
id TEXT PRIMARY KEY,
queue_id TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL DEFAULT '',
mailbox_id TEXT NOT NULL DEFAULT '',
sent_message_id TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
event TEXT NOT NULL,
status TEXT NOT NULL,
mail_from TEXT NOT NULL DEFAULT '',
header_from TEXT NOT NULL DEFAULT '',
recipients_json TEXT NOT NULL DEFAULT '[]',
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_send_audit_events_created ON send_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS attachments (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
@@ -238,6 +337,92 @@ func (a *App) migrate(ctx context.Context) error {
sent_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_scheduled_sends_due ON scheduled_sends(status, send_at)`,
`CREATE TABLE IF NOT EXISTS smtp_send_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_smtp_send_events_user_created ON smtp_send_events(user_id, created_at)`,
`CREATE TABLE IF NOT EXISTS imap_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_imap_events_user_created ON imap_events(user_id, created_at)`,
`CREATE TABLE IF NOT EXISTS pop3_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_pop3_events_user_created ON pop3_events(user_id, created_at)`,
`CREATE TABLE IF NOT EXISTS external_imap_accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
mailbox_id TEXT NOT NULL REFERENCES mailboxes(id) ON DELETE CASCADE,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
tls_mode TEXT NOT NULL CHECK(tls_mode IN ('tls','starttls','plain')),
username TEXT NOT NULL,
password_ciphertext TEXT NOT NULL,
auth_mode TEXT NOT NULL DEFAULT 'password' CHECK(auth_mode IN ('password','oauth2')),
oauth_provider TEXT NOT NULL DEFAULT '',
oauth_email TEXT NOT NULL DEFAULT '',
oauth_access_token_ciphertext TEXT NOT NULL DEFAULT '',
oauth_refresh_token_ciphertext TEXT NOT NULL DEFAULT '',
oauth_expiry TEXT,
storage_mode TEXT NOT NULL DEFAULT 'local' CHECK(storage_mode IN ('local','remote')),
sync_read_state INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 1,
last_sync_at TEXT,
last_status TEXT NOT NULL DEFAULT 'idle',
last_error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_user_mailbox ON external_imap_accounts(user_id, mailbox_id)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_accounts_enabled ON external_imap_accounts(enabled, updated_at)`,
`CREATE TABLE IF NOT EXISTS external_imap_folder_states (
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
remote_folder TEXT NOT NULL,
local_folder_id TEXT NOT NULL DEFAULT '',
uid_validity INTEGER NOT NULL DEFAULT 0,
last_uid INTEGER NOT NULL DEFAULT 0,
last_sync_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(account_id, remote_folder)
)`,
`CREATE TABLE IF NOT EXISTS external_imap_messages (
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
remote_folder TEXT NOT NULL,
uid_validity INTEGER NOT NULL,
uid INTEGER NOT NULL,
message_id TEXT NOT NULL DEFAULT '',
local_message_id TEXT NOT NULL DEFAULT '',
is_read INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(account_id, remote_folder, uid_validity, uid)
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_messages_local ON external_imap_messages(local_message_id) WHERE local_message_id <> ''`,
`CREATE TABLE IF NOT EXISTS external_imap_sync_runs (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES external_imap_accounts(id) ON DELETE CASCADE,
folder TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL,
imported INTEGER NOT NULL DEFAULT 0,
skipped INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
started_at TEXT NOT NULL,
finished_at TEXT
)`,
`CREATE INDEX IF NOT EXISTS idx_external_imap_sync_runs_account_started ON external_imap_sync_runs(account_id, started_at DESC)`,
`CREATE TABLE IF NOT EXISTS contacts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -318,6 +503,12 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateMessagesFromName(ctx); err != nil {
return err
}
if err := a.migrateMessageAuthentication(ctx); err != nil {
return err
}
if err := a.rebuildHTMLOnlyMessageSnippets(ctx); err != nil {
return err
}
if err := a.migrateUsersForTwoFactor(ctx); err != nil {
return err
}
@@ -327,9 +518,195 @@ func (a *App) migrate(ctx context.Context) error {
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
return err
}
if err := a.migratePermissionGroupLimits(ctx); err != nil {
return err
}
if err := a.migrateSendQueueMessageID(ctx); err != nil {
return err
}
if err := a.migrateIMAPMetadata(ctx); err != nil {
return err
}
if err := a.migrateFolderSortOrder(ctx); err != nil {
return err
}
if err := a.migrateExternalIMAP(ctx); err != nil {
return err
}
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
return err
}
return nil
}
func (a *App) migrateMessageAuthentication(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(messages)`)
if err != nil {
return err
}
defer rows.Close()
columns := map[string]bool{}
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
return err
}
columns[name] = true
}
if err := rows.Err(); err != nil {
return err
}
alter := []struct {
name string
sql string
}{
{"auth_results", `ALTER TABLE messages ADD COLUMN auth_results TEXT NOT NULL DEFAULT ''`},
{"auth_spf", `ALTER TABLE messages ADD COLUMN auth_spf TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dkim", `ALTER TABLE messages ADD COLUMN auth_dkim TEXT NOT NULL DEFAULT 'unknown'`},
{"auth_dmarc", `ALTER TABLE messages ADD COLUMN auth_dmarc TEXT NOT NULL DEFAULT 'unknown'`},
{"received_spf", `ALTER TABLE messages ADD COLUMN received_spf TEXT NOT NULL DEFAULT ''`},
}
for _, item := range alter {
if !columns[item.name] {
if _, err := a.db.ExecContext(ctx, item.sql); err != nil {
return err
}
}
}
return nil
}
func (a *App) rebuildHTMLOnlyMessageSnippets(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `SELECT id,body_html,snippet FROM messages WHERE trim(body_text)='' AND body_html<>''`)
if err != nil {
return err
}
defer rows.Close()
type update struct {
id string
snippet string
}
updates := []update{}
for rows.Next() {
var id, bodyHTML, current string
if err := rows.Scan(&id, &bodyHTML, &current); err != nil {
return err
}
next := snippetFrom("", bodyHTML)
if next != current {
updates = append(updates, update{id: id, snippet: next})
}
}
if err := rows.Err(); err != nil {
return err
}
if len(updates) == 0 {
return nil
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, item := range updates {
if _, err := tx.ExecContext(ctx, `UPDATE messages SET snippet=?,updated_at=? WHERE id=?`, item.snippet, now, item.id); err != nil {
return err
}
}
return tx.Commit()
}
func (a *App) migrateSendQueueMessageID(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(send_queue)`)
if err != nil {
return err
}
hasMessageID := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "message_id" {
hasMessageID = true
}
}
if err := rows.Close(); err != nil {
return err
}
if !hasMessageID {
if _, err := a.db.ExecContext(ctx, `ALTER TABLE send_queue ADD COLUMN message_id TEXT NOT NULL DEFAULT ''`); err != nil {
return err
}
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM send_queue
WHERE id IN (
SELECT id FROM (
SELECT id,
ROW_NUMBER() OVER (
PARTITION BY mailbox_id, source, message_id
ORDER BY
CASE status
WHEN 'queued' THEN 0
WHEN 'sending' THEN 1
WHEN 'failed' THEN 2
WHEN 'delivered' THEN 3
ELSE 4
END,
created_at DESC,
id DESC
) AS row_num
FROM send_queue
WHERE message_id <> ''
)
WHERE row_num > 1
)`); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_send_queue_mailbox_source_message_id ON send_queue(mailbox_id, source, message_id) WHERE message_id <> ''`)
return err
}
func (a *App) migratePermissionGroupLimits(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(permission_groups)`)
if err != nil {
return err
}
hasLimits := false
for rows.Next() {
var cid int
var name, typ string
var notnull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notnull, &dflt, &pk); err != nil {
rows.Close()
return err
}
if name == "limits_json" {
hasLimits = true
}
}
if err := rows.Close(); err != nil {
return err
}
if hasLimits {
return nil
}
_, err = a.db.ExecContext(ctx, `ALTER TABLE permission_groups ADD COLUMN limits_json TEXT NOT NULL DEFAULT '{"maxAttachmentMb":25,"smtpDailyLimit":200,"smtpMinuteLimit":20,"imapMinuteLimit":200,"pop3MinuteLimit":150}'`)
return err
}
// migrateLegacyBootstrapMailbox removes mailboxes created by an older version of seed()
// that implicitly created an admin mailbox with display_name "LanQin Admin".
// Current seed() creates mailboxes with display_name = admin email, so this migration
@@ -392,7 +769,7 @@ func (a *App) migrateLegacyBootstrapMailbox(ctx context.Context) error {
return err
}
for _, messageID := range messageIDs {
a.deleteMessageFiles(ctx, messageID)
a.deleteMessage(ctx, messageID)
}
if _, err := a.db.ExecContext(ctx, `DELETE FROM mailboxes WHERE id=?`, item.id); err != nil {
return err
@@ -679,7 +1056,7 @@ func (a *App) seed(ctx context.Context) error {
return err
}
if count > 0 {
return nil
return a.ensureConfiguredAdminSuperAdmin(ctx)
}
adminPassword := a.cfg.AdminPassword
@@ -736,6 +1113,16 @@ func (a *App) seed(ctx context.Context) error {
return nil
}
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
adminEmail := normalizeEmail(a.cfg.AdminEmail)
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
return nil
}
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
return err
}
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
name = normalizeDomain(name)
if name == "" || !strings.Contains(name, ".") {
@@ -829,7 +1216,7 @@ func (a *App) createMailboxWithPasswordHash(ctx context.Context, userID, domainI
return "", err
}
for _, f := range defaultFolderDefs() {
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,created_at) VALUES(?,?,?,?,?)`, newID("fld"), id, f.name, f.role, now)
_, err = tx.ExecContext(ctx, `INSERT INTO folders(id,mailbox_id,name,role,sort_order,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, 0, a.newUIDValidity(), 1, 1, now)
if err != nil {
return "", err
}
File diff suppressed because it is too large Load Diff
+82 -56
View File
@@ -8,67 +8,93 @@ import (
)
type Config struct {
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
Addr string
DBPath string
DataDir string
CookieName string
SessionTTLHours int
AdminEmail string
AdminPassword string
PublicHostname string
PublicBaseURL string
SMTPHost string
SMTPPort string
SMTPUsername string
SMTPPassword string
SMTPRequireTLS bool
SubmissionAddr string
SubmissionTLSAddr string
SubmissionMaxMessageMB int
TLSCertFile string
TLSKeyFile string
MaildirRoot string
MaildirScanSeconds int
AllowInsecureHTTP bool
OpenRegistration bool
TwoFactorEnabled bool
TurnstileEnabled bool
TurnstileSiteKey string
TurnstileSecretKey string
CatchAllEnabled bool
MailAutoRefresh bool
MailRefreshSeconds int
UserMailboxApplyEnabled bool
UserMailboxDomainIDs string
ReservedMailboxPrefixes string
ExternalIMAPEnabled bool
ExternalIMAPSecretKey string
ExternalIMAPSyncSeconds int
ExternalIMAPAllowPrivateHosts bool
ExternalIMAPGmailClientID string
ExternalIMAPGmailClientSecret string
ExternalIMAPOutlookClientID string
ExternalIMAPOutlookClientSecret string
}
func LoadConfig() Config {
dataDir := getenv("LANQIN_DATA_DIR", "./data")
return Config{
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
Addr: getenv("LANQIN_ADDR", ":8080"),
DBPath: getenv("LANQIN_DB_PATH", filepath.Join(dataDir, "lanqin.db")),
DataDir: dataDir,
CookieName: getenv("LANQIN_COOKIE_NAME", "lanqin_session"),
SessionTTLHours: getenvInt("LANQIN_SESSION_TTL_HOURS", 24*7),
AdminEmail: strings.ToLower(getenv("LANQIN_ADMIN_EMAIL", "admin@lanqin.local")),
AdminPassword: getenv("LANQIN_ADMIN_PASSWORD", ""),
PublicHostname: getenv("LANQIN_PUBLIC_HOSTNAME", "mail.lanqin.local"),
PublicBaseURL: getenv("LANQIN_PUBLIC_BASE_URL", "http://localhost:5173"),
SMTPHost: getenv("LANQIN_SMTP_HOST", ""),
SMTPPort: getenv("LANQIN_SMTP_PORT", "25"),
SMTPUsername: getenv("LANQIN_SMTP_USERNAME", ""),
SMTPPassword: getenv("LANQIN_SMTP_PASSWORD", ""),
SMTPRequireTLS: getenvBool("LANQIN_SMTP_REQUIRE_TLS", false),
SubmissionAddr: getenv("LANQIN_SUBMISSION_ADDR", ""),
SubmissionTLSAddr: getenv("LANQIN_SUBMISSION_TLS_ADDR", ""),
SubmissionMaxMessageMB: getenvInt("LANQIN_SUBMISSION_MAX_MESSAGE_MB", 35),
TLSCertFile: getenv("LANQIN_TLS_CERT_FILE", ""),
TLSKeyFile: getenv("LANQIN_TLS_KEY_FILE", ""),
MaildirRoot: getenv("LANQIN_MAILDIR_ROOT", ""),
MaildirScanSeconds: getenvInt("LANQIN_MAILDIR_SCAN_SECONDS", 30),
AllowInsecureHTTP: getenvBool("LANQIN_ALLOW_INSECURE_HTTP", true),
OpenRegistration: getenvBool("LANQIN_OPEN_REGISTRATION", false),
TwoFactorEnabled: getenvBool("LANQIN_TWO_FACTOR_ENABLED", false),
TurnstileEnabled: getenvBool("LANQIN_TURNSTILE_ENABLED", false),
TurnstileSiteKey: getenv("LANQIN_TURNSTILE_SITE_KEY", ""),
TurnstileSecretKey: getenv("LANQIN_TURNSTILE_SECRET_KEY", ""),
CatchAllEnabled: getenvBool("LANQIN_CATCH_ALL_ENABLED", false),
MailAutoRefresh: getenvBool("LANQIN_MAIL_AUTO_REFRESH", true),
MailRefreshSeconds: getenvInt("LANQIN_MAIL_REFRESH_SECONDS", 30),
UserMailboxApplyEnabled: getenvBool("LANQIN_USER_MAILBOX_APPLY_ENABLED", false),
UserMailboxDomainIDs: getenv("LANQIN_USER_MAILBOX_DOMAIN_IDS", ""),
ReservedMailboxPrefixes: getenv("LANQIN_RESERVED_MAILBOX_PREFIXES", "admin,postmaster,abuse,hostmaster,webmaster,root,security,noreply,no-reply,mailer-daemon"),
ExternalIMAPEnabled: getenvBool("LANQIN_EXTERNAL_IMAP_ENABLED", false),
ExternalIMAPSecretKey: getenv("LANQIN_EXTERNAL_IMAP_SECRET_KEY", ""),
ExternalIMAPSyncSeconds: getenvInt("LANQIN_EXTERNAL_IMAP_SYNC_SECONDS", 300),
ExternalIMAPAllowPrivateHosts: getenvBool("LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS", false),
ExternalIMAPGmailClientID: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID", ""),
ExternalIMAPGmailClientSecret: getenv("LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET", ""),
ExternalIMAPOutlookClientID: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID", ""),
ExternalIMAPOutlookClientSecret: getenv("LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET", ""),
}
}
File diff suppressed because it is too large Load Diff
+274
View File
@@ -0,0 +1,274 @@
package app
import (
"context"
"database/sql"
"math"
"time"
)
type imapMetadata struct {
UID int64
ModSeq int64
}
func (a *App) migrateIMAPMetadata(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "folders", "uid_validity", `ALTER TABLE folders ADD COLUMN uid_validity INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "uid_next", `ALTER TABLE folders ADD COLUMN uid_next INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "folders", "highest_modseq", `ALTER TABLE folders ADD COLUMN highest_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_uid", `ALTER TABLE messages ADD COLUMN imap_uid INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
if err := a.ensureTableColumn(ctx, "messages", "imap_modseq", `ALTER TABLE messages ADD COLUMN imap_modseq INTEGER NOT NULL DEFAULT 1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_validity=? WHERE uid_validity=0`, a.newUIDValidity()); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET uid_next=1 WHERE uid_next<1`); err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET highest_modseq=1 WHERE highest_modseq<1`); err != nil {
return err
}
if err := a.backfillIMAPUIDs(ctx); err != nil {
return err
}
_, err := a.db.ExecContext(ctx, `CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_folder_imap_uid ON messages(folder_id, imap_uid) WHERE folder_id IS NOT NULL AND imap_uid > 0`)
return err
}
func (a *App) migrateFolderSortOrder(ctx context.Context) error {
if err := a.ensureTableColumn(ctx, "folders", "sort_order", `ALTER TABLE folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0`); err != nil {
return err
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders WHERE lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash') ORDER BY mailbox_id, created_at, name, id`)
if err != nil {
return err
}
var folderIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return err
}
folderIDs = append(folderIDs, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
rows.Close()
order := customFolderDefaultSortOrderBase + 1
for _, id := range folderIDs {
if _, err := a.db.ExecContext(ctx, `UPDATE folders SET sort_order=? WHERE id=? AND sort_order=0`, order, id); err != nil {
return err
}
order++
}
return nil
}
func (a *App) ensureTableColumn(ctx context.Context, table, column, alterSQL string) error {
rows, err := a.db.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var dflt any
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &dflt, &pk); err != nil {
return err
}
if name == column {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, alterSQL)
return err
}
func (a *App) backfillIMAPUIDs(ctx context.Context) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM folders ORDER BY created_at,id`)
if err != nil {
return err
}
var folderIDs []string
for rows.Next() {
var folderID string
if err := rows.Scan(&folderID); err != nil {
rows.Close()
return err
}
folderIDs = append(folderIDs, folderID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, folderID := range folderIDs {
if err := a.backfillFolderIMAPUIDs(ctx, folderID); err != nil {
return err
}
}
return nil
}
func (a *App) backfillFolderIMAPUIDs(ctx context.Context, folderID string) error {
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE folder_id=? AND imap_uid=0 ORDER BY created_at,id`, folderID)
if err != nil {
return err
}
var messageIDs []string
for rows.Next() {
var messageID string
if err := rows.Scan(&messageID); err != nil {
rows.Close()
return err
}
messageIDs = append(messageIDs, messageID)
}
if err := rows.Err(); err != nil {
rows.Close()
return err
}
if err := rows.Close(); err != nil {
return err
}
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET imap_uid=?,imap_modseq=? WHERE id=?`, meta.UID, meta.ModSeq, messageID); err != nil {
return err
}
}
var maxUID, maxModSeq int64
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(imap_uid),0),COALESCE(MAX(imap_modseq),1) FROM messages WHERE folder_id=?`, folderID).Scan(&maxUID, &maxModSeq); err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE folders SET uid_next=MAX(uid_next,?),highest_modseq=MAX(highest_modseq,?) WHERE id=?`, maxUID+1, maxModSeq, folderID)
return err
}
func (a *App) newUIDValidity() int64 {
value := a.now().UTC().Unix()
if value <= 0 {
return time.Now().UTC().Unix()
}
return value
}
func (a *App) nextIMAPMetadata(ctx context.Context, db dbExecutor, folderID string) (imapMetadata, error) {
if folderID == "" {
return imapMetadata{}, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return imapMetadata{}, nil
}
var nextUID, highestModSeq int64
err := rowDB.QueryRowContext(ctx, `SELECT uid_next,highest_modseq FROM folders WHERE id=?`, folderID).Scan(&nextUID, &highestModSeq)
if err != nil {
return imapMetadata{}, err
}
if nextUID < 1 {
nextUID = 1
}
nextModSeq := highestModSeq + 1
if nextModSeq < 1 {
nextModSeq = 1
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET uid_next=?,highest_modseq=MAX(highest_modseq,?) WHERE id=?`, nextUID+1, nextModSeq, folderID); err != nil {
return imapMetadata{}, err
}
return imapMetadata{UID: nextUID, ModSeq: nextModSeq}, nil
}
func (a *App) bumpFolderModSeq(ctx context.Context, folderID string) (int64, error) {
return a.bumpFolderModSeqWithDB(ctx, a.db, folderID)
}
func (a *App) bumpFolderModSeqWithDB(ctx context.Context, db dbExecutor, folderID string) (int64, error) {
if folderID == "" {
return 0, nil
}
rowDB, ok := db.(dbQueryer)
if !ok {
return 0, nil
}
var current int64
if err := rowDB.QueryRowContext(ctx, `SELECT highest_modseq FROM folders WHERE id=?`, folderID).Scan(&current); err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
next := current + 1
if next < 1 || next == math.MaxInt64 {
next = current
}
if _, err := db.ExecContext(ctx, `UPDATE folders SET highest_modseq=MAX(highest_modseq,?) WHERE id=?`, next, folderID); err != nil {
return 0, err
}
return next, nil
}
func (a *App) touchMessageIMAPModSeq(ctx context.Context, messageID string) error {
var folderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&folderID); err != nil {
return err
}
if !folderID.Valid || folderID.String == "" {
return nil
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID.String)
if err != nil {
return err
}
if modSeq == 0 {
return nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return err
}
func (a *App) updateMessageModSeq(ctx context.Context, messageID string, folderID string) (int64, error) {
if folderID == "" {
var dbFolderID sql.NullString
if err := a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, messageID).Scan(&dbFolderID); err != nil {
return 0, err
}
if !dbFolderID.Valid || dbFolderID.String == "" {
return 0, nil
}
folderID = dbFolderID.String
}
modSeq, err := a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return 0, err
}
if modSeq == 0 {
return 0, nil
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET imap_modseq=? WHERE id=?`, modSeq, messageID)
return modSeq, err
}
File diff suppressed because it is too large Load Diff
+197
View File
@@ -0,0 +1,197 @@
package app
import (
"net/http"
"strings"
"sync"
"time"
)
const maxMaildirRecentErrors = 10
type maildirSyncCounts struct {
FilesScanned int `json:"filesScanned"`
Imported int `json:"imported"`
Backfilled int `json:"backfilled"`
Cleaned int `json:"cleaned"`
FileErrors int `json:"fileErrors"`
fileErrorDetails []string `json:"-"`
}
func (c maildirSyncCounts) total() int {
return c.Imported + c.Backfilled + c.Cleaned
}
type maildirSyncRun struct {
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
DurationMs int64 `json:"durationMs"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
Counts maildirSyncCounts `json:"counts"`
}
type maildirSyncHealthResponse struct {
Configured bool `json:"configured"`
Enabled bool `json:"enabled"`
Root string `json:"root"`
ScanSeconds int `json:"scanSeconds"`
WorkerStarted bool `json:"workerStarted"`
Running bool `json:"running"`
LastRun *maildirSyncRun `json:"lastRun,omitempty"`
LastError string `json:"lastError,omitempty"`
NextRunAt *time.Time `json:"nextRunAt,omitempty"`
RecentErrors []string `json:"recentErrors"`
Summary maildirSyncCounts `json:"summary"`
}
type maildirSyncHealthTracker struct {
mu sync.Mutex
workerStarted bool
running bool
current *maildirSyncRun
lastRun *maildirSyncRun
lastError string
nextRunAt *time.Time
recentErrors []string
summary maildirSyncCounts
}
func newMaildirSyncHealthTracker() *maildirSyncHealthTracker {
return &maildirSyncHealthTracker{}
}
func (h *maildirSyncHealthTracker) markWorkerStarted(nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = true
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) markWorkerStopped() {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
h.workerStarted = false
h.nextRunAt = nil
}
func (h *maildirSyncHealthTracker) markRunStarted(startedAt time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := &maildirSyncRun{StartedAt: startedAt.UTC(), Status: "running"}
h.running = true
h.current = run
h.lastRun = cloneMaildirSyncRun(run)
}
func (h *maildirSyncHealthTracker) markRunFinished(finishedAt time.Time, counts maildirSyncCounts, err error, nextRunAt *time.Time) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
run := h.current
if run == nil {
run = &maildirSyncRun{StartedAt: finishedAt.UTC()}
}
finished := finishedAt.UTC()
run.FinishedAt = &finished
run.DurationMs = finished.Sub(run.StartedAt).Milliseconds()
run.Counts = counts
run.Status = "success"
run.Error = ""
if err != nil {
run.Status = "error"
run.Error = err.Error()
h.lastError = run.Error
h.pushRecentError(run.Error)
} else if counts.FileErrors > 0 {
run.Status = "partial"
if len(counts.fileErrorDetails) > 0 {
run.Error = counts.fileErrorDetails[0]
h.lastError = run.Error
}
for _, detail := range counts.fileErrorDetails {
h.pushRecentError(detail)
}
} else {
h.lastError = ""
}
h.summary.FilesScanned += counts.FilesScanned
h.summary.Imported += counts.Imported
h.summary.Backfilled += counts.Backfilled
h.summary.Cleaned += counts.Cleaned
h.summary.FileErrors += counts.FileErrors
h.running = false
h.current = nil
h.lastRun = cloneMaildirSyncRun(run)
h.nextRunAt = cloneTimePtr(nextRunAt)
}
func (h *maildirSyncHealthTracker) snapshot(cfg Config) maildirSyncHealthResponse {
root := strings.TrimSpace(cfg.MaildirRoot)
scanSeconds := cfg.MaildirScanSeconds
if scanSeconds <= 0 {
scanSeconds = 30
}
out := maildirSyncHealthResponse{
Configured: root != "",
Enabled: root != "",
Root: root,
ScanSeconds: scanSeconds,
}
if h == nil {
return out
}
h.mu.Lock()
defer h.mu.Unlock()
out.WorkerStarted = h.workerStarted
out.Running = h.running
out.LastRun = cloneMaildirSyncRun(h.lastRun)
out.LastError = h.lastError
out.NextRunAt = cloneTimePtr(h.nextRunAt)
out.RecentErrors = append([]string(nil), h.recentErrors...)
out.Summary = h.summary
return out
}
func (h *maildirSyncHealthTracker) pushRecentError(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
h.recentErrors = append([]string{value}, h.recentErrors...)
if len(h.recentErrors) > maxMaildirRecentErrors {
h.recentErrors = h.recentErrors[:maxMaildirRecentErrors]
}
}
func cloneMaildirSyncRun(in *maildirSyncRun) *maildirSyncRun {
if in == nil {
return nil
}
out := *in
out.FinishedAt = cloneTimePtr(in.FinishedAt)
return &out
}
func cloneTimePtr(in *time.Time) *time.Time {
if in == nil {
return nil
}
out := in.UTC()
return &out
}
func (a *App) handleMaildirSyncHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, a.maildirHealth.snapshot(a.cfg))
}
@@ -0,0 +1,7 @@
//go:build !unix
package app
func applyMaildirOwnership(path string) error {
return nil
}
@@ -0,0 +1,17 @@
//go:build unix
package app
import "os"
const (
maildirOwnerUID = 5000
maildirOwnerGID = 5000
)
func applyMaildirOwnership(path string) error {
if os.Geteuid() != 0 {
return nil
}
return os.Chown(path, maildirOwnerUID, maildirOwnerGID)
}
+296 -36
View File
@@ -49,54 +49,79 @@ func (a *App) maildirWorker(ctx context.Context) {
if interval <= 0 {
interval = 30 * time.Second
}
nextRunAt := a.now().UTC()
a.maildirHealth.markWorkerStarted(&nextRunAt)
a.log.Info("maildir sync worker started", "root", a.cfg.MaildirRoot, "interval", interval.String())
if n, err := a.syncMaildirOnce(ctx); err != nil {
if counts, err := a.syncMaildirOnceTracked(ctx, interval); err != nil {
a.log.Warn("initial maildir sync failed", "error", err)
} else if n > 0 {
a.log.Info("initial maildir sync imported messages", "count", n)
} else if n := counts.total(); n > 0 {
a.log.Info("initial maildir sync processed messages", "count", n)
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.maildirHealth.markWorkerStopped()
a.log.Info("maildir sync worker stopped")
return
case <-ticker.C:
n, err := a.syncMaildirOnce(ctx)
counts, err := a.syncMaildirOnceTracked(ctx, interval)
if err != nil {
a.log.Warn("maildir sync failed", "error", err)
continue
}
if n > 0 {
a.log.Info("maildir sync imported messages", "count", n)
if n := counts.total(); n > 0 {
a.log.Info("maildir sync processed messages", "count", n)
}
}
}
}
func (a *App) syncMaildirOnceTracked(ctx context.Context, interval time.Duration) (maildirSyncCounts, error) {
startedAt := a.now().UTC()
a.maildirHealth.markRunStarted(startedAt)
counts, err := a.syncMaildirOnceDetailed(ctx)
finishedAt := a.now().UTC()
var nextRunAt *time.Time
if interval > 0 && err == nil {
next := finishedAt.Add(interval)
nextRunAt = &next
}
a.maildirHealth.markRunFinished(finishedAt, counts, err, nextRunAt)
return counts, err
}
func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
counts, err := a.syncMaildirOnceDetailed(ctx)
return counts.total(), err
}
func (a *App) syncMaildirOnceDetailed(ctx context.Context) (maildirSyncCounts, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
if root == "" {
return 0, nil
return maildirSyncCounts{}, nil
}
mailboxes, err := a.maildirMailboxes(ctx)
if err != nil {
return 0, err
return maildirSyncCounts{}, err
}
imported := 0
counts := maildirSyncCounts{}
for _, mb := range mailboxes {
if mb.Unregistered {
count, err := a.syncUnregisteredMaildir(ctx, mb)
mbCounts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
counts.FilesScanned += mbCounts.FilesScanned
counts.Imported += mbCounts.Imported
counts.FileErrors += mbCounts.FileErrors
counts.fileErrorDetails = append(counts.fileErrorDetails, mbCounts.fileErrorDetails...)
if err != nil {
return imported, err
return counts, err
}
imported += count
continue
}
folders, err := a.maildirFolders(ctx, mb.ID)
if err != nil {
return imported, err
return counts, err
}
base := filepath.Join(root, mb.Domain, mb.LocalPart, "Maildir")
for _, folder := range folders {
@@ -104,7 +129,7 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return imported, ctx.Err()
return counts, ctx.Err()
default:
}
dir := filepath.Join(folderBase, sub)
@@ -113,26 +138,39 @@ func (a *App) syncMaildirOnce(ctx context.Context) (int, error) {
if errors.Is(err, os.ErrNotExist) {
continue
}
return imported, err
return counts, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncMaildirFile(ctx, mb, folder, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("maildir file import failed", "path", path, "error", err)
continue
}
if ok {
imported++
counts.Imported++
}
}
}
}
}
return imported, nil
backfilled, err := a.backfillSQLiteMessagesToMaildir(ctx)
if err != nil {
return counts, err
}
counts.Backfilled += backfilled
cleaned, err := a.cleanupMissingMaildirMessages(ctx)
if err != nil {
return counts, err
}
counts.Cleaned += cleaned
return counts, nil
}
func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
@@ -179,12 +217,17 @@ func (a *App) maildirMailboxes(ctx context.Context) ([]maildirMailbox, error) {
}
func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (int, error) {
counts, err := a.syncUnregisteredMaildirDetailed(ctx, mb)
return counts.Imported, err
}
func (a *App) syncUnregisteredMaildirDetailed(ctx context.Context, mb maildirMailbox) (maildirSyncCounts, error) {
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
imported := 0
counts := maildirSyncCounts{}
for _, sub := range []string{"new", "cur"} {
select {
case <-ctx.Done():
return imported, ctx.Err()
return counts, ctx.Err()
default:
}
dir := filepath.Join(base, sub)
@@ -193,24 +236,27 @@ func (a *App) syncUnregisteredMaildir(ctx context.Context, mb maildirMailbox) (i
if errors.Is(err, os.ErrNotExist) {
continue
}
return imported, err
return counts, err
}
for _, entry := range entries {
if entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
path := filepath.Join(dir, entry.Name())
counts.FilesScanned++
ok, err := a.syncUnregisteredMaildirFile(ctx, mb, path)
if err != nil {
counts.FileErrors++
counts.fileErrorDetails = append(counts.fileErrorDetails, fmt.Sprintf("%s: %v", path, err))
a.log.Warn("unregistered maildir file import failed", "path", path, "error", err)
continue
}
if ok {
imported++
counts.Imported++
}
}
}
return imported, nil
return counts, nil
}
func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox, path string) (bool, error) {
@@ -248,6 +294,7 @@ func (a *App) syncUnregisteredMaildirFile(ctx context.Context, mb maildirMailbox
if exists, err := a.unregisteredMaildirMessageExists(ctx, path, msg.MessageID, msg.RecipientAddr); err != nil {
return false, err
} else if exists {
a.attachUnregisteredMaildirRawPathToExisting(ctx, path, msg.MessageID, msg.RecipientAddr)
return false, nil
}
_, err = a.insertMessage(ctx, msg, attachments)
@@ -291,7 +338,7 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
}
msg.MailboxID = mb.ID
msg.FolderID = folder.ID
msg.IsRead = !strings.EqualFold(folder.Name, "Inbox")
msg.IsRead, msg.IsStarred = maildirFlagsFromPath(path, folder.Name)
msg.RawPath = path
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
@@ -311,6 +358,14 @@ func (a *App) syncMaildirFile(ctx context.Context, mb maildirMailbox, folder mai
if exists, err := a.maildirMessageExists(ctx, mb.ID, folder.ID, path, msg.MessageID); err != nil {
return false, err
} else if exists {
if _, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
}
return false, nil
}
if handled, err := a.syncExistingMaildirMessageState(ctx, mb.ID, folder.ID, path, msg.MessageID, msg.IsRead, msg.IsStarred); err != nil {
return false, err
} else if handled {
return false, nil
}
id, err := a.insertMessage(ctx, msg, attachments)
@@ -338,6 +393,210 @@ func (a *App) unregisteredMaildirMessageExists(ctx context.Context, rawPath, mes
return count > 0, nil
}
func (a *App) attachMaildirRawPathToExisting(ctx context.Context, mailboxID, folderID, rawPath, messageID string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), mailboxID, folderID, messageID); err != nil {
a.log.Warn("failed to attach maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func (a *App) syncExistingMaildirMessageState(ctx context.Context, mailboxID, folderID, rawPath, messageID string, read, starred bool) (bool, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
var samePathID, oldFolderID string
var oldRead, oldStarred int
var oldModSeq int64
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(folder_id,''),is_read,is_starred,imap_modseq FROM messages WHERE mailbox_id=? AND raw_path=?`, mailboxID, rawPath).Scan(&samePathID, &oldFolderID, &oldRead, &oldStarred, &oldModSeq)
if err == nil {
if oldFolderID != folderID {
if oldFolderID != "" {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`,
folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, samePathID)
return err == nil, err
}
modSeq := oldModSeq
if oldRead != boolInt(read) || oldStarred != boolInt(starred) {
modSeq, err = a.bumpFolderModSeq(ctx, folderID)
if err != nil {
return false, err
}
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,is_read=?,is_starred=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`,
rawPath, boolInt(read), boolInt(starred), modSeq, modSeq, now, samePathID)
return err == nil, err
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return false, err
}
if strings.TrimSpace(messageID) == "" {
return false, nil
}
type candidate struct {
ID string
RawPath string
}
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE mailbox_id=? AND message_id=? AND message_id <> '' ORDER BY CASE WHEN folder_id=? THEN 0 ELSE 1 END, created_at`, mailboxID, messageID, folderID)
if err != nil {
return false, err
}
var chosen candidate
for rows.Next() {
var c candidate
if err := rows.Scan(&c.ID, &c.RawPath); err != nil {
rows.Close()
return false, err
}
if c.RawPath == "" || c.RawPath == rawPath {
chosen = c
break
}
ok, err := a.pathIsUnderMaildirRoot(c.RawPath)
if err != nil {
rows.Close()
return false, err
}
if ok {
if _, err := os.Stat(c.RawPath); errors.Is(err, os.ErrNotExist) {
chosen = c
break
} else if err != nil {
rows.Close()
return false, err
}
}
}
if err := rows.Err(); err != nil {
rows.Close()
return false, err
}
if err := rows.Close(); err != nil {
return false, err
}
if chosen.ID == "" {
a.removeDuplicateMaildirMessage(ctx, rawPath, mailboxID, folderID, messageID)
return false, nil
}
var previousFolderID string
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(folder_id,'') FROM messages WHERE id=?`, chosen.ID).Scan(&previousFolderID); err != nil {
return false, err
}
if previousFolderID != "" && previousFolderID != folderID {
if _, err := a.bumpFolderModSeq(ctx, previousFolderID); err != nil {
return false, err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, folderID)
if err != nil {
return false, err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,is_read=?,is_starred=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, folderID, rawPath, boolInt(read), boolInt(starred), meta.UID, meta.ModSeq, now, chosen.ID)
return err == nil, err
}
func (a *App) removeDuplicateMaildirMessage(ctx context.Context, rawPath, mailboxID, folderID, messageID string) {
var existing string
err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' AND raw_path<>'' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
if err != nil || existing == "" || existing == rawPath {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) cleanupMissingMaildirMessages(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return 0, nil
}
cutoff := a.now().UTC().Add(-5 * time.Minute).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,raw_path FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND raw_path<>'' AND updated_at<?`, cutoff)
if err != nil {
return 0, err
}
type item struct {
ID string
RawPath string
}
var missing []item
for rows.Next() {
var it item
if err := rows.Scan(&it.ID, &it.RawPath); err != nil {
rows.Close()
return 0, err
}
ok, err := a.pathIsUnderMaildirRoot(it.RawPath)
if err != nil {
rows.Close()
return 0, err
}
if !ok {
continue
}
if _, err := os.Stat(it.RawPath); errors.Is(err, os.ErrNotExist) {
missing = append(missing, it)
} else if err != nil {
rows.Close()
return 0, err
}
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, it := range missing {
a.deleteMessageFiles(ctx, it.ID)
var folderID sql.NullString
_ = a.db.QueryRowContext(ctx, `SELECT folder_id FROM messages WHERE id=?`, it.ID).Scan(&folderID)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, it.ID); err != nil {
return 0, err
}
if folderID.Valid && folderID.String != "" {
if _, err := a.bumpFolderModSeq(ctx, folderID.String); err != nil {
return 0, err
}
}
}
return len(missing), nil
}
func maildirFlagsFromPath(path, folderName string) (bool, bool) {
base := filepath.Base(path)
flags := ""
hasFlags := false
for _, sep := range []string{maildirFlagSeparator(), ":2,", "!2,"} {
if idx := strings.LastIndex(base, sep); idx >= 0 {
flags = base[idx+len(sep):]
hasFlags = true
break
}
}
if hasFlags {
return strings.ContainsRune(flags, 'S'), strings.ContainsRune(flags, 'F')
}
return !strings.EqualFold(folderName, "Inbox"), false
}
func (a *App) attachUnregisteredMaildirRawPathToExisting(ctx context.Context, rawPath, messageID, recipient string) {
if strings.TrimSpace(messageID) == "" || strings.TrimSpace(rawPath) == "" {
return
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,updated_at=? WHERE mailbox_id IS NULL AND recipient_addr=? AND message_id=? AND message_id <> '' AND raw_path=''`,
rawPath, a.now().UTC().Format(time.RFC3339Nano), recipient, messageID); err != nil {
a.log.Warn("failed to attach unregistered maildir raw path to existing message", "path", rawPath, "error", err)
}
}
func unregisteredRecipientFromMessage(msg storedMessage, domain string) string {
domain = normalizeDomain(domain)
for _, address := range append(append([]string{}, msg.To...), msg.CC...) {
@@ -382,19 +641,20 @@ func (a *App) parseMaildirMessage(raw []byte, fallbackTo string) (storedMessage,
receivedAt = sentAt
}
return storedMessage{
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
MessageUID: newID("uid"),
MessageID: strings.TrimSpace(m.Header.Get("Message-Id")),
Subject: subject,
From: from,
FromName: fromName,
To: to,
CC: cc,
SentAt: sentAt,
ReceivedAt: receivedAt,
Snippet: snippetFrom(bodyText, bodyHTML),
BodyText: bodyText,
BodyHTML: bodyHTML,
IsRead: false,
Authentication: parseMailAuthentication(textproto.MIMEHeader(m.Header)),
}, parsed.Attachments, nil
}
+613
View File
@@ -0,0 +1,613 @@
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
func (a *App) writeStoredMessageToMaildir(ctx context.Context, messageID string, msg storedMessage, attachments []AttachmentInput) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" || strings.TrimSpace(msg.MailboxID) == "" || strings.TrimSpace(msg.FolderID) == "" {
return nil
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, false)
}
func (a *App) rewriteMessageMaildir(ctx context.Context, messageID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildir(ctx, messageID, raw, true)
}
func (a *App) writeRawMessageToMaildir(ctx context.Context, messageID string, raw []byte, replace bool) error {
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, state.FolderID, raw, replace, false)
}
func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, folderID string, raw []byte, replace bool, updateFolder bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if folderID != "" {
oldFolderID := state.FolderID
state.FolderID = folderID
if updateFolder && oldFolderID != "" && oldFolderID != state.FolderID {
if _, err := a.bumpFolderModSeq(ctx, oldFolderID); err != nil {
return err
}
}
}
if state.MailboxID == "" || state.FolderID == "" {
return nil
}
if !replace && state.RawPath != "" {
if ok, err := a.pathIsUnderMaildirRoot(state.RawPath); err != nil {
return err
} else if ok {
if _, err := os.Stat(state.RawPath); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
}
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, state.FolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
filename := maildirFilename(messageID, state.MessageID)
tmpPath := filepath.Join(folderBase, "tmp", filename)
finalPath := filepath.Join(folderBase, subdir, filename)
finalPath = maildirPathWithFlags(finalPath, state.IsRead, state.IsStarred)
if err := os.WriteFile(tmpPath, raw, 0o600); err != nil {
return err
}
if err := applyMaildirOwnership(tmpPath); err != nil {
_ = os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, finalPath); err != nil {
_ = os.Remove(tmpPath)
return err
}
if replace || state.RawPath != "" {
a.removeMaildirPath(ctx, state.RawPath)
}
if updateFolder {
if state.IMAPUID > 0 && folderID == "" {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, state.FolderID, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
} else {
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, state.FolderID, finalPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
} else {
modSeq, metaErr := a.bumpFolderModSeq(ctx, state.FolderID)
if metaErr != nil {
return metaErr
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, finalPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
return err
}
func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID string) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
state, stateErr := a.maildirMessageState(ctx, messageID)
if stateErr != nil {
return stateErr
}
if state.FolderID != "" && state.FolderID != targetFolderID {
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
return err
}
}
meta, metaErr := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if metaErr != nil {
return metaErr
}
_, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.MailboxID == "" {
return nil
}
state.FolderID = targetFolderID
if state.RawPath == "" {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return a.writeMessageToNewMaildirFolder(ctx, messageID, targetFolderID)
}
return err
}
mb, err := a.maildirMailboxByID(ctx, state.MailboxID)
if err != nil {
return err
}
folderName, err := a.folderNameByID(ctx, targetFolderID)
if err != nil {
return err
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
subdir := "cur"
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
targetPath := filepath.Join(folderBase, subdir, filepath.Base(state.RawPath))
if filepath.Clean(targetPath) != filepath.Clean(state.RawPath) {
if err := os.Rename(state.RawPath, targetPath); err != nil {
return err
}
if err := applyMaildirOwnership(targetPath); err != nil {
return err
}
}
if state.FolderID != "" && state.FolderID != targetFolderID {
if _, err := a.bumpFolderModSeq(ctx, state.FolderID); err != nil {
return err
}
}
meta, err := a.nextIMAPMetadata(ctx, a.db, targetFolderID)
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?,raw_path=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, targetFolderID, targetPath, meta.UID, meta.ModSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) writeMessageToNewMaildirFolder(ctx context.Context, messageID, folderID string) error {
msg, err := a.storedMessageByID(ctx, messageID)
if err != nil {
return err
}
msg.FolderID = folderID
attachments, err := a.attachmentInputsForMessage(ctx, messageID)
if err != nil {
return err
}
raw, err := BuildMIME(MIMEMessage{
From: msg.From,
FromName: msg.FromName,
To: msg.To,
CC: msg.CC,
BCC: msg.BCC,
Subject: msg.Subject,
Text: msg.BodyText,
HTML: msg.BodyHTML,
MessageID: msg.MessageID,
Date: messageDate(msg),
Attachments: attachments,
})
if err != nil {
return err
}
return a.writeRawMessageToMaildirFolder(ctx, messageID, folderID, raw, true, true)
}
func (a *App) deleteMessageMaildirFile(ctx context.Context, messageID string) {
var rawPath string
if err := a.db.QueryRowContext(ctx, `SELECT raw_path FROM messages WHERE id=?`, messageID).Scan(&rawPath); err != nil {
return
}
a.removeMaildirPath(ctx, rawPath)
}
func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, read, starred *bool) error {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return nil
}
state, err := a.maildirMessageState(ctx, messageID)
if err != nil {
return err
}
if state.RawPath == "" {
return nil
}
ok, err := a.pathIsUnderMaildirRoot(state.RawPath)
if err != nil {
return err
}
if !ok {
return nil
}
if _, err := os.Stat(state.RawPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
currentRead := state.IsRead
currentStarred := state.IsStarred
if read != nil {
currentRead = *read
}
if starred != nil {
currentStarred = *starred
}
targetPath := maildirPathWithFlags(state.RawPath, currentRead, currentStarred)
if filepath.Clean(targetPath) == filepath.Clean(state.RawPath) {
return nil
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil {
return err
}
if err := os.Rename(state.RawPath, targetPath); err != nil {
return err
}
if err := applyMaildirOwnership(targetPath); err != nil {
return err
}
modSeq, err := a.bumpFolderModSeq(ctx, state.FolderID)
if err != nil {
return err
}
_, err = a.db.ExecContext(ctx, `UPDATE messages SET raw_path=?,imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END,updated_at=? WHERE id=?`, targetPath, modSeq, modSeq, a.now().UTC().Format(time.RFC3339Nano), messageID)
return err
}
func (a *App) removeMaildirPath(ctx context.Context, rawPath string) {
rawPath = strings.TrimSpace(rawPath)
if rawPath == "" {
return
}
ok, err := a.pathIsUnderMaildirRoot(rawPath)
if err != nil || !ok {
if err != nil {
a.log.Warn("failed to validate maildir path", "path", rawPath, "error", err)
}
return
}
if err := os.Remove(rawPath); err != nil && !errors.Is(err, os.ErrNotExist) {
a.log.Warn("failed to remove maildir message", "path", rawPath, "error", err)
}
}
func (a *App) backfillSQLiteMessagesToMaildir(ctx context.Context) (int, error) {
if strings.TrimSpace(a.cfg.MaildirRoot) == "" {
return 0, nil
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE COALESCE(mailbox_id,'')<>'' AND COALESCE(folder_id,'')<>'' AND raw_path='' ORDER BY created_at LIMIT 100`)
if err != nil {
return 0, err
}
var ids []string
for rows.Next() {
select {
case <-ctx.Done():
rows.Close()
return 0, ctx.Err()
default:
}
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
rows.Close()
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
count := 0
for _, id := range ids {
if err := a.rewriteMessageMaildir(ctx, id); err != nil {
return count, err
}
count++
}
return count, nil
}
type maildirMessageState struct {
MailboxID string
FolderID string
MessageID string
RawPath string
IsRead bool
IsStarred bool
IMAPUID int64
IMAPModSeq int64
}
func (a *App) maildirMessageState(ctx context.Context, id string) (maildirMessageState, error) {
var state maildirMessageState
var mailboxID, folderID sql.NullString
var read, starred int
err := a.db.QueryRowContext(ctx, `SELECT mailbox_id,folder_id,message_id,raw_path,is_read,is_starred,imap_uid,imap_modseq FROM messages WHERE id=?`, id).Scan(&mailboxID, &folderID, &state.MessageID, &state.RawPath, &read, &starred, &state.IMAPUID, &state.IMAPModSeq)
if err != nil {
return state, err
}
state.MailboxID = mailboxID.String
state.FolderID = folderID.String
state.IsRead = intBool(read)
state.IsStarred = intBool(starred)
return state, nil
}
func (a *App) storedMessageByID(ctx context.Context, id string) (storedMessage, error) {
row := a.db.QueryRowContext(ctx, `SELECT COALESCE(mailbox_id,''),COALESCE(folder_id,''),recipient_addr,message_uid,message_id,subject,from_addr,from_name,to_addrs,cc_addrs,bcc_addrs,sent_at,received_at,snippet,body_text,body_html,is_read,is_starred,raw_path FROM messages WHERE id=?`, id)
var msg storedMessage
var toJSON, ccJSON, bccJSON, sent, received string
var read, starred int
err := row.Scan(&msg.MailboxID, &msg.FolderID, &msg.RecipientAddr, &msg.MessageUID, &msg.MessageID, &msg.Subject, &msg.From, &msg.FromName, &toJSON, &ccJSON, &bccJSON, &sent, &received, &msg.Snippet, &msg.BodyText, &msg.BodyHTML, &read, &starred, &msg.RawPath)
if err != nil {
return msg, err
}
msg.To = jsonDecodeSlice(toJSON)
msg.CC = jsonDecodeSlice(ccJSON)
msg.BCC = jsonDecodeSlice(bccJSON)
msg.SentAt = parseTime(sent)
msg.ReceivedAt = parseTime(received)
msg.IsRead = intBool(read)
msg.IsStarred = intBool(starred)
return msg, nil
}
func (a *App) attachmentInputsForMessage(ctx context.Context, messageID string) ([]AttachmentInput, error) {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type,storage_path FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []AttachmentInput
for rows.Next() {
var filename, contentType, storagePath string
if err := rows.Scan(&filename, &contentType, &storagePath); err != nil {
return nil, err
}
data, err := os.ReadFile(storagePath)
if err != nil {
return nil, err
}
out = append(out, AttachmentInput{Filename: filename, ContentType: contentType, ContentBase64: base64.StdEncoding.EncodeToString(data)})
}
return out, rows.Err()
}
func (a *App) maildirMailboxByID(ctx context.Context, mailboxID string) (maildirMailbox, error) {
var mb maildirMailbox
err := a.db.QueryRowContext(ctx, `SELECT m.id,m.address,m.local_part,d.name FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE m.id=?`, mailboxID).Scan(&mb.ID, &mb.Address, &mb.LocalPart, &mb.Domain)
return mb, err
}
func (a *App) folderNameByID(ctx context.Context, folderID string) (string, error) {
var name string
err := a.db.QueryRowContext(ctx, `SELECT name FROM folders WHERE id=?`, folderID).Scan(&name)
return name, err
}
func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
root := strings.TrimSpace(a.cfg.MaildirRoot)
if root == "" || strings.TrimSpace(path) == "" {
return false, nil
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return false, err
}
pathAbs, err := filepath.Abs(path)
if err != nil {
return false, err
}
rel, err := filepath.Rel(rootAbs, pathAbs)
if err != nil {
return false, err
}
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
}
func ensureMaildirFolderDirs(base, folderBase string) error {
for _, sub := range []string{"tmp", "new", "cur"} {
if err := os.MkdirAll(filepath.Join(folderBase, sub), 0o755); err != nil {
return err
}
}
for _, dir := range maildirOwnershipDirs(base, folderBase) {
if err := applyMaildirOwnership(dir); err != nil {
return err
}
}
return nil
}
func maildirOwnershipDirs(base, folderBase string) []string {
dirs := []string{
filepath.Dir(filepath.Dir(base)),
filepath.Dir(base),
base,
}
if filepath.Clean(folderBase) != filepath.Clean(base) {
dirs = append(dirs, folderBase)
}
dirs = append(dirs, filepath.Join(folderBase, "tmp"), filepath.Join(folderBase, "new"), filepath.Join(folderBase, "cur"))
return dirs
}
func maildirFilename(messageID, headerMessageID string) string {
base := strings.TrimSpace(headerMessageID)
if base == "" {
base = messageID
}
return fmt.Sprintf("%d.%s.%s", time.Now().UnixNano(), safeMaildirName(messageID), safeMaildirName(base))
}
func safeMaildirName(value string) string {
value = strings.Trim(value, "<>")
var b strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
b.WriteRune(r)
case r == '.', r == '_', r == '-', r == '@':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := strings.Trim(b.String(), "._-")
if out == "" {
out = "message"
}
if len(out) > 120 {
out = out[:120]
}
return out
}
func messageDate(msg storedMessage) time.Time {
if !msg.SentAt.IsZero() {
return msg.SentAt
}
if !msg.ReceivedAt.IsZero() {
return msg.ReceivedAt
}
return time.Now().UTC()
}
func maildirPathWithFlags(path string, read, starred bool) string {
dir := filepath.Dir(path)
name := filepath.Base(path)
if read || starred {
dir = filepath.Join(filepath.Dir(dir), "cur")
} else if filepath.Base(dir) == "cur" {
dir = filepath.Join(filepath.Dir(dir), "new")
}
base := name
sep := maildirFlagSeparator()
existingFlags := ""
if idx := strings.LastIndex(base, sep); idx >= 0 {
existingFlags = base[idx+len(sep):]
base = base[:idx]
}
flags := preserveMaildirFlags(existingFlags, "SF")
if read {
flags = appendMaildirFlag(flags, 'S')
}
if starred {
flags = appendMaildirFlag(flags, 'F')
}
if flags != "" {
base += sep + flags
}
return filepath.Join(dir, base)
}
func preserveMaildirFlags(flags, managed string) string {
var b strings.Builder
for _, flag := range flags {
if strings.ContainsRune(managed, flag) || strings.ContainsRune(b.String(), flag) {
continue
}
b.WriteRune(flag)
}
return b.String()
}
func appendMaildirFlag(flags string, flag rune) string {
if strings.ContainsRune(flags, flag) {
return flags
}
return flags + string(flag)
}
func maildirFlagSeparator() string {
if runtime.GOOS == "windows" {
return "!2,"
}
return ":2,"
}
@@ -0,0 +1,241 @@
package app
import (
"errors"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
)
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
}
func (a *App) handleDefaultPermissionLimits(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, defaultPermissionLimits())
}
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,limits_json,system,created_at,updated_at
FROM permission_groups
ORDER BY created_at ASC,name ASC`)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
items := []PermissionGroup{}
for rows.Next() {
var item PermissionGroup
var rawPermissions, rawLimits, created, updated string
var system int
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &rawPermissions, &rawLimits, &system, &created, &updated); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
return
}
item.Permissions = decodeStoredPermissions(rawPermissions)
item.Limits = decodeStoredLimits(rawLimits)
item.System = intBool(system)
item.CreatedAt = parseTime(created)
item.UpdatedAt = parseTime(updated)
items = append(items, item)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
if err := rows.Close(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
return
}
var adminCount, regularCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
WHERE u.role<>'admin'
AND NOT EXISTS (
SELECT 1 FROM user_permission_groups upg
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
).Scan(&regularCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
for i := range items {
switch items[i].ID {
case PermissionGroupSuperAdmin:
items[i].UserCount = adminCount
case PermissionGroupRegular:
items[i].UserCount = regularCount
default:
var count int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
respondError(w, http.StatusInternalServerError, "failed to count users")
return
}
items[i].UserCount = count
}
}
sortPermissionGroups(items)
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
}
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
permissions, err := normalizePermissionList(req.Permissions)
if err != nil {
badRequest(w, err)
return
}
if !actorCanGrantPermissions(currentUser(r), permissions) {
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
id := newID("pg")
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,limits_json,system,created_at,updated_at)
VALUES(?,?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), now, now); err != nil {
badRequest(w, err)
return
}
group, err := a.permissionGroupByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load permission group")
return
}
respondJSON(w, http.StatusCreated, group)
}
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var existingSystem int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if id == PermissionGroupSuperAdmin {
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
return
}
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Limits *PermissionLimits `json:"limits"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name := strings.TrimSpace(req.Name)
if name == "" {
badRequest(w, errors.New("name is required"))
return
}
permissions, err := normalizePermissionList(req.Permissions)
if err != nil {
badRequest(w, err)
return
}
if !actorCanGrantPermissions(currentUser(r), permissions) {
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
return
}
limits := defaultPermissionLimits()
if req.Limits != nil {
var err error
limits, err = normalizePermissionLimits(*req.Limits)
if err != nil {
badRequest(w, err)
return
}
} else {
var rawLimits string
if err := a.db.QueryRowContext(r.Context(), `SELECT limits_json FROM permission_groups WHERE id=?`, id).Scan(&rawLimits); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
limits = decodeStoredLimits(rawLimits)
}
if !actorCanGrantLimits(currentUser(r), limits) {
respondError(w, http.StatusForbidden, "cannot grant limits above your own")
return
}
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,limits_json=?,updated_at=? WHERE id=?`,
name, strings.TrimSpace(req.Description), encodePermissions(permissions), encodePermissionLimits(limits), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
badRequest(w, err)
return
}
group, err := a.permissionGroupByID(r.Context(), id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load permission group")
return
}
respondJSON(w, http.StatusOK, group)
}
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var system int
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
if intBool(system) {
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
return
}
var userCount int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
respondError(w, http.StatusInternalServerError, "failed to check permission group")
return
}
if userCount > 0 {
badRequest(w, errors.New("permission group is assigned to users"))
return
}
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
return
}
affected, _ := res.RowsAffected()
if affected == 0 {
respondError(w, http.StatusNotFound, "permission group not found")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
File diff suppressed because it is too large Load Diff
+304 -64
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -466,14 +467,12 @@ func (a *App) handleCreateRule(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
matchMode := strings.TrimSpace(req.MatchMode)
if matchMode == "" {
matchMode = "all"
}
if matchMode != "all" && matchMode != "any" {
rawMatchMode := strings.ToLower(strings.TrimSpace(req.MatchMode))
if rawMatchMode != "" && rawMatchMode != "all" && rawMatchMode != "and" && rawMatchMode != "any" && rawMatchMode != "or" {
badRequest(w, errors.New("invalid match mode"))
return
}
matchMode := normalizeRuleMatchMode(rawMatchMode)
conditions := normalizeRuleConditions(req.Conditions, req.FromContains, req.SubjectContains)
if len(conditions) == 0 {
badRequest(w, errors.New("rule condition is required"))
@@ -639,10 +638,21 @@ func (a *App) handleMailStats(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusInternalServerError, "failed to load stats")
return
}
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount); err != nil {
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(a.id),COALESCE(SUM(a.size_bytes),0) FROM attachments a JOIN messages m ON m.id=a.message_id JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...).Scan(&stats.AttachmentCount, &stats.AttachmentBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load attachment stats")
return
}
if mailboxID != "" {
var quotaMB int64
if err := a.db.QueryRowContext(r.Context(), `SELECT quota_mb FROM mailboxes WHERE id=? AND user_id=?`, mailboxID, user.ID).Scan(&quotaMB); err != nil {
respondError(w, http.StatusInternalServerError, "failed to load quota")
return
}
stats.QuotaBytes = quotaMB * 1024 * 1024
if stats.QuotaBytes > 0 {
stats.QuotaUsedPct = float64(stats.StorageBytes) / float64(stats.QuotaBytes) * 100
}
}
rows, err := a.db.QueryContext(r.Context(), `SELECT f.name,f.role,COUNT(m.id),COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0),COALESCE(SUM(m.size_bytes),0)
FROM mailboxes mb JOIN folders f ON f.mailbox_id=mb.id LEFT JOIN messages m ON m.folder_id=f.id
WHERE `+where+` GROUP BY f.id,f.name,f.role ORDER BY CASE f.role WHEN 'inbox' THEN 1 WHEN 'sent' THEN 2 WHEN 'drafts' THEN 3 WHEN 'archive' THEN 4 WHEN 'spam' THEN 5 WHEN 'trash' THEN 6 ELSE 99 END`, args...)
@@ -723,11 +733,14 @@ func (a *App) deleteMessagesInFolder(ctx context.Context, mailboxID, folder stri
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, id := range ids {
a.deleteMessageFiles(ctx, id)
if _, err := a.db.ExecContext(ctx, `DELETE FROM messages WHERE id=?`, id); err != nil {
return 0, err
}
a.deleteMessage(ctx, id)
}
return int64(len(ids)), nil
}
@@ -741,13 +754,31 @@ func (a *App) archiveReadInbox(ctx context.Context, mailboxID string) (int64, er
if err != nil {
return 0, err
}
res, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE mailbox_id=? AND folder_id=? AND is_read=1`,
archiveID, a.now().UTC().Format(time.RFC3339Nano), mailboxID, inboxID)
rows, err := a.db.QueryContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND is_read=1`, mailboxID, inboxID)
if err != nil {
return 0, err
}
n, _ := res.RowsAffected()
return n, nil
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, err
}
if err := rows.Close(); err != nil {
return 0, err
}
for _, id := range ids {
if err := a.moveMessageMaildir(ctx, id, archiveID); err != nil {
return 0, err
}
}
return int64(len(ids)), nil
}
func scanContact(row messageSummaryScanner) (Contact, error) {
@@ -832,9 +863,7 @@ func scanRule(row messageSummaryScanner) (MailRule, error) {
if err == nil {
item.Conditions = decodeRuleConditions(conditionsJSON, item.FromContains, item.SubjectContains)
item.Actions = decodeRuleActions(actionsJSON, item.Action)
if item.MatchMode == "" {
item.MatchMode = "all"
}
item.MatchMode = normalizeRuleMatchMode(item.MatchMode)
}
item.ApplyToExisting = intBool(applyToExisting)
item.StopProcessing = intBool(stopProcessing)
@@ -856,13 +885,8 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
if err := a.db.QueryRowContext(ctx, `SELECT user_id FROM mailboxes WHERE id=?`, mailboxID).Scan(&userID); err != nil {
return
}
from = normalizeEmail(from)
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
if blocked > 0 {
if spamID, err := a.ensureFolder(ctx, mailboxID, "Spam"); err == nil {
_, _ = a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, spamID, a.now().UTC().Format(time.RFC3339Nano), messageID)
}
if a.senderBlocked(ctx, userID, mailboxID, from) {
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
return
}
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,name,match_mode,conditions_json,actions_json,from_contains,subject_contains,action,apply_to_existing,stop_processing,enabled,created_at FROM mail_rules WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND enabled=1 ORDER BY created_at`, userID, mailboxID)
@@ -878,26 +902,94 @@ func (a *App) applyInboundControls(ctx context.Context, messageID, mailboxID, fr
}
rows.Close()
msg := ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
_ = a.db.QueryRowContext(ctx, `SELECT trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,subject,snippet,body_text FROM messages WHERE id=?`, messageID).Scan(&msg.From, &msg.To, &msg.Subject, &msg.Snippet, &msg.BodyText)
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
msg = ruleMessage{ID: messageID, MailboxID: mailboxID, From: from, Subject: subject}
}
for _, rule := range rules {
if !ruleMatches(rule, msg) {
continue
}
_ = a.applyRuleActions(ctx, mailboxID, messageID, rule.Actions)
if rule.StopProcessing {
return
break
}
}
if a.senderBlocked(ctx, userID, mailboxID, from) {
a.moveBlockedMessageToSpam(ctx, messageID, mailboxID)
}
}
func (a *App) senderBlocked(ctx context.Context, userID, mailboxID, from string) bool {
from = normalizeEmail(from)
if from == "" {
return false
}
var blocked int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM blocked_senders WHERE user_id=? AND (mailbox_id='' OR mailbox_id=?) AND email=?`, userID, mailboxID, from).Scan(&blocked)
return blocked > 0
}
func (a *App) moveBlockedMessageToSpam(ctx context.Context, messageID, mailboxID string) {
spamID, err := a.ensureFolder(ctx, mailboxID, "Spam")
if err != nil {
return
}
_ = a.moveMessageMaildir(ctx, messageID, spamID)
}
type ruleMessage struct {
ID string
MailboxID string
From string
To string
Subject string
Snippet string
BodyText string
ID string
MailboxID string
From string
To string
CC string
Subject string
Snippet string
BodyText string
AttachmentNames string
SizeBytes int64
ReceivedAt time.Time
}
func (a *App) ruleMessageByID(ctx context.Context, messageID string) (ruleMessage, bool) {
var msg ruleMessage
var toAddrs, ccAddrs, receivedAt string
err := a.db.QueryRowContext(ctx, `SELECT id,COALESCE(mailbox_id,''),trim(from_addr || ' ' || COALESCE(from_name,'')),to_addrs,cc_addrs,subject,snippet,body_text,size_bytes,received_at FROM messages WHERE id=?`, messageID).
Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &ccAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText, &msg.SizeBytes, &receivedAt)
if err != nil {
return ruleMessage{}, false
}
msg.To = ruleAddressText(toAddrs)
msg.CC = ruleAddressText(ccAddrs)
msg.ReceivedAt = parseTime(receivedAt)
msg.AttachmentNames = a.ruleAttachmentNames(ctx, messageID)
return msg, true
}
func ruleAddressText(raw string) string {
var items []string
if strings.TrimSpace(raw) != "" && json.Unmarshal([]byte(raw), &items) == nil {
return strings.Join(items, " ")
}
return raw
}
func (a *App) ruleAttachmentNames(ctx context.Context, messageID string) string {
rows, err := a.db.QueryContext(ctx, `SELECT filename,content_type FROM attachments WHERE message_id=? ORDER BY filename`, messageID)
if err != nil {
return ""
}
defer rows.Close()
parts := []string{}
for rows.Next() {
var filename, contentType string
if err := rows.Scan(&filename, &contentType); err != nil {
return strings.Join(parts, " ")
}
parts = append(parts, filename, contentType)
}
return strings.Join(parts, " ")
}
func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubject string) []MailRuleCondition {
@@ -911,26 +1003,56 @@ func normalizeRuleConditions(items []MailRuleCondition, legacyFrom, legacySubjec
}
out := []MailRuleCondition{}
for _, item := range items {
field := strings.TrimSpace(item.Field)
operator := strings.TrimSpace(item.Operator)
value := strings.TrimSpace(item.Value)
if value == "" {
continue
if normalized, ok := normalizeRuleCondition(item); ok {
out = append(out, normalized)
}
if field != "from" && field != "to" && field != "subject" && field != "body" {
continue
}
if operator == "" {
operator = "contains"
}
if operator != "contains" && operator != "not-contains" && operator != "equals" && operator != "not-equals" && operator != "starts-with" && operator != "ends-with" {
continue
}
out = append(out, MailRuleCondition{Field: field, Operator: operator, Value: value})
}
return out
}
func normalizeRuleCondition(item MailRuleCondition) (MailRuleCondition, bool) {
matchMode := normalizeRuleMatchMode(item.MatchMode)
if len(item.Conditions) > 0 {
children := normalizeRuleConditions(item.Conditions, "", "")
if len(children) == 0 {
return MailRuleCondition{}, false
}
return MailRuleCondition{MatchMode: matchMode, Conditions: children}, true
}
field := strings.ToLower(strings.TrimSpace(item.Field))
operator := strings.ToLower(strings.TrimSpace(item.Operator))
value := strings.TrimSpace(item.Value)
if value == "" {
return MailRuleCondition{}, false
}
switch field {
case "from", "to", "cc", "subject", "body", "attachment", "size", "date":
default:
return MailRuleCondition{}, false
}
if operator == "" {
operator = "contains"
}
switch operator {
case "contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with":
case "gt", "gte", "lt", "lte", "before", "after", "on":
default:
return MailRuleCondition{}, false
}
return MailRuleCondition{Field: field, Operator: operator, Value: value}, true
}
func normalizeRuleMatchMode(matchMode string) string {
switch strings.ToLower(strings.TrimSpace(matchMode)) {
case "any", "or":
return "any"
case "all", "and":
return "all"
default:
return "all"
}
}
func normalizeRuleActions(items []MailRuleAction, legacyAction string) []MailRuleAction {
if len(items) == 0 && strings.TrimSpace(legacyAction) != "" {
items = append(items, MailRuleAction{Type: strings.TrimSpace(legacyAction)})
@@ -987,10 +1109,11 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
if len(conditions) == 0 {
return false
}
matchMode := rule.MatchMode
if matchMode == "" {
matchMode = "all"
}
matchMode := normalizeRuleMatchMode(rule.MatchMode)
return ruleConditionsMatch(conditions, matchMode, msg)
}
func ruleConditionsMatch(conditions []MailRuleCondition, matchMode string, msg ruleMessage) bool {
matched := 0
for _, condition := range conditions {
if ruleConditionMatches(condition, msg) {
@@ -1006,12 +1129,17 @@ func ruleMatches(rule MailRule, msg ruleMessage) bool {
}
func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
if len(condition.Conditions) > 0 {
return ruleConditionsMatch(condition.Conditions, normalizeRuleMatchMode(condition.MatchMode), msg)
}
var source string
switch condition.Field {
switch strings.ToLower(strings.TrimSpace(condition.Field)) {
case "from":
source = msg.From
case "to":
source = msg.To
case "cc":
source = msg.CC
case "subject":
source = msg.Subject
case "body":
@@ -1019,12 +1147,18 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
if source == "" {
source = msg.Snippet
}
case "attachment":
source = msg.AttachmentNames
case "size":
return ruleNumericConditionMatches(condition, msg.SizeBytes)
case "date":
return ruleDateConditionMatches(condition, msg.ReceivedAt)
default:
return false
}
source = strings.ToLower(source)
value := strings.ToLower(condition.Value)
switch condition.Operator {
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "contains":
return strings.Contains(source, value)
case "not-contains":
@@ -1042,35 +1176,139 @@ func ruleConditionMatches(condition MailRuleCondition, msg ruleMessage) bool {
}
}
func ruleNumericConditionMatches(condition MailRuleCondition, source int64) bool {
value, ok := parseRuleSizeValue(condition.Value)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "gt":
return source > value
case "gte":
return source >= value
case "lt":
return source < value
case "lte":
return source <= value
case "equals":
return source == value
case "not-equals":
return source != value
default:
return false
}
}
func parseRuleSizeValue(raw string) (int64, bool) {
value := strings.ToLower(strings.TrimSpace(raw))
multiplier := int64(1)
for _, suffix := range []struct {
text string
multiplier int64
}{
{"kb", 1024},
{"k", 1024},
{"mb", 1024 * 1024},
{"m", 1024 * 1024},
{"gb", 1024 * 1024 * 1024},
{"g", 1024 * 1024 * 1024},
{"b", 1},
} {
if strings.HasSuffix(value, suffix.text) {
multiplier = suffix.multiplier
value = strings.TrimSpace(strings.TrimSuffix(value, suffix.text))
break
}
}
n, err := strconv.ParseInt(value, 10, 64)
if err != nil || n < 0 {
return 0, false
}
return n * multiplier, true
}
func ruleDateConditionMatches(condition MailRuleCondition, source time.Time) bool {
if source.IsZero() {
return false
}
target, ok := parseRuleDateValue(condition.Value)
if !ok {
return false
}
source = source.UTC()
switch strings.ToLower(strings.TrimSpace(condition.Operator)) {
case "before", "lt":
return source.Before(target)
case "after", "gt":
return source.After(target)
case "on", "equals":
y1, m1, d1 := source.Date()
y2, m2, d2 := target.Date()
return y1 == y2 && m1 == m2 && d1 == d2
case "not-equals":
y1, m1, d1 := source.Date()
y2, m2, d2 := target.Date()
return y1 != y2 || m1 != m2 || d1 != d2
default:
return false
}
}
func parseRuleDateValue(raw string) (time.Time, bool) {
value := strings.TrimSpace(raw)
for _, layout := range []string{time.RFC3339Nano, time.RFC3339, "2006-01-02"} {
if t, err := time.Parse(layout, value); err == nil {
return t.UTC(), true
}
}
return time.Time{}, false
}
func (a *App) applyRuleActions(ctx context.Context, mailboxID, messageID string, actions []MailRuleAction) error {
now := a.now().UTC().Format(time.RFC3339Nano)
for _, action := range normalizeRuleActions(actions, "") {
switch action.Type {
case "archive":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Archive"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
}
case "trash":
if folderID, err := a.ensureFolder(ctx, mailboxID, "Trash"); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
}
case "move":
target := ruleTargetFolder(action.Value)
if folderID, err := a.ensureFolder(ctx, mailboxID, target); err == nil {
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET folder_id=?, updated_at=? WHERE id=?`, folderID, now, messageID); err != nil {
if err := a.moveMessageMaildir(ctx, messageID, folderID); err != nil {
return err
}
}
case "star":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
starred := true
if err := a.updateMessageMaildirFlags(ctx, messageID, nil, &starred); err != nil {
return err
}
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_starred=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
return err
}
case "mark-read":
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, updated_at=? WHERE id=?`, now, messageID); err != nil {
read := true
if err := a.updateMessageMaildirFlags(ctx, messageID, &read, nil); err != nil {
return err
}
modSeq, err := a.updateMessageModSeq(ctx, messageID, "")
if err != nil {
return err
}
if _, err := a.db.ExecContext(ctx, `UPDATE messages SET is_read=1, imap_modseq=CASE WHEN ? > 0 THEN ? ELSE imap_modseq END, updated_at=? WHERE id=?`, modSeq, modSeq, now, messageID); err != nil {
return err
}
case "label":
@@ -1128,19 +1366,21 @@ func (a *App) applyRuleToExistingMessages(ctx context.Context, userID, mailboxID
where += ` AND m.mailbox_id=?`
args = append(args, mailboxID)
}
rows, err := a.db.QueryContext(ctx, `SELECT m.id,m.mailbox_id,trim(m.from_addr || ' ' || COALESCE(m.from_name,'')),m.to_addrs,m.subject,m.snippet,m.body_text FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
rows, err := a.db.QueryContext(ctx, `SELECT m.id FROM messages m JOIN mailboxes mb ON mb.id=m.mailbox_id WHERE `+where, args...)
if err != nil {
return 0, err
}
messages := []ruleMessage{}
var count int64
for rows.Next() {
var msg ruleMessage
var toAddrs sql.NullString
if err := rows.Scan(&msg.ID, &msg.MailboxID, &msg.From, &toAddrs, &msg.Subject, &msg.Snippet, &msg.BodyText); err != nil {
var messageID string
if err := rows.Scan(&messageID); err != nil {
return count, err
}
msg.To = toAddrs.String
msg, ok := a.ruleMessageByID(ctx, messageID)
if !ok {
continue
}
if !ruleMatches(rule, msg) {
continue
}
+112 -81
View File
@@ -24,6 +24,7 @@ func (a *App) Router() http.Handler {
r.Use(middleware.Recoverer)
r.Use(a.corsMiddleware)
r.Post("/auth-policy", a.handleAuthPolicy)
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "time": a.now().UTC()})
})
@@ -36,87 +37,119 @@ func (a *App) Router() http.Handler {
r.With(a.requireAuth).Get("/me", a.handleMe)
r.With(a.requireAuth).Post("/me/profile", a.handleUpdateProfile)
r.With(a.requireAuth).Post("/me/password", a.handleChangePassword)
r.With(a.requireAuth).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Get("/me/mailbox-apply-options", a.handleMailboxApplyOptions)
r.With(a.requireAuth, a.requirePermission(PermissionMailboxApply)).Post("/me/mailboxes/apply", a.handleApplyMailbox)
r.With(a.requireAuth).Post("/me/2fa/setup", a.handleTwoFactorSetup)
r.With(a.requireAuth).Post("/me/2fa/enable", a.handleTwoFactorEnable)
r.With(a.requireAuth).Post("/me/2fa/disable", a.handleTwoFactorDisable)
r.With(a.requireAuth).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth).Get("/me/signatures", a.handleListSignatures)
r.With(a.requireAuth).Post("/me/signatures", a.handleCreateSignature)
r.With(a.requireAuth).Post("/me/signatures/{id}", a.handleUpdateSignature)
r.With(a.requireAuth).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
r.With(a.requireAuth).Delete("/me/signatures/{id}", a.handleDeleteSignature)
r.With(a.requireAuth).Get("/me/signatures/default", a.handleDefaultSignature)
r.With(a.requireAuth).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Get("/me/contacts", a.handleListContacts)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Post("/me/contacts", a.handleCreateContact)
r.With(a.requireAuth, a.requirePermission(PermissionMailContacts)).Delete("/me/contacts/{id}", a.handleDeleteContact)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures", a.handleListSignatures)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures", a.handleCreateSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}", a.handleUpdateSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Post("/me/signatures/{id}/default", a.handleSetDefaultSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Delete("/me/signatures/{id}", a.handleDeleteSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailSignatures)).Get("/me/signatures/default", a.handleDefaultSignature)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Get("/me/rules", a.handleListRules)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Post("/me/rules", a.handleCreateRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailRules)).Delete("/me/rules/{id}", a.handleDeleteRule)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Get("/me/blocked-senders", a.handleListBlockedSenders)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Post("/me/blocked-senders", a.handleCreateBlockedSender)
r.With(a.requireAuth, a.requirePermission(PermissionMailBlocked)).Delete("/me/blocked-senders/{id}", a.handleDeleteBlockedSender)
r.With(a.requireAuth, a.requirePermission(PermissionMailStats)).Get("/me/stats", a.handleMailStats)
r.With(a.requireAuth, a.requirePermission(PermissionMailOrganize)).Post("/me/cleanup", a.handleMailCleanup)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts", a.handleListExternalIMAPAccounts)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts", a.handleCreateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}", a.handleUpdateExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Delete("/me/external-imap-accounts/{id}", a.handleDeleteExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/test", a.handleTestExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Get("/me/external-imap-accounts/{id}/runs", a.handleExternalIMAPSyncRuns)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync", a.handleSyncExternalIMAPAccount)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-accounts/{id}/sync-folder", a.handleSyncExternalIMAPFolder)
r.With(a.requireAuth, a.requirePermission(PermissionMailAccess), a.requireExternalIMAPEnabled).Post("/me/external-imap-oauth/{provider}/start", a.handleStartExternalIMAPOAuth)
r.With(a.requireExternalIMAPEnabled).Get("/external-imap-oauth/{provider}/callback", a.handleExternalIMAPOAuthCallback)
r.With(a.requireAuth).Get("/events", a.handleEvents)
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Get("/mail/mailboxes", a.handleMyMailboxes)
r.Get("/mail/folders", a.handleMailFolders)
r.Get("/mail/labels", a.handleMailLabels)
r.Post("/mail/labels", a.handleCreateMailLabel)
r.Get("/mail/messages", a.handleMailMessages)
r.Get("/mail/starred", a.handleStarredMessages)
r.Get("/mail/messages/{id}", a.handleMailMessage)
r.Post("/mail/send", a.handleMailSend)
r.Get("/mail/scheduled-sends", a.handleScheduledSends)
r.Post("/mail/schedule-send", a.handleScheduleSend)
r.Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
r.Post("/mail/drafts", a.handleSaveDraft)
r.Post("/mail/drafts/{id}", a.handleSaveDraft)
r.Delete("/mail/drafts/{id}", a.handleDeleteDraft)
r.Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.Post("/mail/messages/{id}/star", a.handleStar)
r.Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.Post("/mail/messages/{id}/move", a.handleMove)
r.Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.Get("/mail/attachments/{id}", a.handleAttachment)
r.With(a.requirePermission(PermissionMailAccess)).Get("/mail/mailboxes", a.handleMyMailboxes)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/folders", a.handleMailFolders)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders", a.handleCreateMailFolder)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/folders/reorder", a.handleReorderMailFolders)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/folders/{id}", a.handleDeleteMailFolder)
r.With(a.requireAnyPermission(PermissionMailRead, PermissionMailLabels)).Get("/mail/labels", a.handleMailLabels)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/labels", a.handleCreateMailLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/labels/{id}", a.handleDeleteMailLabel)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages", a.handleMailMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/starred", a.handleStarredMessages)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/messages/{id}", a.handleMailMessage)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts", a.handleMailExternalAccounts)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/folders", a.handleExternalIMAPFolders)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages", a.handleExternalIMAPMessages)
r.With(a.requirePermission(PermissionMailRead), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/messages/{remoteId}", a.handleExternalIMAPMessage)
r.With(a.requirePermission(PermissionMailAttachments), a.requireExternalIMAPEnabled).Get("/mail/external-accounts/{id}/attachments/{remoteId}/{partId}", a.handleExternalIMAPAttachment)
r.With(a.requirePermission(PermissionMailOrganize), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/mark-read", a.handleExternalIMAPMarkRead)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send", a.handleMailSend)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue", a.handleSendQueue)
r.With(a.requirePermission(PermissionMailRead)).Get("/mail/send-queue/{id}/audit", a.handleSendQueueAudit)
r.With(a.requirePermission(PermissionMailSend)).Post("/mail/send-queue/{id}/retry", a.handleRetrySendQueue)
r.With(a.requirePermission(PermissionMailSend)).Delete("/mail/send-queue/{id}", a.handleCancelSendQueue)
r.With(a.requirePermission(PermissionMailSchedule)).Get("/mail/scheduled-sends", a.handleScheduledSends)
r.With(a.requirePermission(PermissionMailSchedule), a.requirePermission(PermissionMailSend)).Post("/mail/schedule-send", a.handleScheduleSend)
r.With(a.requirePermission(PermissionMailSchedule)).Delete("/mail/schedule-send/{id}", a.handleCancelScheduledSend)
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts", a.handleSaveDraft)
r.With(a.requirePermission(PermissionMailDrafts)).Post("/mail/drafts/{id}", a.handleSaveDraft)
r.With(a.requirePermission(PermissionMailDrafts)).Delete("/mail/drafts/{id}", a.handleDeleteDraft)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/mark-read", a.handleMarkRead)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/star", a.handleStar)
r.With(a.requirePermission(PermissionMailLabels)).Post("/mail/messages/{id}/labels", a.handleAddMessageLabel)
r.With(a.requirePermission(PermissionMailLabels)).Delete("/mail/messages/{id}/labels/{labelID}", a.handleRemoveMessageLabel)
r.With(a.requirePermission(PermissionMailOrganize)).Post("/mail/messages/{id}/move", a.handleMove)
r.With(a.requirePermission(PermissionMailOrganize)).Delete("/mail/messages/{id}", a.handleDeleteMessage)
r.With(a.requirePermission(PermissionMailAttachments)).Get("/mail/attachments/{id}", a.handleAttachment)
})
r.Group(func(r chi.Router) {
r.Use(a.requireAuth)
r.Use(a.requireAdmin)
r.Get("/admin/overview", a.handleAdminOverview)
r.Get("/admin/users", a.handleListUsers)
r.Post("/admin/users", a.handleCreateUser)
r.Post("/admin/users/{id}", a.handleUpdateUser)
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
r.Delete("/admin/users/{id}", a.handleDeleteUser)
r.Get("/admin/domains", a.handleListDomains)
r.Post("/admin/domains", a.handleCreateDomain)
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
r.Get("/admin/mailboxes", a.handleListMailboxes)
r.Post("/admin/mailboxes", a.handleCreateMailbox)
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
r.Get("/admin/aliases", a.handleListAliases)
r.Post("/admin/aliases", a.handleCreateAlias)
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.Get("/admin/messages", a.handleAdminMessages)
r.Get("/admin/messages/{id}", a.handleAdminMessage)
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.Get("/admin/settings", a.handleGetSystemSettings)
r.Post("/admin/settings", a.handleUpdateSystemSettings)
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.Get("/admin/mail-templates", a.handleListMailTemplates)
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
r.Use(a.requireAdminAccess)
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-limits/defaults", a.handleDefaultPermissionLimits)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/send-audit", a.handleAdminSendAudit)
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/maildir-sync/health", a.handleMaildirSyncHealth)
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
})
})
@@ -152,17 +185,6 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
})
}
func (a *App) requireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := currentUser(r)
if user == nil || user.Role != "admin" {
respondError(w, http.StatusForbidden, "admin role required")
return
}
next.ServeHTTP(w, r)
})
}
func currentUser(r *http.Request) *User {
user, _ := r.Context().Value(userContextKey).(*User)
return user
@@ -188,6 +210,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
if u.Disabled {
return nil, errors.New("disabled")
}
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
return nil, err
}
return &u, nil
}
@@ -206,6 +231,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err
}
return &u, passwordHash, nil
}
@@ -223,5 +251,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, err
}
return &u, nil
}
+447
View File
@@ -0,0 +1,447 @@
package app
import (
"context"
"database/sql"
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"time"
)
const (
sendQueueStatusQueued = "queued"
sendQueueStatusSending = "sending"
sendQueueStatusDelivered = "delivered"
sendQueueStatusFailed = "failed"
sendQueueStatusCanceled = "canceled"
sendAuditAccepted = "accepted"
sendAuditQueued = "queued"
sendAuditDelivered = "delivered"
sendAuditFailed = "failed"
sendAuditRetry = "retry"
sendAuditCanceled = "canceled"
sendSourceWebmail = "webmail"
sendSourceSubmission = "submission"
sendQueueStaleAfter = 15 * time.Minute
sendQueueConcurrency = 4
sendQueueDeliveredMarkerDir = "send_queue_delivered"
)
type sendQueueInput struct {
UserID string
MailboxID string
SentMessageID string
MessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
MIMEBytes []byte
Now time.Time
}
type sendQueueItem struct {
ID string
UserID string
MailboxID string
SentMessageID string
MessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
MIMEBytes []byte
AttemptCount int
MaxAttempts int
}
func (a *App) enqueueSend(ctx context.Context, in sendQueueInput) (string, error) {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
return "", nil
}
now := in.Now.UTC()
if now.IsZero() {
now = a.now().UTC()
}
id := newID("snd")
messageID := strings.TrimSpace(in.MessageID)
mimeBase64 := base64.StdEncoding.EncodeToString(in.MIMEBytes)
recipientsJSON := jsonEncode(dedupeEmails(in.Recipients))
_, err := a.db.ExecContext(ctx, `INSERT OR IGNORE INTO send_queue(id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,status,next_attempt_at,created_at,updated_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
id, in.UserID, in.MailboxID, in.SentMessageID, messageID, in.Source, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return "", err
}
if strings.TrimSpace(messageID) != "" {
var existingID, status string
var attemptCount, maxAttempts int
if err := a.db.QueryRowContext(ctx, `SELECT id,status,attempt_count,max_attempts FROM send_queue WHERE mailbox_id=? AND source=? AND message_id=?`, in.MailboxID, in.Source, messageID).Scan(&existingID, &status, &attemptCount, &maxAttempts); err != nil {
return "", err
}
if existingID != id {
if status == sendQueueStatusDelivered || status == sendQueueStatusCanceled || (status == sendQueueStatusFailed && attemptCount >= maxAttempts) {
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET user_id=?,sent_message_id=?,mail_from=?,header_from=?,recipients_json=?,mime_base64=?,status=?,attempt_count=0,next_attempt_at=?,last_error='',updated_at=?,delivered_at=NULL WHERE id=? AND status=?`,
in.UserID, in.SentMessageID, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), recipientsJSON, mimeBase64, sendQueueStatusQueued, now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), existingID, status)
if err != nil {
return "", err
}
a.deleteSendQueueDeliveredMarker(existingID)
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
QueueID: existingID,
UserID: in.UserID,
MailboxID: in.MailboxID,
SentMessageID: in.SentMessageID,
Source: in.Source,
MailFrom: in.MailFrom,
HeaderFrom: in.HeaderFrom,
Recipients: in.Recipients,
})
}
return existingID, nil
}
}
a.recordSendAudit(ctx, sendAuditQueued, sendQueueStatusQueued, sendAuditInput{
QueueID: id,
UserID: in.UserID,
MailboxID: in.MailboxID,
SentMessageID: in.SentMessageID,
Source: in.Source,
MailFrom: in.MailFrom,
HeaderFrom: in.HeaderFrom,
Recipients: in.Recipients,
})
return id, nil
}
func (a *App) sendQueueWorker(ctx context.Context) {
a.log.Info("send queue worker started")
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
a.log.Info("send queue worker stopped")
return
default:
}
if err := a.processDueSendQueue(ctx); err != nil {
a.log.Warn("send queue worker failed", "error", err)
}
select {
case <-ctx.Done():
a.log.Info("send queue worker stopped")
return
case <-ticker.C:
}
}
}
func (a *App) processDueSendQueue(ctx context.Context) error {
if strings.TrimSpace(a.cfg.SMTPHost) == "" {
return nil
}
if err := a.recoverStaleSendQueueItems(ctx); err != nil {
return err
}
rows, err := a.db.QueryContext(ctx, `SELECT id FROM send_queue WHERE (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=? ORDER BY next_attempt_at, created_at LIMIT 20`, sendQueueStatusQueued, sendQueueStatusFailed, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return err
}
sem := make(chan struct{}, sendQueueConcurrency)
done := make(chan struct{}, len(ids))
for _, id := range ids {
select {
case <-ctx.Done():
return ctx.Err()
case sem <- struct{}{}:
}
go func(id string) {
defer func() {
<-sem
done <- struct{}{}
}()
a.processSendQueueItem(ctx, id)
}(id)
}
for range ids {
select {
case <-ctx.Done():
return ctx.Err()
case <-done:
}
}
return nil
}
func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
cutoff := a.now().UTC().Add(-sendQueueStaleAfter).Format(time.RFC3339Nano)
rows, err := a.db.QueryContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE status=? AND updated_at<=? AND attempt_count<max_attempts LIMIT 20`, sendQueueStatusSending, cutoff)
if err != nil {
return err
}
defer rows.Close()
var items []sendQueueItem
for rows.Next() {
var item sendQueueItem
var recipientsJSON, mimeBase64 string
if err := rows.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
return err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
if err != nil {
return err
}
if delivered {
items = append(items, item)
continue
}
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
if err != nil {
return err
}
item.MIMEBytes = mimeBytes
items = append(items, item)
}
if err := rows.Err(); err != nil {
return err
}
now := a.now().UTC().Format(time.RFC3339Nano)
for _, item := range items {
delivered, err := a.hasSendQueueDeliveredMarker(item.ID)
if err != nil {
return err
}
if delivered {
if err := a.markSendQueueDelivered(ctx, item); err != nil {
return err
}
continue
}
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusQueued, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n > 0 {
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
}
}
return nil
}
func (a *App) processSendQueueItem(ctx context.Context, id string) {
item, err := a.claimSendQueueItem(ctx, id)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
a.log.Warn("failed to claim send queue item", "id", id, "error", err)
}
return
}
if err := a.sendSMTP(item.MailFrom, item.Recipients, item.MIMEBytes); err != nil {
a.markSendQueueFailed(ctx, item, err)
return
}
if err := a.writeSendQueueDeliveredMarker(item.ID); err != nil {
a.log.Warn("failed to persist send queue delivered marker", "id", item.ID, "error", err)
}
if err := a.markSendQueueDelivered(ctx, item); err != nil {
a.log.Warn("failed to mark send queue delivered", "id", item.ID, "error", err)
return
}
}
func (a *App) markSendQueueDelivered(ctx context.Context, item sendQueueItem) error {
now := a.now().UTC().Format(time.RFC3339Nano)
if _, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,delivered_at=?,updated_at=?,last_error='',mime_base64='' WHERE id=?`, sendQueueStatusDelivered, now, now, item.ID); err != nil {
return err
}
a.deleteSendQueueDeliveredMarker(item.ID)
a.recordSendAudit(ctx, sendAuditDelivered, sendQueueStatusDelivered, sendAuditInputFromQueue(item, ""))
return nil
}
func (a *App) claimSendQueueItem(ctx context.Context, id string) (sendQueueItem, error) {
now := a.now().UTC().Format(time.RFC3339Nano)
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,attempt_count=attempt_count+1,updated_at=? WHERE id=? AND (status=? OR (status=? AND attempt_count<max_attempts)) AND next_attempt_at<=?`, sendQueueStatusSending, now, id, sendQueueStatusQueued, sendQueueStatusFailed, now)
if err != nil {
return sendQueueItem{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return sendQueueItem{}, sql.ErrNoRows
}
var item sendQueueItem
var recipientsJSON, mimeBase64 string
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,mailbox_id,sent_message_id,message_id,source,mail_from,header_from,recipients_json,mime_base64,attempt_count,max_attempts FROM send_queue WHERE id=?`, id)
if err := row.Scan(&item.ID, &item.UserID, &item.MailboxID, &item.SentMessageID, &item.MessageID, &item.Source, &item.MailFrom, &item.HeaderFrom, &recipientsJSON, &mimeBase64, &item.AttemptCount, &item.MaxAttempts); err != nil {
return sendQueueItem{}, err
}
item.Recipients = jsonDecodeSlice(recipientsJSON)
mimeBytes, err := base64.StdEncoding.DecodeString(mimeBase64)
if err != nil {
return sendQueueItem{}, err
}
item.MIMEBytes = mimeBytes
return item, nil
}
func (a *App) markSendQueueFailed(ctx context.Context, item sendQueueItem, sendErr error) {
now := a.now().UTC()
status := sendQueueStatusFailed
nextAttempt := now.Add(sendRetryDelay(item.AttemptCount))
if item.AttemptCount >= item.MaxAttempts {
nextAttempt = now.Add(365 * 24 * time.Hour)
}
_, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=?`, status, nextAttempt.Format(time.RFC3339Nano), sendErr.Error(), now.Format(time.RFC3339Nano), item.ID)
if err != nil {
a.log.Warn("failed to mark send queue failed", "id", item.ID, "error", err)
}
event := sendAuditRetry
if item.AttemptCount >= item.MaxAttempts {
event = sendAuditFailed
}
a.recordSendAudit(ctx, event, status, sendAuditInputFromQueue(item, sendErr.Error()))
}
func sendRetryDelay(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
delays := []time.Duration{30 * time.Second, 2 * time.Minute, 10 * time.Minute, time.Hour, 6 * time.Hour}
if attempt > len(delays) {
return delays[len(delays)-1]
}
return delays[attempt-1]
}
type sendAuditInput struct {
QueueID string
UserID string
MailboxID string
SentMessageID string
Source string
MailFrom string
HeaderFrom string
Recipients []string
Error string
}
func sendAuditInputFromQueue(item sendQueueItem, errorText string) sendAuditInput {
return sendAuditInput{
QueueID: item.ID,
UserID: item.UserID,
MailboxID: item.MailboxID,
SentMessageID: item.SentMessageID,
Source: item.Source,
MailFrom: item.MailFrom,
HeaderFrom: item.HeaderFrom,
Recipients: item.Recipients,
Error: errorText,
}
}
func (a *App) recordSendAudit(ctx context.Context, event, status string, in sendAuditInput) {
source := strings.TrimSpace(in.Source)
if source == "" {
source = "unknown"
}
_, err := a.db.ExecContext(ctx, `INSERT INTO send_audit_events(id,queue_id,user_id,mailbox_id,sent_message_id,source,event,status,mail_from,header_from,recipients_json,error,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, newID("audit"), in.QueueID, in.UserID, in.MailboxID, in.SentMessageID, source, event, status, normalizeEmail(in.MailFrom), normalizeEmail(in.HeaderFrom), jsonEncode(dedupeEmails(in.Recipients)), in.Error, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
a.log.Warn("failed to record send audit", "event", event, "error", err)
}
}
func (a *App) sendQueueDeliveredMarkerPath(id string) string {
safeID := filepath.Base(strings.TrimSpace(id))
if safeID == "" || safeID == "." {
safeID = "unknown"
}
return filepath.Join(a.cfg.DataDir, sendQueueDeliveredMarkerDir, safeID+".marker")
}
func (a *App) writeSendQueueDeliveredMarker(id string) error {
path := a.sendQueueDeliveredMarkerPath(id)
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
tmp := filepath.Join(dir, filepath.Base(path)+"."+newID("tmp"))
if err := os.WriteFile(tmp, []byte(a.now().UTC().Format(time.RFC3339Nano)), 0o600); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
func (a *App) hasSendQueueDeliveredMarker(id string) (bool, error) {
_, err := os.Stat(a.sendQueueDeliveredMarkerPath(id))
if err == nil {
return true, nil
}
if errors.Is(err, os.ErrNotExist) {
return false, nil
}
return false, err
}
func (a *App) deleteSendQueueDeliveredMarker(id string) {
err := os.Remove(a.sendQueueDeliveredMarkerPath(id))
if err != nil && !errors.Is(err, os.ErrNotExist) {
a.log.Warn("failed to remove send queue delivered marker", "id", id, "error", err)
}
}
func (a *App) authorizedSender(ctx context.Context, mb *Mailbox, from string) (string, string, error) {
from = normalizeEmail(from)
if from == "" {
from = normalizeEmail(mb.Address)
}
if from == normalizeEmail(mb.Address) {
return normalizeEmail(mb.Address), mb.DisplayName, nil
}
var displayName string
var enabled int
err := a.db.QueryRowContext(ctx, `SELECT display_name,enabled FROM send_as_grants WHERE mailbox_id=? AND address=?`, mb.ID, from).Scan(&displayName, &enabled)
if err == nil {
if enabled == 0 {
return "", "", errSenderNotAuthorized
}
return from, strings.TrimSpace(displayName), nil
}
if !errors.Is(err, sql.ErrNoRows) {
return "", "", err
}
var aliasDestination string
err = a.db.QueryRowContext(ctx, `SELECT destination FROM aliases WHERE source=? AND enabled=1`, from).Scan(&aliasDestination)
if err == nil {
for _, destination := range strings.Split(aliasDestination, ",") {
if normalizeEmail(destination) == normalizeEmail(mb.Address) {
return from, mb.DisplayName, nil
}
}
}
return "", "", errSenderNotAuthorized
}
+168 -96
View File
@@ -10,63 +10,80 @@ import (
)
type SystemSettings struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPasswordSet bool `json:"smtpPasswordSet"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretSet bool `json:"turnstileSecretSet"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretSet bool `json:"externalImapSecretSet"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecretSet bool `json:"externalImapGmailClientSecretSet"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecretSet bool `json:"externalImapOutlookClientSecretSet"`
}
type systemSettingsUpdate struct {
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
PublicHostname string `json:"publicHostname"`
PublicBaseURL string `json:"publicBaseUrl"`
SMTPHost string `json:"smtpHost"`
SMTPPort string `json:"smtpPort"`
SMTPUsername string `json:"smtpUsername"`
SMTPPassword string `json:"smtpPassword"`
SMTPRequireTLS bool `json:"smtpRequireTls"`
MaildirRoot string `json:"maildirRoot"`
MaildirScanSeconds int `json:"maildirScanSeconds"`
SessionTTLHours int `json:"sessionTtlHours"`
AllowInsecureHTTP bool `json:"allowInsecureHttp"`
OpenRegistration bool `json:"openRegistration"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
TurnstileSecretKey string `json:"turnstileSecretKey"`
CatchAllEnabled bool `json:"catchAllEnabled"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshSeconds int `json:"mailRefreshSeconds"`
UserMailboxApplyEnabled bool `json:"userMailboxApplyEnabled"`
UserMailboxDomainIDs []string `json:"userMailboxDomainIds"`
ReservedMailboxPrefixes string `json:"reservedMailboxPrefixes"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
ExternalIMAPSecretKey string `json:"externalImapSecretKey"`
ExternalIMAPSyncSeconds int `json:"externalImapSyncSeconds"`
ExternalIMAPAllowPrivateHosts bool `json:"externalImapAllowPrivateHosts"`
ExternalIMAPGmailClientID string `json:"externalImapGmailClientId"`
ExternalIMAPGmailClientSecret string `json:"externalImapGmailClientSecret"`
ExternalIMAPOutlookClientID string `json:"externalImapOutlookClientId"`
ExternalIMAPOutlookClientSecret string `json:"externalImapOutlookClientSecret"`
}
type PublicSettings struct {
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
OpenRegistration bool `json:"openRegistration"`
TurnstileEnabled bool `json:"turnstileEnabled"`
TurnstileSiteKey string `json:"turnstileSiteKey"`
PublicHostname string `json:"publicHostname"`
MailAutoRefresh bool `json:"mailAutoRefresh"`
MailRefreshMs int `json:"mailRefreshMs"`
ExternalIMAPEnabled bool `json:"externalImapEnabled"`
MailboxDomains []PublicDomain `json:"mailboxDomains,omitempty"`
}
type PublicDomain struct {
@@ -88,7 +105,7 @@ func (a *App) handlePublicSettings(w http.ResponseWriter, r *http.Request) {
if refreshSeconds <= 0 {
refreshSeconds = 30
}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000}
settings := PublicSettings{OpenRegistration: a.cfg.OpenRegistration, TurnstileEnabled: enabled, TurnstileSiteKey: a.cfg.TurnstileSiteKey, PublicHostname: a.cfg.PublicHostname, MailAutoRefresh: a.cfg.MailAutoRefresh, MailRefreshMs: refreshSeconds * 1000, ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled}
// Include available domains for mailbox creation during registration
if a.cfg.OpenRegistration {
@@ -165,6 +182,27 @@ func (a *App) handleUpdateSystemSettings(w http.ResponseWriter, r *http.Request)
next.UserMailboxApplyEnabled = req.UserMailboxApplyEnabled
next.UserMailboxDomainIDs = strings.Join(cleanIDList(req.UserMailboxDomainIDs), ",")
next.ReservedMailboxPrefixes = strings.Join(parseReservedPrefixes(req.ReservedMailboxPrefixes), ",")
next.ExternalIMAPEnabled = req.ExternalIMAPEnabled
if strings.TrimSpace(req.ExternalIMAPSecretKey) != "" {
next.ExternalIMAPSecretKey = strings.TrimSpace(req.ExternalIMAPSecretKey)
}
if req.ExternalIMAPSyncSeconds <= 0 {
req.ExternalIMAPSyncSeconds = 300
}
next.ExternalIMAPSyncSeconds = req.ExternalIMAPSyncSeconds
next.ExternalIMAPAllowPrivateHosts = req.ExternalIMAPAllowPrivateHosts
next.ExternalIMAPGmailClientID = strings.TrimSpace(req.ExternalIMAPGmailClientID)
if strings.TrimSpace(req.ExternalIMAPGmailClientSecret) != "" {
next.ExternalIMAPGmailClientSecret = strings.TrimSpace(req.ExternalIMAPGmailClientSecret)
}
next.ExternalIMAPOutlookClientID = strings.TrimSpace(req.ExternalIMAPOutlookClientID)
if strings.TrimSpace(req.ExternalIMAPOutlookClientSecret) != "" {
next.ExternalIMAPOutlookClientSecret = strings.TrimSpace(req.ExternalIMAPOutlookClientSecret)
}
if next.ExternalIMAPEnabled && strings.TrimSpace(next.ExternalIMAPSecretKey) == "" {
badRequest(w, errors.New("外部 IMAP 加密密钥未设置"))
return
}
if err := a.saveSystemSettings(r.Context(), next); err != nil {
respondError(w, http.StatusInternalServerError, "failed to save settings")
@@ -248,28 +286,36 @@ func (a *App) handleTestSMTP(w http.ResponseWriter, r *http.Request) {
func (a *App) systemSettingsSnapshot() SystemSettings {
return SystemSettings{
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
PublicHostname: a.cfg.PublicHostname,
PublicBaseURL: a.cfg.PublicBaseURL,
SMTPHost: a.cfg.SMTPHost,
SMTPPort: a.cfg.SMTPPort,
SMTPUsername: a.cfg.SMTPUsername,
SMTPPasswordSet: strings.TrimSpace(a.cfg.SMTPPassword) != "",
SMTPRequireTLS: a.cfg.SMTPRequireTLS,
MaildirRoot: a.cfg.MaildirRoot,
MaildirScanSeconds: a.cfg.MaildirScanSeconds,
SessionTTLHours: a.cfg.SessionTTLHours,
AllowInsecureHTTP: a.cfg.AllowInsecureHTTP,
OpenRegistration: a.cfg.OpenRegistration,
TwoFactorEnabled: a.cfg.TwoFactorEnabled,
TurnstileEnabled: a.cfg.TurnstileEnabled,
TurnstileSiteKey: a.cfg.TurnstileSiteKey,
TurnstileSecretSet: strings.TrimSpace(a.cfg.TurnstileSecretKey) != "",
CatchAllEnabled: a.cfg.CatchAllEnabled,
MailAutoRefresh: a.cfg.MailAutoRefresh,
MailRefreshSeconds: a.cfg.MailRefreshSeconds,
UserMailboxApplyEnabled: a.cfg.UserMailboxApplyEnabled,
UserMailboxDomainIDs: cleanIDList(strings.Split(a.cfg.UserMailboxDomainIDs, ",")),
ReservedMailboxPrefixes: strings.Join(parseReservedPrefixes(a.cfg.ReservedMailboxPrefixes), "\n"),
ExternalIMAPEnabled: a.cfg.ExternalIMAPEnabled,
ExternalIMAPSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPSecretKey) != "",
ExternalIMAPSyncSeconds: a.cfg.ExternalIMAPSyncSeconds,
ExternalIMAPAllowPrivateHosts: a.cfg.ExternalIMAPAllowPrivateHosts,
ExternalIMAPGmailClientID: a.cfg.ExternalIMAPGmailClientID,
ExternalIMAPGmailClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPGmailClientSecret) != "",
ExternalIMAPOutlookClientID: a.cfg.ExternalIMAPOutlookClientID,
ExternalIMAPOutlookClientSecretSet: strings.TrimSpace(a.cfg.ExternalIMAPOutlookClientSecret) != "",
}
}
@@ -335,6 +381,24 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
a.cfg.UserMailboxDomainIDs = value
case "reservedMailboxPrefixes":
a.cfg.ReservedMailboxPrefixes = value
case "externalImapEnabled":
a.cfg.ExternalIMAPEnabled = value == "true"
case "externalImapSecretKey":
a.cfg.ExternalIMAPSecretKey = value
case "externalImapSyncSeconds":
if n, err := strconv.Atoi(value); err == nil && n > 0 {
a.cfg.ExternalIMAPSyncSeconds = n
}
case "externalImapAllowPrivateHosts":
a.cfg.ExternalIMAPAllowPrivateHosts = value == "true"
case "externalImapGmailClientId":
a.cfg.ExternalIMAPGmailClientID = value
case "externalImapGmailClientSecret":
a.cfg.ExternalIMAPGmailClientSecret = value
case "externalImapOutlookClientId":
a.cfg.ExternalIMAPOutlookClientID = value
case "externalImapOutlookClientSecret":
a.cfg.ExternalIMAPOutlookClientSecret = value
}
}
return rows.Err()
@@ -342,28 +406,36 @@ func (a *App) loadPersistedSystemSettings(ctx context.Context) error {
func (a *App) saveSystemSettings(ctx context.Context, cfg Config) error {
values := map[string]string{
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
"publicHostname": cfg.PublicHostname,
"publicBaseUrl": cfg.PublicBaseURL,
"smtpHost": cfg.SMTPHost,
"smtpPort": cfg.SMTPPort,
"smtpUsername": cfg.SMTPUsername,
"smtpPassword": cfg.SMTPPassword,
"smtpRequireTls": strconv.FormatBool(cfg.SMTPRequireTLS),
"maildirRoot": cfg.MaildirRoot,
"maildirScanSeconds": strconv.Itoa(cfg.MaildirScanSeconds),
"sessionTtlHours": strconv.Itoa(cfg.SessionTTLHours),
"allowInsecureHttp": strconv.FormatBool(cfg.AllowInsecureHTTP),
"openRegistration": strconv.FormatBool(cfg.OpenRegistration),
"twoFactorEnabled": strconv.FormatBool(cfg.TwoFactorEnabled),
"turnstileEnabled": strconv.FormatBool(cfg.TurnstileEnabled),
"turnstileSiteKey": cfg.TurnstileSiteKey,
"turnstileSecretKey": cfg.TurnstileSecretKey,
"catchAllEnabled": strconv.FormatBool(cfg.CatchAllEnabled),
"mailAutoRefresh": strconv.FormatBool(cfg.MailAutoRefresh),
"mailRefreshSeconds": strconv.Itoa(cfg.MailRefreshSeconds),
"userMailboxApplyEnabled": strconv.FormatBool(cfg.UserMailboxApplyEnabled),
"userMailboxDomainIds": strings.Join(cleanIDList(strings.Split(cfg.UserMailboxDomainIDs, ",")), ","),
"reservedMailboxPrefixes": strings.Join(parseReservedPrefixes(cfg.ReservedMailboxPrefixes), ","),
"externalImapEnabled": strconv.FormatBool(cfg.ExternalIMAPEnabled),
"externalImapSecretKey": cfg.ExternalIMAPSecretKey,
"externalImapSyncSeconds": strconv.Itoa(cfg.ExternalIMAPSyncSeconds),
"externalImapAllowPrivateHosts": strconv.FormatBool(cfg.ExternalIMAPAllowPrivateHosts),
"externalImapGmailClientId": cfg.ExternalIMAPGmailClientID,
"externalImapGmailClientSecret": cfg.ExternalIMAPGmailClientSecret,
"externalImapOutlookClientId": cfg.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": cfg.ExternalIMAPOutlookClientSecret,
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(ctx, nil)
+506
View File
@@ -0,0 +1,506 @@
package app
import (
"bytes"
"context"
"crypto/tls"
"database/sql"
"errors"
"fmt"
"io"
"log"
netmail "net/mail"
"net/textproto"
"sort"
"strings"
"time"
"github.com/emersion/go-sasl"
smtpserver "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
)
const (
defaultSubmissionMaxRecipients = 200
)
type SubmissionServers struct {
Plain *smtpserver.Server
TLS *smtpserver.Server
}
func (s *SubmissionServers) Shutdown(ctx context.Context) error {
if s == nil {
return nil
}
var errs []error
if s.Plain != nil {
if err := s.Plain.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
if s.TLS != nil {
if err := s.TLS.Shutdown(ctx); err != nil && !errors.Is(err, smtpserver.ErrServerClosed) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
func (a *App) NewSubmissionServers(tlsConfig *tls.Config) *SubmissionServers {
return &SubmissionServers{
Plain: a.newSubmissionServer(a.cfg.SubmissionAddr, tlsConfig),
TLS: a.newSubmissionServer(a.cfg.SubmissionTLSAddr, tlsConfig),
}
}
func (a *App) newSubmissionServer(addr string, tlsConfig *tls.Config) *smtpserver.Server {
addr = strings.TrimSpace(addr)
if addr == "" {
return nil
}
s := smtpserver.NewServer(submissionBackend{app: a})
s.Addr = addr
s.Domain = a.cfg.PublicHostname
s.TLSConfig = tlsConfig
s.AllowInsecureAuth = false
s.MaxRecipients = defaultSubmissionMaxRecipients
s.MaxMessageBytes = int64(a.cfg.SubmissionMaxMessageMB) * 1024 * 1024
s.ReadTimeout = smtpSessionTimeout
s.WriteTimeout = smtpSessionTimeout
s.ErrorLog = log.New(submissionLogWriter{log: a.log}, "smtp/submission ", 0)
return s
}
func LoadServerTLSConfig(cfg Config) (*tls.Config, error) {
certFile, keyFile := strings.TrimSpace(cfg.TLSCertFile), strings.TrimSpace(cfg.TLSKeyFile)
if certFile == "" || keyFile == "" {
return nil, errors.New("LANQIN_TLS_CERT_FILE and LANQIN_TLS_KEY_FILE are required when SMTP submission is enabled")
}
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &cert, nil
},
}, nil
}
type submissionLogWriter struct {
log slogLogger
}
func (w submissionLogWriter) Write(p []byte) (int, error) {
if w.log != nil {
w.log.Warn(strings.TrimSpace(string(p)))
}
return len(p), nil
}
type slogLogger interface {
Warn(msg string, args ...any)
}
type submissionBackend struct {
app *App
}
func (b submissionBackend) NewSession(*smtpserver.Conn) (smtpserver.Session, error) {
return &submissionSession{app: b.app}, nil
}
type submissionSession struct {
app *App
user *User
mailbox *Mailbox
mailFrom string
recipients []string
}
func (s *submissionSession) AuthMechanisms() []string {
return []string{sasl.Plain}
}
func (s *submissionSession) Auth(mech string) (sasl.Server, error) {
if !strings.EqualFold(mech, sasl.Plain) {
return nil, smtpserver.ErrAuthUnknownMechanism
}
return sasl.NewPlainServer(func(identity, username, password string) error {
user, mailbox, err := s.app.authenticateSubmission(context.Background(), username, password)
if err != nil {
return smtpserver.ErrAuthFailed
}
s.user, s.mailbox = user, mailbox
return nil
}), nil
}
func (s *submissionSession) Mail(from string, _ *smtpserver.MailOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
from = normalizeEmail(from)
authorized, _, err := s.app.authorizedSender(context.Background(), s.mailbox, from)
if err != nil || from == "" || from != authorized {
return smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
s.mailFrom = from
s.recipients = nil
return nil
}
func (s *submissionSession) Rcpt(to string, _ *smtpserver.RcptOptions) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
to = normalizeEmail(to)
if to == "" || !strings.Contains(to, "@") {
return smtpError(501, smtpserver.EnhancedCode{5, 1, 3}, "invalid recipient")
}
s.recipients = append(s.recipients, to)
return nil
}
func (s *submissionSession) Data(r io.Reader) error {
if s.user == nil || s.mailbox == nil {
return smtpserver.ErrAuthRequired
}
if s.mailFrom == "" || len(s.recipients) == 0 {
return smtpError(503, smtpserver.EnhancedCode{5, 5, 1}, "missing sender or recipients")
}
if err := s.app.submitSMTPMessage(context.Background(), s.user, s.mailbox, s.mailFrom, s.recipients, r); err != nil {
var smtpErr *smtpserver.SMTPError
if errors.As(err, &smtpErr) {
return smtpErr
}
return smtpError(451, smtpserver.EnhancedCode{4, 0, 0}, "message submission failed")
}
s.Reset()
return nil
}
func (s *submissionSession) Reset() {
s.mailFrom = ""
s.recipients = nil
}
func (s *submissionSession) Logout() error {
s.Reset()
return nil
}
func (a *App) authenticateSubmission(ctx context.Context, username, password string) (*User, *Mailbox, error) {
address := normalizeEmail(username)
if address == "" {
return nil, nil, errors.New("missing username")
}
var mb Mailbox
var passwordHash, created string
row := a.db.QueryRowContext(ctx, `SELECT id,user_id,domain_id,local_part,address,display_name,password_hash,quota_mb,status,created_at
FROM mailboxes WHERE address=? AND status='active'`, address)
if err := row.Scan(&mb.ID, &mb.UserID, &mb.DomainID, &mb.LocalPart, &mb.Address, &mb.DisplayName, &passwordHash, &mb.QuotaMB, &mb.Status, &created); err != nil {
return nil, nil, err
}
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)); err != nil {
return nil, nil, err
}
mb.CreatedAt = parseTime(created)
user, err := a.userByID(ctx, mb.UserID)
if err != nil {
return nil, nil, err
}
if user.Disabled {
return nil, nil, errors.New("user disabled")
}
if !userHasPermission(user, PermissionMailSend) {
return nil, nil, errors.New("send permission required")
}
return user, &mb, nil
}
func (a *App) submitSMTPMessage(ctx context.Context, user *User, mb *Mailbox, mailFrom string, recipients []string, r io.Reader) error {
if err := a.recordSMTPRate(ctx, user, mb); err != nil {
if errors.Is(err, errSMTPRateLimited) {
return smtpError(452, smtpserver.EnhancedCode{4, 7, 0}, err.Error())
}
return err
}
raw, err := io.ReadAll(r)
if err != nil {
return err
}
prepared, msg, attachments, err := a.prepareSubmittedMessage(ctx, raw, mb, mailFrom, recipients)
if err != nil {
return err
}
msg.MailboxID = mb.ID
sentID, insertedSent, err := a.insertSentMessageOnce(ctx, msg, attachments)
if err != nil {
return err
}
if insertedSent {
if err := a.rewriteMessageMaildir(ctx, sentID); err != nil {
a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
return err
}
}
a.recordSendAudit(ctx, sendAuditAccepted, sendQueueStatusQueued, sendAuditInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients})
if sentID != "" {
if _, err := a.enqueueSend(ctx, sendQueueInput{UserID: user.ID, MailboxID: mb.ID, SentMessageID: sentID, MessageID: msg.MessageID, Source: sendSourceSubmission, MailFrom: mailFrom, HeaderFrom: msg.From, Recipients: recipients, MIMEBytes: prepared, Now: a.now().UTC()}); err != nil {
if insertedSent {
a.deleteMessage(ctx, sentID)
if sentFolderID, ferr := a.ensureFolder(ctx, mb.ID, "Sent"); ferr == nil {
a.deleteSentDedupeKey(ctx, mb.ID, sentFolderID, msg.MessageID)
}
}
return err
}
}
return nil
}
func (a *App) prepareSubmittedMessage(ctx context.Context, raw []byte, mb *Mailbox, mailFrom string, recipients []string) ([]byte, storedMessage, []AttachmentInput, error) {
header, body, err := readMessageHeader(raw)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
fromAddress, fromName, ok := singleHeaderAddress(header.Get("From"))
if !ok || fromAddress == "" {
return nil, storedMessage{}, nil, smtpError(550, smtpserver.EnhancedCode{5, 7, 1}, "From header must contain exactly one address")
}
authAddress, fromName, err := a.authorizedSender(ctx, mb, fromAddress)
if err != nil || normalizeEmail(mailFrom) != authAddress || normalizeEmail(fromAddress) != authAddress {
return nil, storedMessage{}, nil, smtpError(553, smtpserver.EnhancedCode{5, 7, 1}, "sender must match authenticated mailbox")
}
now := a.now().UTC()
messageID := strings.TrimSpace(header.Get("Message-Id"))
if messageID == "" {
messageID = fmt.Sprintf("<%s@%s>", newID("msg"), domainPart(authAddress))
header.Set("Message-ID", messageID)
} else {
header.Set("Message-ID", messageID)
}
sentAt := parseMailDate(header.Get("Date"))
if sentAt.IsZero() {
sentAt = now
header.Set("Date", sentAt.Format(time.RFC1123Z))
}
header.Del("Bcc")
prepared := serializeMessage(header, body)
msg, attachments, err := a.parseMaildirMessage(prepared, authAddress)
if err != nil {
return nil, storedMessage{}, nil, smtpError(554, smtpserver.EnhancedCode{5, 6, 0}, "invalid message")
}
if msg.MessageID == "" {
msg.MessageID = messageID
}
if msg.SentAt.IsZero() {
msg.SentAt = sentAt
}
if msg.ReceivedAt.IsZero() {
msg.ReceivedAt = sentAt
}
msg.From = authAddress
msg.FromName = fromName
msg.To = dedupeEmails(msg.To)
msg.CC = dedupeEmails(msg.CC)
msg.BCC = deduceBCCRecipients(recipients, addressList(header.Get("To")), addressList(header.Get("Cc")))
msg.IsRead = true
msg.RawPath = ""
if msg.Subject == "" {
msg.Subject = "(no subject)"
}
if msg.Snippet == "" {
msg.Snippet = snippetFrom(msg.BodyText, msg.BodyHTML)
}
return prepared, msg, attachments, nil
}
func (a *App) insertSentMessageOnce(ctx context.Context, msg storedMessage, attachments []AttachmentInput) (string, bool, error) {
sentFolderID, err := a.ensureFolder(ctx, msg.MailboxID, "Sent")
if err != nil {
return "", false, err
}
msg.FolderID = sentFolderID
if msg.MessageUID == "" {
msg.MessageUID = newID("uid")
}
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return "", false, err
}
committed := false
messageIDForCleanup := ""
defer func() {
if !committed {
_ = tx.Rollback()
if messageIDForCleanup != "" {
a.deleteMessageFiles(ctx, messageIDForCleanup)
}
}
}()
if msg.MessageID != "" {
existing, err := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
if err == nil {
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil && !errors.Is(err, errSentDedupeExists) {
return "", false, err
}
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return existing, false, nil
}
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return "", false, err
}
if err := a.insertSentDedupeKeyWithDB(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID); err != nil {
if errors.Is(err, errSentDedupeExists) {
existing, qerr := sentMessageIDByMessageID(ctx, tx, msg.MailboxID, sentFolderID, msg.MessageID)
if qerr == nil {
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return existing, false, nil
}
if errors.Is(qerr, sql.ErrNoRows) {
return "", false, fmt.Errorf("sent dedupe key exists without sent message: %w", errSentDedupeExists)
}
return "", false, qerr
}
return "", false, err
}
}
id, err := a.insertMessageWithDB(ctx, tx, msg, attachments)
if err != nil {
return "", false, err
}
messageIDForCleanup = id
if err := tx.Commit(); err != nil {
return "", false, err
}
committed = true
return id, true, nil
}
var errSentDedupeExists = errors.New("sent message already exists")
func (a *App) insertSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) error {
return a.insertSentDedupeKeyWithDB(ctx, a.db, mailboxID, folderID, messageID)
}
func (a *App) insertSentDedupeKeyWithDB(ctx context.Context, db dbExecutor, mailboxID, folderID, messageID string) error {
if strings.TrimSpace(messageID) == "" {
return nil
}
res, err := db.ExecContext(ctx, `INSERT OR IGNORE INTO sent_message_dedupe_keys(mailbox_id,folder_id,message_id,created_at) VALUES(?,?,?,?)`, mailboxID, folderID, messageID, a.now().UTC().Format(time.RFC3339Nano))
if err != nil {
return err
}
if rows, err := res.RowsAffected(); err == nil && rows == 0 {
return errSentDedupeExists
}
return nil
}
func sentMessageIDByMessageID(ctx context.Context, db dbQueryer, mailboxID, folderID, messageID string) (string, error) {
var existing string
err := db.QueryRowContext(ctx, `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? AND message_id=? AND message_id <> '' LIMIT 1`, mailboxID, folderID, messageID).Scan(&existing)
return existing, err
}
func (a *App) deleteSentDedupeKey(ctx context.Context, mailboxID, folderID, messageID string) {
if strings.TrimSpace(messageID) == "" {
return
}
_, _ = a.db.ExecContext(ctx, `DELETE FROM sent_message_dedupe_keys WHERE mailbox_id=? AND folder_id=? AND message_id=?`, mailboxID, folderID, messageID)
}
func readMessageHeader(raw []byte) (textproto.MIMEHeader, []byte, error) {
msg, err := netmail.ReadMessage(bytes.NewReader(raw))
if err != nil {
return nil, nil, err
}
body, err := io.ReadAll(msg.Body)
if err != nil {
return nil, nil, err
}
return textproto.MIMEHeader(msg.Header), body, nil
}
func serializeMessage(header textproto.MIMEHeader, body []byte) []byte {
var buf bytes.Buffer
keys := make([]string, 0, len(header))
for key := range header {
keys = append(keys, key)
}
sort.SliceStable(keys, func(i, j int) bool {
return textproto.CanonicalMIMEHeaderKey(keys[i]) < textproto.CanonicalMIMEHeaderKey(keys[j])
})
for _, key := range keys {
values := header[key]
canonical := textproto.CanonicalMIMEHeaderKey(key)
for _, value := range values {
fmt.Fprintf(&buf, "%s: %s\r\n", canonical, strings.ReplaceAll(strings.ReplaceAll(value, "\r", ""), "\n", " "))
}
}
buf.WriteString("\r\n")
buf.Write(body)
return buf.Bytes()
}
func singleHeaderAddress(value string) (string, string, bool) {
value = strings.TrimSpace(value)
if value == "" {
return "", "", false
}
items, err := netmail.ParseAddressList(value)
if err != nil || len(items) != 1 {
decoded := decodeMIMEHeader(value)
items, err = netmail.ParseAddressList(decoded)
if err != nil || len(items) != 1 {
return "", "", false
}
}
item := items[0]
return normalizeEmail(item.Address), strings.TrimSpace(decodeMIMEHeader(item.Name)), true
}
func deduceBCCRecipients(envelope, to, cc []string) []string {
visible := map[string]bool{}
for _, item := range append(to, cc...) {
if email := normalizeEmail(item); email != "" {
visible[email] = true
}
}
var out []string
seen := map[string]bool{}
for _, item := range envelope {
email := normalizeEmail(item)
if email == "" || visible[email] || seen[email] {
continue
}
seen[email] = true
out = append(out, email)
}
return out
}
func domainPart(email string) string {
parts := strings.SplitN(normalizeEmail(email), "@", 2)
if len(parts) != 2 || parts[1] == "" {
return "lanqin.local"
}
return parts[1]
}
func smtpError(code int, enhanced smtpserver.EnhancedCode, message string) *smtpserver.SMTPError {
return &smtpserver.SMTPError{Code: code, EnhancedCode: enhanced, Message: message}
}
+3
View File
@@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
u.Disabled = intBool(disabled)
u.TwoFactorEnabled = intBool(twoFactorEnabled)
u.CreatedAt = parseTime(created)
if err := a.attachUserAuthorization(ctx, &u); err != nil {
return nil, "", err
}
return &u, secret, nil
}
+149 -41
View File
@@ -3,13 +3,18 @@ package app
import "time"
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
CreatedAt time.Time `json:"createdAt"`
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
Protected bool `json:"protected"`
TwoFactorEnabled bool `json:"twoFactorEnabled"`
Permissions []string `json:"permissions"`
Limits PermissionLimits `json:"limits"`
PermissionGroupIDs []string `json:"permissionGroupIds"`
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
CreatedAt time.Time `json:"createdAt"`
}
type AdminUser struct {
@@ -52,11 +57,15 @@ type Alias struct {
}
type MailFolder struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
SortOrder int `json:"sortOrder"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
UIDValidity int64 `json:"uidValidity"`
UIDNext int64 `json:"uidNext"`
HighestModSeq int64 `json:"highestModseq"`
}
type MailLabel struct {
@@ -68,32 +77,46 @@ type MailLabel struct {
}
type MailMessage struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
ID string `json:"id"`
MailboxID string `json:"mailboxId,omitempty"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
OwnerEmail string `json:"ownerEmail,omitempty"`
RecipientAddr string `json:"recipientAddress,omitempty"`
FolderID string `json:"folderId"`
Folder string `json:"folder"`
MessageUID string `json:"messageUid"`
IMAPUID int64 `json:"imapUid"`
IMAPModSeq int64 `json:"imapModseq"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
From string `json:"from"`
FromName string `json:"fromName,omitempty"`
To []string `json:"to"`
CC []string `json:"cc"`
BCC []string `json:"bcc,omitempty"`
SentAt time.Time `json:"sentAt"`
ReceivedAt time.Time `json:"receivedAt"`
Snippet string `json:"snippet"`
BodyText string `json:"bodyText,omitempty"`
BodyHTML string `json:"bodyHtml,omitempty"`
IsRead bool `json:"isRead"`
IsStarred bool `json:"isStarred"`
HasAttachments bool `json:"hasAttachments"`
SizeBytes int64 `json:"sizeBytes"`
Labels []MailLabel `json:"labels,omitempty"`
Attachments []Attachment `json:"attachments,omitempty"`
Authentication MailAuthentication `json:"authentication"`
SendQueueID string `json:"sendQueueId,omitempty"`
SendQueueStatus string `json:"sendQueueStatus,omitempty"`
ExternalAccountID string `json:"externalAccountId,omitempty"`
}
type MailAuthentication struct {
AuthenticationResults string `json:"authenticationResults"`
ReceivedSPF string `json:"receivedSpf"`
SPF string `json:"spf"`
DKIM string `json:"dkim"`
DMARC string `json:"dmarc"`
}
type Attachment struct {
@@ -163,9 +186,11 @@ type MailRule struct {
}
type MailRuleCondition struct {
Field string `json:"field"`
Operator string `json:"operator"`
Value string `json:"value"`
Field string `json:"field,omitempty"`
Operator string `json:"operator,omitempty"`
Value string `json:"value,omitempty"`
MatchMode string `json:"matchMode,omitempty"`
Conditions []MailRuleCondition `json:"conditions,omitempty"`
}
type MailRuleAction struct {
@@ -188,7 +213,10 @@ type MailStats struct {
UnreadMessages int64 `json:"unreadMessages"`
StarredMessages int64 `json:"starredMessages"`
AttachmentCount int64 `json:"attachmentCount"`
AttachmentBytes int64 `json:"attachmentBytes"`
StorageBytes int64 `json:"storageBytes"`
QuotaBytes int64 `json:"quotaBytes"`
QuotaUsedPct float64 `json:"quotaUsedPct"`
ByFolder []MailStatsFolderCount `json:"byFolder"`
}
@@ -199,3 +227,83 @@ type MailStatsFolderCount struct {
Unread int64 `json:"unread"`
Bytes int64 `json:"bytes"`
}
type ExternalIMAPAccount struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
MailboxID string `json:"mailboxId"`
Name string `json:"name"`
Host string `json:"host"`
Port int `json:"port"`
TLSMode string `json:"tlsMode"`
Username string `json:"username"`
AuthMode string `json:"authMode"`
OAuthProvider string `json:"oauthProvider,omitempty"`
OAuthEmail string `json:"oauthEmail,omitempty"`
OAuthConfigured bool `json:"oauthConfigured,omitempty"`
StorageMode string `json:"storageMode"`
SyncReadState bool `json:"syncReadState"`
Enabled bool `json:"enabled"`
LastSyncAt *time.Time `json:"lastSyncAt,omitempty"`
LastStatus string `json:"lastStatus"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ExternalIMAPFolder struct {
Name string `json:"name"`
Role string `json:"role"`
UnreadCount int `json:"unreadCount"`
TotalCount int `json:"totalCount"`
}
type ExternalIMAPSyncRun struct {
ID string `json:"id"`
AccountID string `json:"accountId"`
Folder string `json:"folder,omitempty"`
Status string `json:"status"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"startedAt"`
FinishedAt *time.Time `json:"finishedAt,omitempty"`
}
type SendQueueEntry struct {
ID string `json:"id"`
MailboxID string `json:"mailboxId"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId"`
Subject string `json:"subject"`
Source string `json:"source"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Status string `json:"status"`
AttemptCount int `json:"attemptCount"`
MaxAttempts int `json:"maxAttempts"`
NextAttemptAt time.Time `json:"nextAttemptAt"`
LastError string `json:"lastError"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
DeliveredAt *time.Time `json:"deliveredAt,omitempty"`
}
type SendAuditEvent struct {
ID string `json:"id"`
QueueID string `json:"queueId"`
MailboxID string `json:"mailboxId"`
MailboxAddress string `json:"mailboxAddress,omitempty"`
SentMessageID string `json:"sentMessageId"`
MessageID string `json:"messageId,omitempty"`
Source string `json:"source"`
Event string `json:"event"`
Status string `json:"status"`
MailFrom string `json:"mailFrom"`
HeaderFrom string `json:"headerFrom"`
Recipients []string `json:"recipients"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
+78 -2
View File
@@ -22,7 +22,21 @@ type HTMLPolicy struct{ policy *bluemonday.Policy }
func NewHTMLPolicy() *HTMLPolicy {
p := bluemonday.UGCPolicy()
p.AllowAttrs("style").OnElements("p", "span", "div", "table", "td", "th")
p.AllowElements("html", "head", "body", "center", "font")
p.AllowAttrs("style").Globally()
p.AllowAttrs("class").Matching(bluemonday.SpaceSeparatedTokens).Globally()
p.AllowAttrs("align", "valign").Matching(bluemonday.Paragraph).Globally()
p.AllowAttrs("width", "height").Matching(bluemonday.NumberOrPercent).Globally()
p.AllowAttrs("bgcolor", "color").Matching(regexp.MustCompile(`(?i)^#[0-9a-f]{3,8}$|^[a-z][a-z0-9 -]{0,31}$`)).Globally()
p.AllowAttrs("border", "cellpadding", "cellspacing").Matching(bluemonday.Number).OnElements("table")
p.AllowStyles(
"background", "background-color", "background-image", "border", "border-collapse", "border-color",
"border-radius", "border-spacing", "border-style", "border-width", "box-shadow", "color", "display",
"font", "font-family", "font-size", "font-style", "font-weight", "height", "letter-spacing",
"line-height", "margin", "margin-bottom", "margin-left", "margin-right", "margin-top", "max-width",
"min-width", "opacity", "padding", "padding-bottom", "padding-left", "padding-right", "padding-top",
"text-align", "text-decoration", "text-transform", "vertical-align", "white-space", "width",
).MatchingHandler(safeEmailCSSValue).Globally()
return &HTMLPolicy{policy: p}
}
@@ -30,7 +44,68 @@ func (p *HTMLPolicy) Sanitize(s string) string {
if p == nil || p.policy == nil {
return s
}
return p.policy.Sanitize(s)
styles, withoutStyles := extractSafeEmailStyles(s)
clean := p.policy.Sanitize(withoutStyles)
if len(styles) == 0 {
return clean
}
return strings.Join(styles, "") + clean
}
var emailStyleTagRe = regexp.MustCompile(`(?is)<style\b([^>]*)>(.*?)</style>`)
var htmlNonContentTagRe = regexp.MustCompile(`(?is)<(style|script|head|title|noscript)\b[^>]*>.*?</\s*(style|script|head|title|noscript)\s*>`)
func extractSafeEmailStyles(value string) ([]string, string) {
styles := []string{}
withoutStyles := emailStyleTagRe.ReplaceAllStringFunc(value, func(tag string) string {
match := emailStyleTagRe.FindStringSubmatch(tag)
if len(match) != 3 {
return ""
}
attrs, css := match[1], strings.TrimSpace(match[2])
if !safeEmailStyleAttrs(attrs) || !safeEmailCSSBlock(css) {
return ""
}
styles = append(styles, `<style type="text/css">`+css+`</style>`)
return ""
})
return styles, withoutStyles
}
func safeEmailStyleAttrs(attrs string) bool {
attrs = strings.ToLower(strings.TrimSpace(attrs))
if attrs == "" {
return true
}
return regexp.MustCompile(`^\s*type\s*=\s*["']?text/css["']?\s*$`).MatchString(attrs)
}
func safeEmailCSSBlock(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 50000 {
return false
}
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
for _, token := range unsafe {
if strings.Contains(value, token) {
return false
}
}
return true
}
func safeEmailCSSValue(value string) bool {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" || len(value) > 512 {
return false
}
unsafe := []string{"expression", "javascript:", "vbscript:", "data:", "behavior", "-moz-binding", "@import", "</", "url("}
for _, token := range unsafe {
if strings.Contains(value, token) {
return false
}
}
return true
}
func newID(prefix string) string {
@@ -167,6 +242,7 @@ func snippetFrom(text, html string) string {
}
func stripTags(s string) string {
s = htmlNonContentTagRe.ReplaceAllString(s, " ")
var b strings.Builder
inTag := false
for _, r := range s {
+2 -1
View File
@@ -1,11 +1,12 @@
import React from "react"
import { Navigate } from "react-router-dom"
import { useMe } from "@/hooks/use-me"
import { hasAdminAccess } from "@/lib/permissions"
export function AdminOnly({ children }: { children: React.ReactNode }) {
const me = useMe()
if (me.isLoading) return null
if (!me.data?.user) return <Navigate to="/login" replace />
if (me.data.user.role !== "admin") return <Navigate to="/" replace />
if (!hasAdminAccess(me.data.user)) return <Navigate to="/" replace />
return <>{children}</>
}
+19 -15
View File
@@ -1,12 +1,14 @@
import * as React from "react"
import { Outlet, Link, useLocation } from "react-router-dom"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, Users } from "lucide-react"
import { BarChart3, Copy, Globe2, Inbox, LogOut, Mail, Mailbox, Settings, ShieldCheck, Users } from "lucide-react"
import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { AuthGuard } from "@/components/auth-guard"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { hasAnyPermission } from "@/lib/permissions"
import type { PermissionKey } from "@/lib/api-types"
import {
Sidebar,
SidebarContent,
@@ -24,14 +26,15 @@ import {
useSidebar,
} from "@/components/ui/sidebar"
const adminSections = [
{ key: "overview", label: "概览", icon: <BarChart3 /> },
{ key: "users", label: "用户", icon: <Users /> },
{ key: "domains", label: "域名", icon: <Globe2 /> },
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox /> },
{ key: "aliases", label: "别名转发", icon: <Copy /> },
{ key: "messages", label: "全部邮件", icon: <Inbox /> },
{ key: "settings", label: "系统设置", icon: <Settings /> },
const adminSections: { key: string; label: string; icon: React.ReactNode; permissions: PermissionKey[] }[] = [
{ key: "overview", label: "概览", icon: <BarChart3 />, permissions: ["admin.overview.view"] },
{ key: "users", label: "用户", icon: <Users />, permissions: ["admin.users.view"] },
{ key: "permissionGroups", label: "权限组", icon: <ShieldCheck />, permissions: ["admin.permission_groups.view"] },
{ key: "domains", label: "域名", icon: <Globe2 />, permissions: ["admin.domains.view", "admin.dns.view"] },
{ key: "mailboxes", label: "邮箱账号", icon: <Mailbox />, permissions: ["admin.mailboxes.view"] },
{ key: "aliases", label: "别名转发", icon: <Copy />, permissions: ["admin.aliases.view"] },
{ key: "messages", label: "全部邮件", icon: <Inbox />, permissions: ["admin.messages.view"] },
{ key: "settings", label: "系统设置", icon: <Settings />, permissions: ["admin.settings.view", "admin.templates.view"] },
]
export function ProtectedLayout() {
@@ -52,6 +55,7 @@ function ProtectedContent() {
const isProfileRoute = location.pathname.startsWith("/profile")
const isAdminRoute = location.pathname.startsWith("/admin")
const adminSection = new URLSearchParams(location.search).get("section") || "overview"
const visibleAdminSections = adminSections.filter((item) => hasAnyPermission(user, item.permissions))
if (isMailRoute || isProfileRoute) {
return <Outlet />
@@ -77,11 +81,11 @@ function ProtectedContent() {
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
{user.role === "admin" && isAdminRoute && (
{isAdminRoute && visibleAdminSections.length > 0 && (
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu>
<AdminSectionItems activeSection={adminSection} />
<AdminSectionItems activeSection={adminSection} sections={visibleAdminSections} />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
@@ -102,7 +106,7 @@ function ProtectedContent() {
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "secondary"} className="ml-auto text-[10px]">
{user.role === "admin" ? "管理员" : "用户"}
{user.role === "admin" ? "超级管理员" : "普通用户"}
</Badge>
</Link>
</SidebarMenuButton>
@@ -121,7 +125,7 @@ function ProtectedContent() {
<div className="flex h-12 items-center gap-3 border-b bg-background px-3 md:hidden">
<SidebarTrigger aria-label="打开导航" />
<div className="min-w-0 flex-1 truncate text-sm font-semibold">
{isAdminRoute ? adminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
{isAdminRoute ? visibleAdminSections.find((item) => item.key === adminSection)?.label || "系统管理" : "LanQin Email"}
</div>
</div>
<Outlet />
@@ -131,13 +135,13 @@ function ProtectedContent() {
)
}
function AdminSectionItems({ activeSection }: { activeSection: string }) {
function AdminSectionItems({ activeSection, sections }: { activeSection: string; sections: typeof adminSections }) {
const { isMobile, setOpenMobile } = useSidebar()
function closeMobile() {
if (isMobile) setOpenMobile(false)
}
return adminSections.map((item) => (
return sections.map((item) => (
<SidebarMenuItem key={item.key}>
<SidebarMenuButton asChild isActive={activeSection === item.key} tooltip={item.label}>
<Link to={`/admin?section=${item.key}`} onClick={closeMobile}>
+5 -1
View File
@@ -48,7 +48,7 @@ const Toast = React.forwardRef<
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
className={cn(toastVariants({ variant }), props.onClick && "cursor-pointer", className)}
{...props}
/>
)
@@ -76,6 +76,10 @@ const ToastClose = React.forwardRef<
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
onClick={(event) => {
event.stopPropagation()
props.onClick?.(event)
}}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
+26
View File
@@ -162,3 +162,29 @@ vue-devtools-anchor,
[class*="vue-devtools"] {
display: none !important;
}
/* Label badge: hide delete button by default, show on hover (desktop) */
.label-badge-delete {
visibility: hidden;
width: 0;
padding: 0;
overflow: hidden;
transition: visibility 0.1s, width 0.1s, padding 0.1s, opacity 0.15s;
}
.label-badge:hover .label-badge-delete,
.label-badge:focus-within .label-badge-delete {
visibility: visible;
width: 1rem;
padding: 0;
opacity: 1 !important;
}
/* Touch devices: always show the delete button inside label badges */
@media (hover: none) and (pointer: coarse) {
.label-badge-delete {
visibility: visible;
width: 1rem;
padding: 0;
opacity: 1 !important;
}
}
+137 -7
View File
@@ -1,15 +1,70 @@
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }
export type PermissionKey =
| "mail.access"
| "mail.messages.read"
| "mail.messages.send"
| "mail.messages.drafts"
| "mail.messages.schedule"
| "mail.messages.organize"
| "mail.labels.manage"
| "mail.attachments.download"
| "mail.contacts.manage"
| "mail.signatures.manage"
| "mail.rules.manage"
| "mail.blocked_senders.manage"
| "mail.stats.view"
| "mail.mailboxes.apply"
| "admin.overview.view"
| "admin.users.view"
| "admin.users.create"
| "admin.users.update"
| "admin.users.delete"
| "admin.users.reset_password"
| "admin.permission_groups.view"
| "admin.permission_groups.create"
| "admin.permission_groups.update"
| "admin.permission_groups.delete"
| "admin.domains.view"
| "admin.domains.create"
| "admin.domains.update"
| "admin.domains.delete"
| "admin.dns.view"
| "admin.dns.check"
| "admin.mailboxes.view"
| "admin.mailboxes.create"
| "admin.mailboxes.update"
| "admin.mailboxes.delete"
| "admin.aliases.view"
| "admin.aliases.create"
| "admin.aliases.update"
| "admin.aliases.delete"
| "admin.messages.view"
| "admin.messages.read"
| "admin.messages.attachments"
| "admin.settings.view"
| "admin.settings.update"
| "admin.settings.test_smtp"
| "admin.templates.view"
| "admin.templates.update"
| "admin.templates.reset"
export type PermissionInfo = { key: PermissionKey; label: string; description: string; category: string }
export type PermissionLimits = { maxAttachmentMb: number; smtpDailyLimit: number; smtpMinuteLimit: number; imapMinuteLimit: number; pop3MinuteLimit: number }
export type PermissionGroupSummary = { id: string; name: string }
export type PermissionGroup = { id: string; name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits; system: boolean; userCount: number; createdAt: string; updatedAt: string }
export type User = { id: string; email: string; displayName: string; role: "admin" | "user"; disabled: boolean; protected: boolean; twoFactorEnabled: boolean; permissions: PermissionKey[]; limits: PermissionLimits; permissionGroupIds: string[]; permissionGroups: PermissionGroupSummary[]; createdAt: string }
export type AdminUser = User & { mailboxCount: number; mailboxes?: string[] }
export type AdminOverview = { users: number; activeUsers: number; domains: number; mailboxes: number; activeMailboxes: number; aliases: number; messages: number; unreadMessages: number; storageBytes: number }
export type Domain = { id: string; name: string; status: string; dkimSelector: string; dkimPublicKey?: string; dnsStatus: string; dnsCheckedAt?: string; createdAt: string }
export type Mailbox = { id: string; userId: string; userEmail?: string; domainId: string; localPart: string; address: string; displayName: string; quotaMb: number; status: string; createdAt: string }
export type Alias = { id: string; domainId: string; source: string; destination: string; enabled: boolean; createdAt: string }
export type MailFolder = { id: string; name: string; role: string; unreadCount: number; totalCount: number }
export type MailFolder = { id: string; name: string; role: string; sortOrder: number; unreadCount: number; totalCount: number; uidValidity: number; uidNext: number; highestModseq: number }
export type Attachment = { id: string; messageId: string; filename: string; contentType: string; sizeBytes: number; createdAt: string }
export type MailLabel = { id: string; mailboxId?: string; name: string; color: string; messageCount?: number }
export type MailMessage = {
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
id: string; mailboxId?: string; mailboxAddress?: string; ownerEmail?: string; recipientAddress?: string; folderId: string; folder: string; messageUid: string; imapUid: number; imapModseq: number; messageId: string; subject: string; from: string; fromName?: string; to: string[]; cc: string[]; bcc?: string[]; sentAt: string; receivedAt: string; snippet: string; bodyText?: string; bodyHtml?: string; isRead: boolean; isStarred: boolean; hasAttachments: boolean; sizeBytes: number; attachments?: Attachment[]
labels?: MailLabel[]
sendQueueId?: string
sendQueueStatus?: SendQueueStatus
externalAccountId?: string
}
export type DNSRecord = { type: string; name: string; value: string; ttl: number }
export type DNSCheckResult = { domain: string; status: string; checks: Record<string, { ok: boolean; message: string; found?: string[] }> }
@@ -18,15 +73,82 @@ export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc:
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
export type ScheduledSend = { id: string; mailboxId: string; draftId?: string; subject: string; to: string[]; snippet: string; sendAt: string; status: "pending" | "sending" | "sent" | "failed" | "cancelled"; error?: string; createdAt: string; updatedAt: string; sentAt?: string }
export type SendQueueStatus = "queued" | "sending" | "delivered" | "failed" | "canceled"
export type SendQueueItem = {
id: string
mailboxId: string
sentMessageId?: string
messageId?: string
mailFrom?: string
headerFrom?: string
subject: string
recipients: string[]
source: string
status: SendQueueStatus
attemptCount: number
maxAttempts: number
nextAttemptAt?: string
lastError?: string
error?: string
failureReason?: string
createdAt: string
updatedAt: string
deliveredAt?: string
}
export type SendQueueAuditEvent = {
id: string
queueId?: string
mailboxId?: string
mailboxAddress?: string
sentMessageId?: string
messageId?: string
source?: string
status?: SendQueueStatus
event?: string
eventType?: string
mailFrom?: string
headerFrom?: string
recipients?: string[]
message?: string
error?: string
attemptCount?: number
createdAt: string
}
export type Contact = { id: string; name: string; email: string; note: string; createdAt: string }
export type MailSignature = { id: string; mailboxId: string; name: string; content: string; isDefault: boolean; createdAt: string; updatedAt: string }
export type MailRuleCondition = { field: "from" | "to" | "subject" | "body"; operator: "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with"; value: string }
export type MailRuleConditionField = "from" | "to" | "cc" | "subject" | "body" | "attachment" | "size" | "date"
export type MailRuleConditionOperator = "contains" | "not-contains" | "equals" | "not-equals" | "starts-with" | "ends-with" | "gt" | "gte" | "lt" | "lte" | "before" | "after" | "on"
export type MailRuleCondition = { field?: MailRuleConditionField; operator?: MailRuleConditionOperator; value?: string; matchMode?: "all" | "any"; conditions?: MailRuleCondition[] }
export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; value?: string; labelId?: string }
export type MailRule = { id: string; mailboxId: string; name: string; matchMode: "all" | "any"; conditions: MailRuleCondition[]; actions: MailRuleAction[]; applyToExisting: boolean; stopProcessing: boolean; fromContains: string; subjectContains: string; action: "archive" | "trash" | "star" | "mark-read" | "label" | "move"; enabled: boolean; createdAt: string; appliedExistingCount?: number }
export type BlockedSender = { id: string; mailboxId: string; email: string; reason: string; createdAt: string }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; storageBytes: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type MailStats = { totalMessages: number; unreadMessages: number; starredMessages: number; attachmentCount: number; attachmentBytes: number; storageBytes: number; quotaBytes: number; quotaUsedPct: number; byFolder: { folder: string; role: string; count: number; unread: number; bytes: number }[] }
export type ExternalImapStorageMode = "local" | "remote"
export type ExternalImapTlsMode = "tls" | "starttls" | "plain"
export type ExternalImapAuthMode = "password" | "oauth2"
export type ExternalImapAccount = { id: string; mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; authMode: ExternalImapAuthMode; oauthProvider?: ExternalImapOAuthProvider; oauthEmail?: string; oauthConfigured?: boolean; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean; lastSyncAt?: string; lastStatus: string; lastError?: string; createdAt: string; updatedAt: string }
export type ExternalImapAccountPayload = { mailboxId: string; name: string; host: string; port: number; tlsMode: ExternalImapTlsMode; username: string; password?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
export type ExternalImapOAuthProvider = "gmail" | "outlook"
export type ExternalImapOAuthStartPayload = { mailboxId: string; name?: string; email?: string; storageMode: ExternalImapStorageMode; syncReadState: boolean; enabled: boolean }
export type ExternalImapFolder = { name: string; role: string; unreadCount: number; totalCount: number }
export type ExternalImapSyncRun = { id: string; accountId: string; folder?: string; status: string; imported: number; skipped: number; failed: number; error?: string; startedAt: string; finishedAt?: string }
export type MailTemplate = { key: string; name: string; subject: string; bodyText: string; bodyHtml: string; updatedAt: string }
export type MailboxApplyOptions = { enabled: boolean; domains: Domain[]; reservedPrefixes?: string[] }
export type MaildirSyncCounts = { filesScanned: number; imported: number; backfilled: number; cleaned: number; fileErrors: number }
export type MaildirSyncRun = { startedAt: string; finishedAt?: string; durationMs: number; status: "running" | "success" | "partial" | "error"; error?: string; counts: MaildirSyncCounts }
export type MaildirSyncHealth = {
configured: boolean
enabled: boolean
root: string
scanSeconds: number
workerStarted: boolean
running: boolean
lastRun?: MaildirSyncRun
lastError?: string
nextRunAt?: string
recentErrors: string[]
summary: MaildirSyncCounts
}
export type SystemSettings = {
publicHostname: string
publicBaseUrl: string
@@ -50,10 +172,18 @@ export type SystemSettings = {
userMailboxApplyEnabled: boolean
userMailboxDomainIds: string[]
reservedMailboxPrefixes: string
externalImapEnabled: boolean
externalImapSecretSet: boolean
externalImapSyncSeconds: number
externalImapAllowPrivateHosts: boolean
externalImapGmailClientId: string
externalImapGmailClientSecretSet: boolean
externalImapOutlookClientId: string
externalImapOutlookClientSecretSet: boolean
}
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet"> & { smtpPassword: string; turnstileSecretKey: string }
export type SystemSettingsPayload = Omit<SystemSettings, "smtpPasswordSet" | "turnstileSecretSet" | "externalImapSecretSet" | "externalImapGmailClientSecretSet" | "externalImapOutlookClientSecretSet"> & { smtpPassword: string; turnstileSecretKey: string; externalImapSecretKey: string; externalImapGmailClientSecret: string; externalImapOutlookClientSecret: string }
export type PublicDomain = { id: string; name: string }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; mailboxDomains?: PublicDomain[] }
export type PublicSettings = { openRegistration: boolean; turnstileEnabled: boolean; turnstileSiteKey: string; publicHostname: string; mailAutoRefresh: boolean; mailRefreshMs: number; externalImapEnabled: boolean; mailboxDomains?: PublicDomain[] }
export type LoginPayload = { email?: string; password?: string; turnstileToken?: string; challengeToken?: string; twoFactorCode?: string }
export type LoginResponse = { user?: User; twoFactorRequired?: boolean; challengeToken?: string }
export type RegisterPayload = { email: string; displayName: string; password: string; turnstileToken?: string; domainId?: string; localPart?: string }
+62 -3
View File
@@ -1,4 +1,4 @@
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, DNSRecord, DNSCheckResult, ListResponse, SendPayload, DraftPayload, ScheduleSendPayload, ScheduledSend, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
export * from "./api-types"
const REQUEST_TIMEOUT_MS = 15_000
@@ -61,10 +61,24 @@ export const api = {
cleanupMail: (payload: { mailboxId: string; target: "empty-trash" | "empty-spam" | "archive-read-inbox" }) => request<{ ok: boolean; affected: number }>("/api/me/cleanup", { method: "POST", body: JSON.stringify(payload) }),
mailboxApplyOptions: () => request<MailboxApplyOptions>("/api/me/mailbox-apply-options"),
applyMailbox: (payload: { domainId: string; localPart: string; displayName: string }) => request<Mailbox>("/api/me/mailboxes/apply", { method: "POST", body: JSON.stringify(payload) }),
externalImapAccounts: (mailboxId?: string) => request<ListResponse<ExternalImapAccount>>(`/api/me/external-imap-accounts${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createExternalImapAccount: (payload: ExternalImapAccountPayload) => request<ExternalImapAccount>("/api/me/external-imap-accounts", { method: "POST", body: JSON.stringify(payload) }),
updateExternalImapAccount: (id: string, payload: ExternalImapAccountPayload) => request<ExternalImapAccount>(`/api/me/external-imap-accounts/${id}`, { method: "POST", body: JSON.stringify(payload) }),
deleteExternalImapAccount: (id: string) => request<{ ok: boolean }>(`/api/me/external-imap-accounts/${id}`, { method: "DELETE" }),
startExternalImapOAuth: (provider: ExternalImapOAuthProvider, payload: ExternalImapOAuthStartPayload) => request<{ url: string }>(`/api/me/external-imap-oauth/${provider}/start`, { method: "POST", body: JSON.stringify(payload) }),
testExternalImapAccount: (id: string) => request<{ ok: boolean; folders: number }>(`/api/me/external-imap-accounts/${id}/test`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
externalImapSyncRuns: (id: string) => request<ListResponse<ExternalImapSyncRun>>(`/api/me/external-imap-accounts/${id}/runs`),
syncExternalImapAccount: (id: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
syncExternalImapFolder: (id: string, folder: string) => request<ExternalImapSyncRun>(`/api/me/external-imap-accounts/${id}/sync-folder`, { method: "POST", body: JSON.stringify({ folder }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
adminOverview: () => request<AdminOverview>("/api/admin/overview"),
users: () => request<ListResponse<AdminUser>>("/api/admin/users"),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
createPermissionGroup: (payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>("/api/admin/permission-groups", { method: "POST", body: JSON.stringify(payload) }),
updatePermissionGroup: (id: string, payload: { name: string; description: string; permissions: PermissionKey[]; limits: PermissionLimits }) => request<PermissionGroup>(`/api/admin/permission-groups/${id}`, { method: "POST", body: JSON.stringify(payload) }),
defaultPermissionLimits: () => request<PermissionLimits>("/api/admin/permission-limits/defaults"),
deletePermissionGroup: (id: string) => request<{ ok: boolean }>(`/api/admin/permission-groups/${id}`, { method: "DELETE" }),
createUser: (payload: { email: string; displayName: string; role: "admin" | "user"; password: string; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>("/api/admin/users", { method: "POST", body: JSON.stringify(payload) }),
updateUser: (id: string, payload: { displayName: string; role: "admin" | "user"; disabled: boolean; permissionGroupIds?: string[] }) => request<AdminUser>(`/api/admin/users/${id}`, { method: "POST", body: JSON.stringify(payload) }),
resetUserPassword: (id: string, password: string) => request<{ ok: boolean }>(`/api/admin/users/${id}/password`, { method: "POST", body: JSON.stringify({ password }) }),
deleteUser: (id: string) => request<{ ok: boolean }>(`/api/admin/users/${id}`, { method: "DELETE" }),
domains: () => request<ListResponse<Domain>>("/api/admin/domains"),
@@ -89,7 +103,19 @@ export const api = {
return request<ListResponse<MailMessage>>(`/api/admin/messages${suffix ? `?${suffix}` : ""}`)
},
adminMessage: (id: string) => request<MailMessage>(`/api/admin/messages/${id}`),
adminSendAudit: (params: { mailboxId?: string; messageId?: string; event?: string; from?: string; to?: string; cursor?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.messageId) query.set("messageId", params.messageId)
if (params.event) query.set("event", params.event)
if (params.from) query.set("from", params.from)
if (params.to) query.set("to", params.to)
if (params.cursor) query.set("cursor", params.cursor)
const suffix = query.toString()
return request<ListResponse<SendQueueAuditEvent>>(`/api/admin/send-audit${suffix ? `?${suffix}` : ""}`)
},
systemSettings: () => request<SystemSettings>("/api/admin/settings"),
maildirSyncHealth: () => request<MaildirSyncHealth>("/api/admin/maildir-sync/health"),
updateSystemSettings: (payload: SystemSettingsPayload) => request<SystemSettings>("/api/admin/settings", { method: "POST", body: JSON.stringify(payload) }),
testSmtp: (to: string) => request<{ ok: boolean }>("/api/admin/settings/test-smtp", { method: "POST", body: JSON.stringify({ to }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
mailTemplates: () => request<ListResponse<MailTemplate>>("/api/admin/mail-templates"),
@@ -98,12 +124,30 @@ export const api = {
dnsRecords: (domainId: string) => request<{ items: DNSRecord[] }>(`/api/admin/domains/${domainId}/dns-records`),
checkDns: (domainId: string) => request<DNSCheckResult>(`/api/admin/domains/${domainId}/check-dns`, { method: "POST" }),
myMailboxes: () => request<ListResponse<Mailbox>>("/api/mail/mailboxes"),
externalMailAccounts: () => request<ListResponse<ExternalImapAccount>>("/api/mail/external-accounts"),
externalFolders: (id: string) => request<ListResponse<ExternalImapFolder>>(`/api/mail/external-accounts/${id}/folders`),
externalMessages: (id: string, folder: string, cursor = "", q = "") => {
const params = new URLSearchParams({ folder, cursor, q })
return request<ListResponse<MailMessage>>(`/api/mail/external-accounts/${id}/messages?${params.toString()}`)
},
externalMessage: (id: string, remoteId: string) => request<MailMessage>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}`),
markExternalRead: (id: string, remoteId: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
folders: (mailboxId?: string) => request<ListResponse<MailFolder>>(`/api/mail/folders${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createFolder: (payload: { mailboxId?: string; name: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailFolder>(`/api/mail/folders${query}`, { method: "POST", body: JSON.stringify({ name: payload.name }) })
},
reorderFolders: (payload: { mailboxId?: string; folderIds: string[]; folders?: { id: string; sortOrder: number }[] }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<{ ok: boolean }>(`/api/mail/folders/reorder${query}`, { method: "POST", body: JSON.stringify(payload.folders ? { folders: payload.folders } : { folderIds: payload.folderIds }) })
},
deleteFolder: (id: string, mailboxId?: string) => request<{ ok: boolean; moved: number }>(`/api/mail/folders/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
labels: (mailboxId?: string) => request<ListResponse<MailLabel>>(`/api/mail/labels${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
createLabel: (payload: { mailboxId?: string; name: string; color?: string }) => {
const query = payload.mailboxId ? `?mailboxId=${encodeURIComponent(payload.mailboxId)}` : ""
return request<MailLabel>(`/api/mail/labels${query}`, { method: "POST", body: JSON.stringify({ name: payload.name, color: payload.color || "" }) })
},
deleteLabel: (id: string, mailboxId?: string) => request<{ labels: MailLabel[] }>(`/api/mail/labels/${id}${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`, { method: "DELETE" }),
messages: (folder: string, q = "", cursor = "", mailboxId?: string) => {
const params = new URLSearchParams({ folder, q, cursor })
if (mailboxId) params.set("mailboxId", mailboxId)
@@ -124,6 +168,21 @@ export const api = {
scheduledSends: (mailboxId?: string) => request<ListResponse<ScheduledSend>>(`/api/mail/scheduled-sends${mailboxId ? `?mailboxId=${encodeURIComponent(mailboxId)}` : ""}`),
scheduleSend: (payload: ScheduleSendPayload) => request<ScheduledSend>("/api/mail/schedule-send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelScheduledSend: (id: string) => request<{ ok: boolean }>(`/api/mail/schedule-send/${id}`, { method: "DELETE" }),
sendQueue: (params: { mailboxId?: string; status?: SendQueueStatus | "all"; cursor?: string; messageId?: string; recipient?: string; from?: string; to?: string } = {}) => {
const query = new URLSearchParams()
if (params.mailboxId) query.set("mailboxId", params.mailboxId)
if (params.status && params.status !== "all") query.set("status", params.status)
if (params.cursor) query.set("cursor", params.cursor)
if (params.messageId) query.set("messageId", params.messageId)
if (params.recipient) query.set("recipient", params.recipient)
if (params.from) query.set("from", params.from)
if (params.to) query.set("to", params.to)
const suffix = query.toString()
return request<ListResponse<SendQueueItem>>(`/api/mail/send-queue${suffix ? `?${suffix}` : ""}`)
},
sendQueueAudit: (id: string) => request<ListResponse<SendQueueAuditEvent>>(`/api/mail/send-queue/${id}/audit`),
retrySendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}/retry`, { method: "POST", timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
cancelSendQueue: (id: string) => request<SendQueueItem>(`/api/mail/send-queue/${id}`, { method: "DELETE" }),
saveDraft: (payload: DraftPayload, id?: string) => request<MailMessage>(id ? `/api/mail/drafts/${id}` : "/api/mail/drafts", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
deleteDraft: (id: string) => request<{ ok: boolean }>(`/api/mail/drafts/${id}`, { method: "DELETE" }),
markRead: (id: string, read: boolean) => request<{ ok: boolean }>(`/api/mail/messages/${id}/mark-read`, { method: "POST", body: JSON.stringify({ read }) }),
+74
View File
@@ -0,0 +1,74 @@
import type { PermissionKey, User } from "@/lib/api-types"
export const MAIL_PERMISSIONS: PermissionKey[] = [
"mail.access",
"mail.messages.read",
"mail.messages.send",
"mail.messages.drafts",
"mail.messages.schedule",
"mail.messages.organize",
"mail.labels.manage",
"mail.attachments.download",
"mail.contacts.manage",
"mail.signatures.manage",
"mail.rules.manage",
"mail.blocked_senders.manage",
"mail.stats.view",
"mail.mailboxes.apply",
]
export const ADMIN_PERMISSIONS: PermissionKey[] = [
"admin.overview.view",
"admin.users.view",
"admin.users.create",
"admin.users.update",
"admin.users.delete",
"admin.users.reset_password",
"admin.permission_groups.view",
"admin.permission_groups.create",
"admin.permission_groups.update",
"admin.permission_groups.delete",
"admin.domains.view",
"admin.domains.create",
"admin.domains.update",
"admin.domains.delete",
"admin.dns.view",
"admin.dns.check",
"admin.mailboxes.view",
"admin.mailboxes.create",
"admin.mailboxes.update",
"admin.mailboxes.delete",
"admin.aliases.view",
"admin.aliases.create",
"admin.aliases.update",
"admin.aliases.delete",
"admin.messages.view",
"admin.messages.read",
"admin.messages.attachments",
"admin.settings.view",
"admin.settings.update",
"admin.settings.test_smtp",
"admin.templates.view",
"admin.templates.update",
"admin.templates.reset",
]
export function hasPermission(user: User | undefined | null, permission: PermissionKey) {
if (!user) return false
if (user.role === "admin") return true
return (user.permissions || []).includes(permission)
}
export function hasAnyPermission(user: User | undefined | null, permissions: PermissionKey[]) {
if (!user) return false
if (user.role === "admin") return true
return permissions.some((permission) => (user.permissions || []).includes(permission))
}
export function hasAdminAccess(user: User | undefined | null) {
return hasAnyPermission(user, ADMIN_PERMISSIONS)
}
export function hasMailAccess(user: User | undefined | null) {
return hasPermission(user, "mail.access")
}
+24
View File
@@ -84,3 +84,27 @@ export function decodeMimeHeader(value: string): string {
}
})
}
export interface LabelColorStyle {
/** CSS background-color value (HSL string) */
backgroundColor: string
/** CSS color value for text — always high contrast against the background */
color: string
}
/**
* Generate a deterministic, high-contrast color pair for a label name.
* Uses FNV-1a hash with position-dependent mixing HSL mapping.
* Text is always white (lightness 45 % guarantees dark-enough background).
*/
export function generateLabelColor(name: string): LabelColorStyle {
// FNV-1a with 32-bit offset basis and prime
let h = 0x811c9dc5
for (let i = 0; i < name.length; i++) {
h ^= name.charCodeAt(i) + i * 0x01000193 // position-dependent seed
h = Math.imul(h, 0x01000193)
h ^= h >>> 16
}
const hue = ((h >>> 0) % 360 + 360) % 360
return { backgroundColor: `hsl(${hue}, 70%, 45%)`, color: "#ffffff" }
}
File diff suppressed because it is too large Load Diff
+1583 -221
View File
File diff suppressed because it is too large Load Diff
+490 -40
View File
@@ -2,9 +2,9 @@ import * as React from "react"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import type { ImperativePanelHandle } from "react-resizable-panels"
import { useNavigate, useSearchParams } from "react-router-dom"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { ArrowLeft, BarChart3, Ban, Contact, Copy, Info, KeyRound, Laptop, Link2, LogOut, Mail, MailCheck, MailX, Moon, PanelLeftClose, PanelLeftOpen, PencilLine, Plus, RefreshCcw, Settings, ShieldCheck, SlidersHorizontal, Sun, Trash2, X } from "lucide-react"
import { QRCodeSVG } from "qrcode.react"
import { api, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats } from "@/lib/api"
import { api, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapStorageMode, ExternalImapSyncRun, ExternalImapTlsMode, MailLabel, MailRule, MailRuleAction, MailRuleCondition, Mailbox, MailboxApplyOptions, MailSignature, MailStats, PermissionLimits } from "@/lib/api"
import { cn, formatBytes } from "@/lib/utils"
import { applyTheme, getInitialTheme } from "@/lib/theme"
import { DisplayMode, useDisplayMode } from "@/lib/display-mode"
@@ -12,6 +12,7 @@ import { useMe } from "@/hooks/use-me"
import { useLogout } from "@/hooks/use-logout"
import { useIsMobile } from "@/hooks/use-mobile"
import { validatePasswordConfirm } from "@/lib/validation"
import { hasPermission } from "@/lib/permissions"
import { Button } from "@/components/ui/button"
import { PasswordInput } from "@/components/ui/password-input"
import { Input } from "@/components/ui/input"
@@ -63,23 +64,56 @@ export function ProfilePage() {
const [blockedMailboxId, setBlockedMailboxId] = React.useState("all")
const [ruleDialogOpen, setRuleDialogOpen] = React.useState(false)
const [mobileSidebarOpen, setMobileSidebarOpen] = React.useState(false)
const [externalRunAccountId, setExternalRunAccountId] = React.useState("")
const isMobile = useIsMobile()
const themeMountedRef = React.useRef(false)
const rawTab = params.get("tab") as Tab | null
const tab: Tab = rawTab && tabKeys.includes(rawTab) ? rawTab : "profile"
const user = me.data?.user
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes })
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions })
const canAccessMail = hasPermission(user, "mail.access")
const canReadMail = hasPermission(user, "mail.messages.read")
const canOrganizeMail = hasPermission(user, "mail.messages.organize")
const canManageLabels = hasPermission(user, "mail.labels.manage")
const canManageContacts = hasPermission(user, "mail.contacts.manage")
const canManageSignatures = hasPermission(user, "mail.signatures.manage")
const canManageRules = hasPermission(user, "mail.rules.manage")
const canManageBlocked = hasPermission(user, "mail.blocked_senders.manage")
const canViewStats = hasPermission(user, "mail.stats.view")
const canApplyMailbox = hasPermission(user, "mail.mailboxes.apply")
const visibleTabKeys = tabKeys.filter((key) => {
if (key === "profile") return true
if (key === "mailboxes") return canAccessMail || canApplyMailbox
if (key === "clients") return canAccessMail
if (key === "signatures") return canManageSignatures
if (key === "contacts") return canManageContacts
if (key === "cleanup") return canOrganizeMail
if (key === "rules") return canManageRules
if (key === "blocked") return canManageBlocked
if (key === "stats") return canViewStats
return false
})
const tab: Tab = rawTab && visibleTabKeys.includes(rawTab) ? rawTab : "profile"
const mailboxes = useQuery({ queryKey: ["mailboxes", "mine"], queryFn: api.myMailboxes, enabled: canAccessMail })
const mailboxApplyOptions = useQuery({ queryKey: ["mailbox-apply-options"], queryFn: api.mailboxApplyOptions, enabled: canApplyMailbox })
const publicSettings = useQuery({ queryKey: ["public-settings"], queryFn: api.publicSettings })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts })
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders })
const contacts = useQuery({ queryKey: ["contacts"], queryFn: api.contacts, enabled: canManageContacts })
const signatures = useQuery({ queryKey: ["signatures"], queryFn: api.signatures, enabled: canManageSignatures })
const rules = useQuery({ queryKey: ["rules"], queryFn: api.rules, enabled: canManageRules })
const blocked = useQuery({ queryKey: ["blocked-senders"], queryFn: api.blockedSenders, enabled: canManageBlocked })
const selectedMailbox = React.useMemo(() => mailboxes.data?.items.find((m) => m.id === mailboxId), [mailboxes.data?.items, mailboxId])
const activeMailboxId = selectedMailbox?.id || ""
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId })
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId })
const externalImapEnabled = publicSettings.data?.externalImapEnabled ?? false
const externalImapAccounts = useQuery({ queryKey: ["external-imap-accounts", activeMailboxId], queryFn: () => api.externalImapAccounts(activeMailboxId), enabled: !!activeMailboxId && canAccessMail && externalImapEnabled })
React.useEffect(() => {
if (!externalRunAccountId) return
if (externalImapAccounts.data?.items.some((item) => item.id === externalRunAccountId)) return
setExternalRunAccountId("")
}, [externalImapAccounts.data?.items, externalRunAccountId])
const selectedExternalRunAccount = externalImapAccounts.data?.items.find((item) => item.id === externalRunAccountId)
const externalRunFolders = useQuery({ queryKey: ["external-imap-run-folders", externalRunAccountId], queryFn: () => api.externalFolders(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const externalSyncRuns = useQuery({ queryKey: ["external-imap-sync-runs", externalRunAccountId], queryFn: () => api.externalImapSyncRuns(externalRunAccountId), enabled: !!externalRunAccountId && !!selectedExternalRunAccount && canAccessMail && externalImapEnabled })
const ruleLabels = useQuery({ queryKey: ["labels", "rules", activeMailboxId], queryFn: () => api.labels(activeMailboxId), enabled: !!activeMailboxId && canManageRules && (canReadMail || canManageLabels) })
const stats = useQuery({ queryKey: ["mail-stats", activeMailboxId], queryFn: () => api.mailStats(activeMailboxId), enabled: !!activeMailboxId && canViewStats })
const profile = useMutation({
mutationFn: (form: FormData) => api.updateProfile({ displayName: String(form.get("displayName") || "") }),
@@ -175,6 +209,41 @@ export function ProfilePage() {
},
onError: (error) => toast({ title: "申请失败", description: error.message }),
})
const createExternalImap = useMutation({
mutationFn: api.createExternalImapAccount,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已保存" }) },
onError: (error) => toast({ title: "保存失败", description: error.message }),
})
const updateExternalImap = useMutation({
mutationFn: ({ id, payload }: { id: string; payload: ExternalImapAccountPayload }) => api.updateExternalImapAccount(id, payload),
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已更新" }) },
onError: (error) => toast({ title: "更新失败", description: error.message }),
})
const deleteExternalImap = useMutation({
mutationFn: api.deleteExternalImapAccount,
onSuccess: () => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["mail-external-accounts"] }); toast({ title: "外部 IMAP 已删除" }) },
onError: (error) => toast({ title: "删除失败", description: error.message }),
})
const testExternalImap = useMutation({
mutationFn: api.testExternalImapAccount,
onSuccess: (res) => toast({ title: `连接成功,发现 ${res.folders} 个文件夹` }),
onError: (error) => toast({ title: "连接失败", description: error.message }),
})
const syncExternalImap = useMutation({
mutationFn: api.syncExternalImapAccount,
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
onError: (error) => toast({ title: "同步失败", description: error.message }),
})
const syncExternalImapFolder = useMutation({
mutationFn: ({ id, folder }: { id: string; folder: string }) => api.syncExternalImapFolder(id, folder),
onSuccess: (run) => { qc.invalidateQueries({ queryKey: ["external-imap-accounts"] }); qc.invalidateQueries({ queryKey: ["external-imap-sync-runs"] }); qc.invalidateQueries({ queryKey: ["folders"] }); qc.invalidateQueries({ queryKey: ["messages"] }); toast({ title: `${run.folder || "文件夹"} 同步完成:导入 ${run.imported},跳过 ${run.skipped}` }) },
onError: (error) => toast({ title: "同步失败", description: error.message }),
})
const startExternalOAuth = useMutation({
mutationFn: ({ provider, mailboxId, email, storageMode }: { provider: ExternalImapOAuthProvider; mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => api.startExternalImapOAuth(provider, { mailboxId, email, storageMode, syncReadState: true, enabled: true }),
onSuccess: (res) => { window.location.href = res.url },
onError: (error) => toast({ title: "授权失败", description: error.message }),
})
React.useEffect(() => {
if (!mailboxes.isSuccess) return
@@ -191,7 +260,11 @@ export function ProfilePage() {
const logout = useLogout()
async function copy(text: string) { await navigator.clipboard.writeText(text); toast({ title: "已复制" }) }
function setTab(next: Tab) { setParams(next === "profile" ? {} : { tab: next }); setMobileSidebarOpen(false) }
function setTab(next: Tab) {
const visibleNext = visibleTabKeys.includes(next) ? next : "profile"
setParams(visibleNext === "profile" ? {} : { tab: visibleNext })
setMobileSidebarOpen(false)
}
function toggleSidebar() { sidebarCollapsed ? (sidebarPanelRef.current?.expand(14), setSidebarCollapsed(false)) : (sidebarPanelRef.current?.collapse(), setSidebarCollapsed(true)) }
if (me.isLoading) return <div className="grid h-svh place-items-center text-muted-foreground">...</div>
if (me.isError || !user) return <div className="grid h-svh place-items-center text-muted-foreground"></div>
@@ -205,7 +278,7 @@ export function ProfilePage() {
<SidebarGroup>
{!sidebarCollapsed && <SidebarGroupLabel></SidebarGroupLabel>}
<SidebarGroupContent>
<SidebarMenu>{tabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
<SidebarMenu>{visibleTabKeys.map((key) => <SidebarMenuItem key={key}><SidebarMenuButton isActive={tab === key} className={cn(sidebarCollapsed && "justify-center px-0")} onClick={() => setTab(key)}>{tabs[key].icon}{!sidebarCollapsed && <span>{tabs[key].label}</span>}</SidebarMenuButton></SidebarMenuItem>)}</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
</SidebarContent>
@@ -264,21 +337,64 @@ export function ProfilePage() {
</div>
)
function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={mailboxes.data?.items || []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { setMailboxId(id); navigate("/") }} onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)} />
if (tab === "mailboxes") return (
<MailboxManagement
mailboxes={canAccessMail ? mailboxes.data?.items || [] : []}
applyOptions={mailboxApplyOptions.data}
applyPending={applyMailbox.isPending}
selectedMailboxId={mailboxId}
externalImapEnabled={externalImapEnabled}
externalAccounts={externalImapAccounts.data?.items || []}
externalPending={createExternalImap.isPending || updateExternalImap.isPending || deleteExternalImap.isPending || testExternalImap.isPending || syncExternalImap.isPending || syncExternalImapFolder.isPending || startExternalOAuth.isPending}
selectedExternalRunAccountId={externalRunAccountId}
externalRunFolders={externalRunFolders.data?.items || []}
externalSyncRuns={externalSyncRuns.data?.items || []}
onSelectExternalRunAccount={setExternalRunAccountId}
onSelect={setMailboxId}
onCopy={copy}
onOpen={(id) => { if (!canAccessMail) return; setMailboxId(id); navigate("/") }}
onApply={(payload) => applyMailbox.mutateAsync(payload).then(() => undefined)}
onCreateExternal={(payload) => createExternalImap.mutate(payload)}
onStartExternalOAuth={(provider, payload) => startExternalOAuth.mutate({ provider, ...payload })}
onUpdateExternal={(id, payload) => updateExternalImap.mutate({ id, payload })}
onDeleteExternal={(id) => deleteExternalImap.mutate(id)}
onTestExternal={(id) => testExternalImap.mutate(id)}
onSyncExternal={(id) => syncExternalImap.mutate(id)}
onSyncExternalFolder={(id, folder) => syncExternalImapFolder.mutate({ id, folder })}
/>
)
if (tab === "clients") return <ClientSettingsSection mailboxes={mailboxes.data?.items || []} selectedMailboxId={mailboxId} hostname={publicSettings.data?.publicHostname} onSelectMailbox={setMailboxId} onCopy={copy} />
if (tab === "signatures") return <SignaturesSection items={signatures.data?.items || []} mailboxes={mailboxes.data?.items || []} loading={signatures.isLoading} pending={createSignature.isPending || updateSignature.isPending || setDefaultSignature.isPending || deleteSignature.isPending} onCreate={(form) => createSignature.mutate(form)} onUpdate={(id, form) => updateSignature.mutate({ id, form })} onSetDefault={(id) => setDefaultSignature.mutate(id)} onDelete={(id) => deleteSignature.mutate(id)} />
if (tab === "contacts") return <ContactsSection items={contacts.data?.items || []} loading={contacts.isLoading} pending={createContact.isPending} onCreate={(form) => createContact.mutate(form)} onDelete={(id) => deleteContact.mutate(id)} onCopy={copy} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={stats.data} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "cleanup") return <CleanupSection mailbox={selectedMailbox} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} pending={cleanup.isPending} onCleanup={(target) => cleanup.mutate(target)} />
if (tab === "rules") return <RulesSection items={rules.data?.items || []} mailboxes={mailboxes.data?.items || []} labels={ruleLabels.data?.items || []} open={ruleDialogOpen} onOpenChange={setRuleDialogOpen} onCreate={(payload) => createRule.mutate(payload)} onDelete={(id) => deleteRule.mutate(id)} pending={createRule.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "blocked") return <BlockedSection items={blocked.data?.items || []} mailboxes={mailboxes.data?.items || []} mailboxId={blockedMailboxId} spamCount={canViewStats ? stats.data?.byFolder.find((f) => f.role === "spam")?.count || 0 : 0} onMailboxChange={setBlockedMailboxId} onCreate={(form) => createBlocked.mutate(form)} onDelete={(id) => deleteBlocked.mutate(id)} pending={createBlocked.isPending} />
if (tab === "stats") return <StatsSection stats={stats.data} mailbox={selectedMailbox} onRefresh={() => stats.refetch()} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={stats.data} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
return <ProfileOverview user={user!} profile={profile} password={password} passwordFormRef={passwordFormRef} stats={canViewStats ? stats.data : undefined} showStats={canViewStats} displayMode={displayMode} onDisplayModeChange={setDisplayMode} twoFactorFormRef={twoFactorFormRef} setupTwoFactor={setupTwoFactor} enableTwoFactor={enableTwoFactor} disableTwoFactor={disableTwoFactor} onCopy={copy} />
}
}
function ProfileOverview({ user, profile, password, passwordFormRef, stats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
function ProfileOverview({ user, profile, password, passwordFormRef, stats, showStats, displayMode, onDisplayModeChange, twoFactorFormRef, setupTwoFactor, enableTwoFactor, disableTwoFactor, onCopy }: { user: { email: string; displayName: string; role: string; disabled: boolean; twoFactorEnabled: boolean; createdAt: string; limits?: PermissionLimits }; profile: { mutate: (form: FormData) => void; isPending: boolean }; password: { mutate: (form: FormData) => void; isPending: boolean }; passwordFormRef: React.RefObject<HTMLFormElement>; stats?: MailStats; showStats: boolean; displayMode: DisplayMode; onDisplayModeChange: (mode: DisplayMode) => void; twoFactorFormRef: React.RefObject<HTMLFormElement>; setupTwoFactor: { data?: { secret: string; otpauthUrl: string }; mutate: () => void; reset: () => void; isPending: boolean }; enableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; disableTwoFactor: { mutate: (form: FormData) => void; isPending: boolean }; onCopy: (text: string) => void }) {
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-5">
<LimitBadge label="附件上限" value={user.limits?.maxAttachmentMb} unit="MB" />
<LimitBadge label="SMTP 每日" value={user.limits?.smtpDailyLimit} unit="封" />
<LimitBadge label="SMTP 每分钟" value={user.limits?.smtpMinuteLimit} unit="封" />
<LimitBadge label="IMAP 每分钟" value={user.limits?.imapMinuteLimit} unit="次" />
<LimitBadge label="POP3 每分钟" value={user.limits?.pop3MinuteLimit} unit="次" />
</div>
</CardContent>
</Card>
{showStats && <StatsSummary stats={stats} />}
<Card>
<CardHeader>
<CardTitle></CardTitle>
@@ -306,7 +422,7 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
<ShieldCheck className="h-4 w-4" />
</div>
<Badge>{user.role === "admin" ? "管理员" : "普通用户"}</Badge>
<Badge>{user.role === "admin" ? "超级管理员" : "普通用户"}</Badge>
</div>
<div className="flex items-center justify-between rounded-lg border p-3 text-sm">
<span></span>
@@ -424,15 +540,73 @@ function ProfileOverview({ user, profile, password, passwordFormRef, stats, disp
</CardContent>
</Card>
<StatsSummary stats={stats} />
</div>
)
}
function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMailboxId, onSelect, onCopy, onOpen, onApply }: { mailboxes: Mailbox[]; applyOptions?: MailboxApplyOptions; applyPending: boolean; selectedMailboxId: string; onSelect: (id: string) => void; onCopy: (text: string) => void; onOpen: (id: string) => void; onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void> }) {
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
function LimitBadge({ label, value, unit }: { label: string; value?: number; unit: string }) {
return (
<div className="space-y-4">
<div className="rounded-lg border p-3 text-center">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 text-lg font-semibold tabular-nums tracking-tight">
{value !== undefined && value > 0 ? value : "不限"}
</div>
{value !== undefined && value > 0 && <div className="text-xs text-muted-foreground">{unit}</div>}
</div>
)
}
function MailboxManagement({
mailboxes,
applyOptions,
applyPending,
selectedMailboxId,
externalImapEnabled,
externalAccounts,
externalPending,
selectedExternalRunAccountId,
externalRunFolders,
externalSyncRuns,
onSelectExternalRunAccount,
onSelect,
onCopy,
onOpen,
onApply,
onCreateExternal,
onStartExternalOAuth,
onUpdateExternal,
onDeleteExternal,
onTestExternal,
onSyncExternal,
onSyncExternalFolder,
}: {
mailboxes: Mailbox[]
applyOptions?: MailboxApplyOptions
applyPending: boolean
selectedMailboxId: string
externalImapEnabled: boolean
externalAccounts: ExternalImapAccount[]
externalPending: boolean
selectedExternalRunAccountId: string
externalRunFolders: ExternalImapFolder[]
externalSyncRuns: ExternalImapSyncRun[]
onSelectExternalRunAccount: (id: string) => void
onSelect: (id: string) => void
onCopy: (text: string) => void
onOpen: (id: string) => void
onApply: (payload: { domainId: string; localPart: string; displayName: string }) => Promise<void>
onCreateExternal: (payload: ExternalImapAccountPayload) => void
onStartExternalOAuth: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void
onUpdateExternal: (id: string, payload: ExternalImapAccountPayload) => void
onDeleteExternal: (id: string) => void
onTestExternal: (id: string) => void
onSyncExternal: (id: string) => void
onSyncExternalFolder: (id: string, folder: string) => void
}) {
const canApply = !!applyOptions?.enabled && (applyOptions.domains || []).length > 0
const selectedMailbox = mailboxes.find((item) => item.id === selectedMailboxId)
return (
<div className="space-y-6">
<div className="flex justify-end">
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
</div>
@@ -440,6 +614,52 @@ function MailboxManagement({ mailboxes, applyOptions, applyPending, selectedMail
{mailboxes.map((m) => <Card key={m.id} className={cn(selectedMailboxId === m.id && "border-primary")}><CardHeader><div className="flex items-start justify-between gap-3"><div className="min-w-0"><CardTitle className="truncate text-base">{m.address}</CardTitle></div>{selectedMailboxId === m.id && <Badge></Badge>}</div></CardHeader><CardContent className="flex flex-wrap gap-2"><Button variant="outline" size="sm" onClick={() => onSelect(m.id)}></Button><Button variant="outline" size="sm" onClick={() => onCopy(m.address)}><Copy className="h-4 w-4" /></Button><Button size="sm" onClick={() => onOpen(m.id)}></Button></CardContent></Card>)}
{mailboxes.length === 0 && <EmptyState text={canApply ? "暂无邮箱账号,点击申请邮箱创建" : "暂无邮箱账号"} />}
</div>
{externalImapEnabled && <Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<CardTitle> IMAP </CardTitle>
<div className="mt-1 text-sm text-muted-foreground"></div>
</div>
<div className="flex flex-wrap gap-2">
<ExternalImapOAuthDialog provider="gmail" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
<ExternalImapOAuthDialog provider="outlook" selectedMailbox={selectedMailbox} disabled={!selectedMailbox} pending={externalPending} onStart={onStartExternalOAuth} />
<ExternalImapDialog mailboxId={selectedMailboxId} disabled={!selectedMailbox} pending={externalPending} onSubmit={onCreateExternal} />
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{!selectedMailbox && <EmptyState text="请先选择一个本地邮箱" />}
{selectedMailbox && externalAccounts.length === 0 && <EmptyState text="暂无外部 IMAP 账号" />}
{selectedMailbox && externalAccounts.map((account) => {
const selectedForRuns = selectedExternalRunAccountId === account.id
return (
<div key={account.id} className="space-y-3 rounded-lg border p-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="truncate font-medium">{account.name}</div>
<Badge variant={account.enabled ? "secondary" : "outline"}>{account.enabled ? "已启用" : "已停用"}</Badge>
<Badge variant="outline">{account.storageMode === "local" ? "本地存储" : "远端直连"}</Badge>
</div>
<div className="mt-1 truncate text-xs text-muted-foreground">{account.username} · {account.host}:{account.port} · {account.tlsMode.toUpperCase()}{account.authMode === "oauth2" ? ` · ${externalOAuthProviderLabel(account.oauthProvider)}` : ""}</div>
<div className="mt-1 text-xs text-muted-foreground">{externalStatusLabel(account.lastStatus)}{account.lastSyncAt ? ` · 最近同步 ${formatDateTime(account.lastSyncAt)}` : ""}{account.lastError ? ` · ${account.lastError}` : ""}</div>
</div>
<div className="flex flex-wrap gap-2">
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onTestExternal(account.id)}><Link2 className="h-4 w-4" /></Button>
{account.storageMode === "local" && <Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onSyncExternal(account.id)}><RefreshCcw className="h-4 w-4" /></Button>}
{account.storageMode === "local" && <Button type="button" variant="ghost" size="sm" onClick={() => onSelectExternalRunAccount(selectedForRuns ? "" : account.id)}></Button>}
<ExternalImapDialog account={account} mailboxId={selectedMailboxId} pending={externalPending} onSubmit={(payload) => onUpdateExternal(account.id, payload)} />
<Button type="button" variant="outline" size="sm" disabled={externalPending} onClick={() => onUpdateExternal(account.id, { ...externalPayloadFromAccount(account), enabled: !account.enabled })}>{account.enabled ? "停用" : "启用"}</Button>
<Button type="button" variant="destructive" size="sm" disabled={externalPending} onClick={() => onDeleteExternal(account.id)}></Button>
</div>
</div>
{selectedForRuns && <ExternalImapSyncPanel account={account} folders={externalRunFolders} runs={externalSyncRuns} pending={externalPending} onSyncFolder={onSyncExternalFolder} />}
</div>
)
})}
</CardContent>
</Card>}
</div>
)
}
@@ -490,6 +710,182 @@ function ApplyMailboxDialog({ options, pending, onApply }: { options: MailboxApp
)
}
function ExternalImapOAuthDialog({ provider, selectedMailbox, disabled, pending, onStart }: { provider: ExternalImapOAuthProvider; selectedMailbox?: Mailbox; disabled?: boolean; pending: boolean; onStart: (provider: ExternalImapOAuthProvider, payload: { mailboxId: string; email: string; storageMode: ExternalImapStorageMode }) => void }) {
const [open, setOpen] = React.useState(false)
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>("local")
const label = provider === "gmail" ? "Gmail OAuth" : "Microsoft 365 / Outlook OAuth"
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
if (!selectedMailbox) return
const form = new FormData(event.currentTarget)
onStart(provider, {
mailboxId: selectedMailbox.id,
email: String(form.get("email") || ""),
storageMode,
})
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" variant="outline" disabled={disabled || pending} onClick={() => setOpen(true)}>{label}</Button>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-lg">
<DialogHeader><DialogTitle>{label}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<div className="rounded-lg border bg-muted/30 p-3 text-sm text-muted-foreground">
OAuth {provider === "gmail" ? "Google Gmail" : "Microsoft 365 / Outlook / Exchange Online"} 使 IMAP
</div>
<Field label="外部邮箱地址(可选)"><Input name="email" type="email" placeholder={selectedMailbox?.address || "name@example.com"} /></Field>
<div className="text-xs text-muted-foreground"> OAuth </div>
<Field label="存储模式">
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="local"></SelectItem><SelectItem value="remote"></SelectItem></SelectContent>
</Select>
</Field>
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !selectedMailbox}>{pending ? "跳转中..." : "前往授权"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function ExternalImapDialog({ account, mailboxId, disabled, pending, onSubmit }: { account?: ExternalImapAccount; mailboxId: string; disabled?: boolean; pending: boolean; onSubmit: (payload: ExternalImapAccountPayload) => void }) {
const [open, setOpen] = React.useState(false)
const [tlsMode, setTlsMode] = React.useState<ExternalImapTlsMode>(account?.tlsMode || "tls")
const [storageMode, setStorageMode] = React.useState<ExternalImapStorageMode>(account?.storageMode || "local")
const [syncReadState, setSyncReadState] = React.useState(account?.syncReadState ?? true)
const [enabled, setEnabled] = React.useState(account?.enabled ?? true)
React.useEffect(() => {
if (!open) return
setTlsMode(account?.tlsMode || "tls")
setStorageMode(account?.storageMode || "local")
setSyncReadState(account?.syncReadState ?? true)
setEnabled(account?.enabled ?? true)
}, [account, open])
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = new FormData(event.currentTarget)
const payload: ExternalImapAccountPayload = {
mailboxId,
name: String(form.get("name") || ""),
host: String(form.get("host") || ""),
port: Number(form.get("port") || (tlsMode === "tls" ? 993 : 143)),
tlsMode,
username: String(form.get("username") || ""),
password: String(form.get("password") || ""),
storageMode,
syncReadState,
enabled,
}
onSubmit(payload)
if (!pending) setOpen(false)
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<Button type="button" variant={account ? "outline" : "default"} size={account ? "sm" : "default"} disabled={disabled} onClick={() => setOpen(true)}>
{account ? "编辑" : <><Plus className="h-4 w-4" /></>}
</Button>
<DialogContent className="max-h-[92dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader><DialogTitle>{account ? "编辑外部 IMAP" : "添加外部 IMAP"}</DialogTitle></DialogHeader>
<form className="space-y-4" onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<Field label="显示名称"><Input name="name" defaultValue={account?.name || ""} placeholder="Gmail / 工作邮箱" /></Field>
<Field label="用户名"><Input name="username" defaultValue={account?.username || ""} required placeholder="name@example.com" /></Field>
<Field label="服务器"><Input name="host" defaultValue={account?.host || ""} required placeholder="imap.example.com" /></Field>
<Field label="端口"><Input name="port" type="number" min={1} max={65535} defaultValue={account?.port || (tlsMode === "tls" ? 993 : 143)} /></Field>
<Field label="加密方式">
<Select value={tlsMode} onValueChange={(value) => setTlsMode(value as ExternalImapTlsMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="tls">SSL/TLS</SelectItem><SelectItem value="starttls">STARTTLS</SelectItem><SelectItem value="plain"></SelectItem></SelectContent>
</Select>
</Field>
<Field label="存储模式">
<Select value={storageMode} onValueChange={(value) => setStorageMode(value as ExternalImapStorageMode)}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="local"></SelectItem><SelectItem value="remote"></SelectItem></SelectContent>
</Select>
</Field>
</div>
<Field label={account ? "密码(留空则不修改)" : "密码"}><PasswordInput name="password" required={!account} placeholder={account ? "不修改请留空" : "外部邮箱密码或授权码"} /></Field>
<div className="grid gap-3 sm:grid-cols-2">
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={syncReadState} onCheckedChange={(checked) => setSyncReadState(checked === true)} /></label>
<label className="flex items-center gap-2 rounded-lg border p-3 text-sm"><Checkbox checked={enabled} onCheckedChange={(checked) => setEnabled(checked === true)} /></label>
</div>
<DialogFooter className="gap-2 [&>button]:w-full sm:[&>button]:w-auto">
<Button type="button" variant="outline" onClick={() => setOpen(false)}></Button>
<Button disabled={pending || !mailboxId}>{pending ? "保存中..." : "保存"}</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
function externalPayloadFromAccount(account: ExternalImapAccount): ExternalImapAccountPayload {
return { mailboxId: account.mailboxId, name: account.name, host: account.host, port: account.port, tlsMode: account.tlsMode, username: account.username, password: "", storageMode: account.storageMode, syncReadState: account.syncReadState, enabled: account.enabled }
}
function externalStatusLabel(status: string) {
return ({ idle: "未同步", ok: "正常", partial: "部分成功", error: "错误", running: "同步中" } as Record<string, string>)[status] || status || "未知"
}
function externalOAuthProviderLabel(provider?: ExternalImapOAuthProvider) {
return provider === "gmail" ? "Gmail OAuth" : provider === "outlook" ? "Microsoft 365 / Outlook OAuth" : "OAuth"
}
function ExternalImapSyncPanel({ account, folders, runs, pending, onSyncFolder }: { account: ExternalImapAccount; folders: ExternalImapFolder[]; runs: ExternalImapSyncRun[]; pending: boolean; onSyncFolder: (id: string, folder: string) => void }) {
const [folder, setFolder] = React.useState("")
React.useEffect(() => {
if (folder && folders.some((item) => item.name === folder)) return
setFolder(folders[0]?.name || "INBOX")
}, [folder, folders])
return (
<div className="rounded-lg bg-muted/40 p-3">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<Field label="单文件夹同步">
<Select value={folder} onValueChange={setFolder}>
<SelectTrigger><SelectValue placeholder="选择远端文件夹" /></SelectTrigger>
<SelectContent>{folders.map((item) => <SelectItem key={item.name} value={item.name}>{folderLabel(item.name)}</SelectItem>)}</SelectContent>
</Select>
</Field>
<Button type="button" variant="outline" disabled={pending || !folder} onClick={() => onSyncFolder(account.id, folder)}><RefreshCcw className="h-4 w-4" /></Button>
</div>
<div className="mt-3 space-y-2">
<div className="text-xs font-medium text-muted-foreground"></div>
{runs.length === 0 && <div className="rounded-md border bg-background p-3 text-sm text-muted-foreground"></div>}
{runs.slice(0, 6).map((run) => (
<div key={run.id} className="grid gap-2 rounded-md border bg-background p-3 text-sm md:grid-cols-[minmax(0,1fr)_auto] md:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<Badge variant={run.status === "ok" ? "secondary" : run.status === "failed" ? "destructive" : "outline"}>{externalStatusLabel(run.status)}</Badge>
<span className="truncate">{run.folder ? folderLabel(run.folder) : "全部文件夹"}</span>
</div>
{run.error && <div className="mt-1 truncate text-xs text-destructive">{run.error}</div>}
</div>
<div className="text-xs text-muted-foreground md:text-right">
<div> {run.imported} · {run.skipped} · {run.failed}</div>
<div>{formatDateTime(run.startedAt)}</div>
</div>
</div>
))}
</div>
</div>
)
}
function formatDateTime(value: string) {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleString()
}
function ClientSettingsSection({ mailboxes, selectedMailboxId, hostname, onSelectMailbox, onCopy }: { mailboxes: Mailbox[]; selectedMailboxId: string; hostname?: string; onSelectMailbox: (id: string) => void; onCopy: (text: string) => void }) {
const selected = mailboxes.find((item) => item.id === selectedMailboxId) || mailboxes[0]
const server = clientServerHost(hostname, selected?.address)
@@ -718,7 +1114,7 @@ function ContactsSection({ items, loading, onCreate, onDelete, onCopy, pending }
)
}
function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
function CleanupSection({ mailbox, stats, showStats, pending, onCleanup }: { mailbox?: Mailbox; stats?: MailStats; showStats: boolean; pending: boolean; onCleanup: (target: "empty-trash" | "empty-spam" | "archive-read-inbox") => void }) {
const [pendingConfirm, setPendingConfirm] = React.useState<PendingConfirm | null>(null)
function confirmCleanup(target: "empty-trash" | "empty-spam" | "archive-read-inbox", title: string, destructive = false) {
setPendingConfirm({
@@ -731,7 +1127,7 @@ function CleanupSection({ mailbox, stats, pending, onCleanup }: { mailbox?: Mail
}
return (
<div className="space-y-6">
<StatsSummary stats={stats} />
{showStats && <StatsSummary stats={stats} />}
<Card>
<CardHeader><CardTitle></CardTitle></CardHeader>
<CardContent className="grid gap-3 md:grid-cols-3">
@@ -756,8 +1152,15 @@ type RuleCreatePayload = {
enabled: boolean
}
const conditionFieldLabels: Record<MailRuleCondition["field"], string> = { from: "发件人地址", to: "收件人地址", subject: "邮件主题", body: "邮件正文" }
const conditionOperatorLabels: Record<MailRuleCondition["operator"], string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是" }
type RuleConditionField = NonNullable<MailRuleCondition["field"]>
type RuleConditionOperator = NonNullable<MailRuleCondition["operator"]>
const conditionFieldLabels: Record<RuleConditionField, string> = { from: "发件人地址", to: "收件人地址", cc: "抄送地址", subject: "邮件主题", body: "邮件正文", attachment: "附件名称", size: "邮件大小", date: "收信日期" }
const conditionOperatorLabels: Record<RuleConditionOperator, string> = { contains: "包含", "not-contains": "不包含", equals: "等于", "not-equals": "不等于", "starts-with": "开头是", "ends-with": "结尾是", gt: "大于", gte: "大于等于", lt: "小于", lte: "小于等于", before: "早于", after: "晚于", on: "当天" }
const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contains", "equals", "not-equals", "starts-with", "ends-with"]
const sizeConditionOperators: RuleConditionOperator[] = ["gt", "gte", "lt", "lte", "equals", "not-equals"]
const dateConditionOperators: RuleConditionOperator[] = ["before", "after", "on", "equals", "not-equals"]
const conditionFields = Object.keys(conditionFieldLabels) as RuleConditionField[]
const commonRuleFolders = ["Inbox", "Archive", "Spam", "Trash"]
const ruleActionLabels: Record<MailRuleAction["type"], string> = { archive: "移入归档", trash: "移入回收站", star: "添加星标", "mark-read": "标记已读", label: "添加标签", move: "移动到" }
function RulesSection({ items, mailboxes, labels, open, onOpenChange, onCreate, onDelete, pending }: { items: MailRule[]; mailboxes: Mailbox[]; labels: MailLabel[]; open: boolean; onOpenChange: (open: boolean) => void; onCreate: (payload: RuleCreatePayload) => void; onDelete: (id: string) => void; pending: boolean }) {
@@ -804,7 +1207,14 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
}, [open, labels])
function updateCondition(index: number, patch: Partial<MailRuleCondition>) {
setConditions((items) => items.map((item, i) => i === index ? { ...item, ...patch } : item))
setConditions((items) => items.map((item, i) => {
if (i !== index) return item
const next = { ...item, ...patch }
if (patch.field && !conditionOperatorsForField(patch.field).includes(next.operator || "contains")) {
next.operator = defaultConditionOperator(patch.field)
}
return next
}))
}
function updateAction(index: number, patch: Partial<MailRuleAction>) {
setActions((items) => items.map((item, i) => i === index ? normalizeDraftAction({ ...item, ...patch }, availableLabels) : item))
@@ -814,7 +1224,7 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
function removeCondition(index: number) { setConditions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
function removeAction(index: number) { setActions((items) => items.length > 1 ? items.filter((_, i) => i !== index) : items) }
const validConditions = conditions.map((item) => ({ ...item, value: item.value.trim() })).filter((item) => item.value)
const validConditions = conditions.map((item) => ({ ...item, value: (item.value || "").trim() })).filter((item) => item.field && item.operator && item.value)
const validActions = actions.map((item) => normalizeDraftAction(item, availableLabels)).filter((item) => item.type !== "label" || item.value || item.labelId).filter((item) => item.type !== "move" || item.value)
const canCreate = validConditions.length > 0 && validActions.length > 0 && !pending
@@ -846,15 +1256,15 @@ function RuleDialog({ open, onOpenChange, mailboxes, labels, pending, onCreate }
<div className="space-y-3">
{conditions.map((condition, index) => (
<div key={index} className="grid gap-3 md:grid-cols-[220px_150px_minmax(0,1fr)_auto_auto]">
<Select value={condition.field} onValueChange={(value) => updateCondition(index, { field: value as MailRuleCondition["field"] })}>
<Select value={condition.field || "from"} onValueChange={(value) => updateCondition(index, { field: value as RuleConditionField })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionFieldLabels) as MailRuleCondition["field"][]).map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
<SelectContent>{conditionFields.map((value) => <SelectItem key={value} value={value}>{conditionFieldLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Select value={condition.operator} onValueChange={(value) => updateCondition(index, { operator: value as MailRuleCondition["operator"] })}>
<Select value={condition.operator || defaultConditionOperator(condition.field)} onValueChange={(value) => updateCondition(index, { operator: value as RuleConditionOperator })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>{(Object.keys(conditionOperatorLabels) as MailRuleCondition["operator"][]).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
<SelectContent>{conditionOperatorsForField(condition.field).map((value) => <SelectItem key={value} value={value}>{conditionOperatorLabels[value]}</SelectItem>)}</SelectContent>
</Select>
<Input value={condition.value} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder="输入值" />
<Input type={condition.field === "date" ? "date" : "text"} value={condition.value || ""} onChange={(event) => updateCondition(index, { value: event.target.value })} placeholder={conditionPlaceholder(condition.field)} />
<Button type="button" variant="ghost" size="icon" className="text-muted-foreground" onClick={() => removeCondition(index)} disabled={conditions.length === 1}><X className="h-4 w-4" /></Button>
<Button type="button" variant="ghost" size="icon" onClick={addCondition}><Plus className="h-4 w-4" /></Button>
</div>
@@ -913,11 +1323,21 @@ function RuleActionValue({ action, labels, onChange }: { action: MailRuleAction;
return <Input value={action.value || ""} onChange={(event) => onChange({ value: event.target.value, labelId: "" })} placeholder="标签名称" />
}
if (action.type === "move") {
const value = action.value || "Archive"
return (
<Select value={action.value || "Archive"} onValueChange={(value) => onChange({ value })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="Inbox"></SelectItem><SelectItem value="Archive"></SelectItem><SelectItem value="Spam"></SelectItem><SelectItem value="Trash"></SelectItem></SelectContent>
</Select>
<div className="grid gap-2 md:grid-cols-[180px_minmax(0,1fr)]">
<Select value={commonRuleFolders.includes(value) ? value : "__custom"} onValueChange={(next) => onChange({ value: next === "__custom" ? "" : next })}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="Inbox"></SelectItem>
<SelectItem value="Archive"></SelectItem>
<SelectItem value="Spam"></SelectItem>
<SelectItem value="Trash"></SelectItem>
<SelectItem value="__custom"></SelectItem>
</SelectContent>
</Select>
<Input value={value} onChange={(event) => onChange({ value: event.target.value })} placeholder="输入或选择文件夹名" />
</div>
)
}
return <Input value="无需填写" readOnly />
@@ -956,9 +1376,38 @@ function normalizeDraftAction(action: MailRuleAction, labels: MailLabel[]): Mail
return { type: action.type }
}
function conditionOperatorsForField(field?: MailRuleCondition["field"]) {
if (field === "size") return sizeConditionOperators
if (field === "date") return dateConditionOperators
return textConditionOperators
}
function defaultConditionOperator(field?: MailRuleCondition["field"]): RuleConditionOperator {
if (field === "size") return "gte"
if (field === "date") return "on"
return "contains"
}
function conditionPlaceholder(field?: MailRuleCondition["field"]) {
if (field === "size") return "例如 10mb"
if (field === "date") return "选择日期"
if (field === "attachment") return "输入附件名或扩展名"
return "输入值"
}
function conditionSummary(conditions: MailRuleCondition[] = [], fromContains = "", subjectContains = "") {
const items = conditions.length > 0 ? conditions : [fromContains ? { field: "from", operator: "contains", value: fromContains } as MailRuleCondition : undefined, subjectContains ? { field: "subject", operator: "contains", value: subjectContains } as MailRuleCondition : undefined].filter(Boolean) as MailRuleCondition[]
return items.map((item) => `${conditionFieldLabels[item.field]} ${conditionOperatorLabels[item.operator]} ${item.value}`).join("") || "无条件"
return items.map(conditionItemSummary).join("") || "无条件"
}
function conditionItemSummary(item: MailRuleCondition): string {
if (item.conditions?.length) {
const mode = item.matchMode === "any" ? "任一" : "全部"
return `${mode}(${item.conditions.map(conditionItemSummary).join("")})`
}
const field = item.field || "from"
const operator = item.operator || defaultConditionOperator(field)
return `${conditionFieldLabels[field]} ${conditionOperatorLabels[operator]} ${item.value || ""}`
}
function actionSummary(action: MailRuleAction) {
@@ -1007,7 +1456,8 @@ function StatsSection({ stats, mailbox, onRefresh }: { stats?: MailStats; mailbo
}
function StatsSummary({ stats }: { stats?: MailStats }) {
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: stats?.attachmentCount || 0 }, { label: "容量", value: formatBytes(stats?.storageBytes || 0) }]
const quotaLabel = stats?.quotaBytes ? `${formatBytes(stats.storageBytes || 0)} / ${formatBytes(stats.quotaBytes)}` : formatBytes(stats?.storageBytes || 0)
const cards = [{ label: "总邮件", value: stats?.totalMessages || 0 }, { label: "未读", value: stats?.unreadMessages || 0 }, { label: "星标", value: stats?.starredMessages || 0 }, { label: "附件", value: `${stats?.attachmentCount || 0} / ${formatBytes(stats?.attachmentBytes || 0)}` }, { label: stats?.quotaBytes ? `容量 ${Math.min(stats.quotaUsedPct || 0, 999).toFixed(1)}%` : "容量", value: quotaLabel }]
return <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">{cards.map((c) => <Card key={c.label}><CardContent className="p-4"><div className="text-2xl font-semibold tracking-tight">{c.value}</div><div className="text-xs text-muted-foreground">{c.label}</div></CardContent></Card>)}</div>
}
+6 -1
View File
@@ -1,4 +1,9 @@
declare module "dompurify" {
const DOMPurify: { sanitize: (source: string) => string }
type SanitizeConfig = {
ADD_ATTR?: string[]
ADD_TAGS?: string[]
WHOLE_DOCUMENT?: boolean
}
const DOMPurify: { sanitize: (source: string, config?: SanitizeConfig) => string }
export default DOMPurify
}
+30 -1
View File
@@ -28,7 +28,7 @@ LANQIN_PUBLIC_BASE_URL=https://mail.example.com
# 邮件客户端 TLS 证书。用于 SMTP 465/587、IMAP 993、POP3 995。
# 生产环境请挂载真实证书,并确保域名包含 LANQIN_PUBLIC_HOSTNAME。
# 留空时会使用容器自带 localhost 自签证书,第三方客户端会提示证书不匹配
# 留空时 Dovecot/Postfix 会使用容器自带 localhost 自签证书LanQin API 的 SMTP submission 不会启用
LANQIN_TLS_CERT_FILE=
LANQIN_TLS_KEY_FILE=
@@ -84,15 +84,24 @@ LANQIN_TURNSTILE_SECRET_KEY=
# SMTP 发信
# =========================
# 单容器部署默认提交给容器内 Postfix。
# Split stack 会由 docker-compose.stack.yml 默认覆盖为 postfix:25。
# 如需在 split stack 使用外部 SMTP,可设置 LANQIN_STACK_SMTP_HOST / LANQIN_STACK_SMTP_PORT。
# 如果要走外部 SMTP,把 Host/Port/Username/Password 改成外部服务配置。
LANQIN_SMTP_HOST=127.0.0.1
LANQIN_SMTP_PORT=25
LANQIN_STACK_SMTP_HOST=
LANQIN_STACK_SMTP_PORT=
LANQIN_SMTP_USERNAME=
LANQIN_SMTP_PASSWORD=
# 外部 SMTP 要求 STARTTLS / TLS 时改 true;本机 Postfix 默认 false。
LANQIN_SMTP_REQUIRE_TLS=false
# 第三方客户端 SMTP 提交,由 LanQin API 监听 587/465;启用前必须配置可读 TLS 证书。
LANQIN_SUBMISSION_ADDR=
LANQIN_SUBMISSION_TLS_ADDR=
LANQIN_SUBMISSION_MAX_MESSAGE_MB=35
# =========================
# 收件 / Maildir 同步
# =========================
@@ -123,6 +132,26 @@ LANQIN_MAIL_AUTO_REFRESH=true
# 自动刷新间隔,单位:秒。
LANQIN_MAIL_REFRESH_SECONDS=30
# 是否启用外部 IMAP 接入。也可在后台“系统设置 > 外部 IMAP”里开启,默认关闭。
LANQIN_EXTERNAL_IMAP_ENABLED=false
# 外部 IMAP 密码加密密钥。启用外部 IMAP 接入前必须设置为足够长的随机字符串;也可在后台“系统设置 > 外部 IMAP”里配置。
LANQIN_EXTERNAL_IMAP_SECRET_KEY=
# 外部 IMAP 本地存储模式的后台同步间隔,单位:秒;也可在后台配置。
LANQIN_EXTERNAL_IMAP_SYNC_SECONDS=300
# 是否允许用户配置 localhost / 内网 / link-local IMAP 主机。默认 false,避免 SSRF 风险;也可在后台配置。
LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS=false
# Gmail 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/gmail/callback
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET=
# Microsoft 365 / Outlook 外部 IMAP OAuth2。也可在后台配置。回调地址:${LANQIN_PUBLIC_BASE_URL}/api/external-imap-oauth/outlook/callback
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID=
LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET=
# =========================
# 系统
# =========================
+12 -4
View File
@@ -128,18 +128,22 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Rspamd 会周期性从 SQLite 导出域名 DKIM 私钥到容器内 `/var/lib/rspamd/dkim`
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 Webmail 索引。
- 第三方客户端通过 SMTP `465/587` 发信时,Postfix 会把已认证发件人的邮件自动 BCC 到 `发件人+Sent@域名`Dovecot LMTP 会保存到该邮箱的 `Sent` 文件夹,Webmail 扫描后会显示在“已发送”
- 第三方客户端通过 LanQin API 提供的 SMTP `465/587` 发信Webmail/API 和第三方客户端的“已发送”都由 API 写入,外发投递进入发送队列并由 API worker relay/retry,客户端后续 IMAP APPEND 到 Sent 会按 `Message-ID` 去重
- 用户可在个人邮箱管理中接入外部 IMAP 账号;默认关闭,可在后台“系统设置 > 外部 IMAP”开启并配置密钥/OAuth。本地存储模式会同步到 LanQin,远端直连模式每次从远端读取。启用前必须配置外部 IMAP 密码加密密钥,默认不允许连接 localhost / 内网 / link-local IMAP 主机。Gmail / Microsoft 365 / Outlook OAuth2 需要在对应控制台配置回调地址:`/api/external-imap-oauth/gmail/callback``/api/external-imap-oauth/outlook/callback`
- send-as v1 支持本人邮箱、启用的别名转发 source 指向本人邮箱,或数据库表 `send_as_grants` 中显式授权的地址。
## 邮件客户端 TLS 证书
Web 站点可以由宿主机 Nginx / 宝塔反代到容器 `80`,但 SMTP/IMAP/POP3 端口不会使用 Web 反代的证书。
如果第三方客户端连接 `465/587/993/995` 时提示证书是 `localhost`,说明 Postfix/Dovecot 仍在使用容器自带的测试证书。
如果第三方客户端连接 `993/995` 时提示证书是 `localhost`,说明 Dovecot 仍在使用容器自带的测试证书。LanQin API 的 SMTP `465/587` submission 不会使用自签测试证书;启用前必须配置可读的真实证书。
生产环境请把域名证书挂载进容器,并在 `.env` 指向证书文件:
```env
LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
LANQIN_TLS_KEY_FILE=/certs/privkey.pem
LANQIN_SUBMISSION_ADDR=:587
LANQIN_SUBMISSION_TLS_ADDR=:465
```
单容器示例:
@@ -170,12 +174,16 @@ LANQIN_SMTP_PORT=25
LANQIN_SMTP_REQUIRE_TLS=false
```
如果页面提示 `smtp delivery failed: EOF`,通常是 Postfix 会话被中断。优先检查:
Split stack 使用 `docker-compose.stack.yml` 时,API 容器默认会把 `LANQIN_SMTP_HOST` 覆盖为 `postfix`,让 Webmail 和 SMTP 提交都 relay 到 Postfix service。只有改用外部 SMTP 时才需要在 `.env` 明确填写 `LANQIN_STACK_SMTP_HOST` / `LANQIN_STACK_SMTP_PORT`
如果发送队列里出现 relay 失败,通常是 Postfix 会话被中断或外部 SMTP 配置错误。优先检查:
```bash
docker compose exec lanqin-email supervisorctl status
docker compose exec lanqin-email postconf -M smtp/inet submission/inet
docker compose exec lanqin-email postconf -M smtp/inet
# SMTP 提交 465/587 由 LanQin API 提供,不再由 Postfix 监听。
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select key,value from system_settings where key like 'smtp%' order by key;"
docker compose exec lanqin-email sqlite3 /data/lanqin.db "select status,attempt_count,last_error from send_queue order by created_at desc limit 10;"
docker compose logs --tail=200 lanqin-email
```
+21 -2
View File
@@ -7,11 +7,14 @@ set -eu
: "${LANQIN_ADDR:=127.0.0.1:8080}"
: "${LANQIN_SMTP_HOST:=127.0.0.1}"
: "${LANQIN_SMTP_PORT:=25}"
: "${LANQIN_SUBMISSION_ADDR:=}"
: "${LANQIN_SUBMISSION_TLS_ADDR:=}"
: "${LANQIN_SUBMISSION_MAX_MESSAGE_MB:=35}"
: "${LANQIN_MAILDIR_ROOT:=/var/mail/vhosts}"
: "${LANQIN_TLS_CERT_FILE:=}"
: "${LANQIN_TLS_KEY_FILE:=}"
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_MAILDIR_ROOT
export LANQIN_DATA_DIR LANQIN_DB_PATH LANQIN_ADDR LANQIN_SMTP_HOST LANQIN_SMTP_PORT LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR LANQIN_SUBMISSION_MAX_MESSAGE_MB LANQIN_MAILDIR_ROOT LANQIN_TLS_CERT_FILE LANQIN_TLS_KEY_FILE
addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
@@ -23,28 +26,44 @@ elif id rspamd >/dev/null 2>&1; then
chown -R rspamd:rspamd /run/rspamd /var/lib/rspamd 2>/dev/null || true
fi
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
fi
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
if [ -f "$LANQIN_TLS_CERT_FILE" ] && [ -f "$LANQIN_TLS_KEY_FILE" ]; then
TLS_CERT="$LANQIN_TLS_CERT_FILE"
TLS_KEY="$LANQIN_TLS_KEY_FILE"
: "${LANQIN_SUBMISSION_ADDR:=:587}"
: "${LANQIN_SUBMISSION_TLS_ADDR:=:465}"
else
echo "warning: LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE not readable; using snakeoil localhost certificate" >&2
fi
fi
if [ -n "$LANQIN_SUBMISSION_ADDR$LANQIN_SUBMISSION_TLS_ADDR" ] && { [ "$TLS_CERT" = "/etc/ssl/certs/ssl-cert-snakeoil.pem" ] || [ "$TLS_KEY" = "/etc/ssl/private/ssl-cert-snakeoil.key" ]; }; then
echo "warning: SMTP submission disabled because LANQIN_TLS_CERT_FILE/LANQIN_TLS_KEY_FILE are not configured with readable certificate files" >&2
LANQIN_SUBMISSION_ADDR=""
LANQIN_SUBMISSION_TLS_ADDR=""
fi
export LANQIN_SUBMISSION_ADDR LANQIN_SUBMISSION_TLS_ADDR
postconf -e "myhostname = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "myorigin = ${LANQIN_PUBLIC_HOSTNAME}"
postconf -e "smtpd_tls_cert_file = ${TLS_CERT}"
postconf -e "smtpd_tls_key_file = ${TLS_KEY}"
postconf -e "virtual_transport = lmtp:inet:127.0.0.1:24"
postconf -e "smtpd_sasl_path = inet:127.0.0.1:12345"
postconf -e "milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen}"
postconf -e "smtpd_milters = inet:127.0.0.1:11332"
postconf -e "non_smtpd_milters = inet:127.0.0.1:11332"
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
# Rspamd DKIM keys are exported after API seed/migrations create the SQLite DB.
/usr/local/bin/lanqin-api >/tmp/lanqin-api-bootstrap.log 2>&1 &
+1 -1
View File
@@ -17,5 +17,5 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends ca-certificates tzdata
WORKDIR /app
COPY --from=build /out/lanqin-api /usr/local/bin/lanqin-api
EXPOSE 8080
EXPOSE 8080 465 587
CMD ["lanqin-api"]
+13 -3
View File
@@ -2,9 +2,21 @@ services:
api:
image: ${LANQIN_API_IMAGE:-ghcr.io/lanqin996/lanqin-email-api:latest}
env_file: .env
environment:
LANQIN_SMTP_HOST: ${LANQIN_STACK_SMTP_HOST:-postfix}
LANQIN_SMTP_PORT: ${LANQIN_STACK_SMTP_PORT:-25}
LANQIN_SUBMISSION_ADDR: ${LANQIN_SUBMISSION_ADDR:-}
LANQIN_SUBMISSION_TLS_ADDR: ${LANQIN_SUBMISSION_TLS_ADDR:-}
volumes:
- ./data:/data:ro
- ./data:/data
- ./mail:/var/mail/vhosts:ro
# 生产环境如需第三方客户端校验证书,请取消下面挂载的注释,并在 .env 配置:
# LANQIN_TLS_CERT_FILE=/certs/fullchain.pem
# LANQIN_TLS_KEY_FILE=/certs/privkey.pem
# - /etc/letsencrypt/live/${LANQIN_PUBLIC_HOSTNAME}:/certs:ro
ports:
- "465:465"
- "587:587"
depends_on:
- dovecot
- postfix
@@ -36,8 +48,6 @@ services:
# - /etc/letsencrypt:/etc/letsencrypt:ro
ports:
- "25:25"
- "465:465"
- "587:587"
depends_on:
- dovecot
- rspamd
+1 -1
View File
@@ -2,4 +2,4 @@ driver = sqlite
connect = /data/lanqin.db
default_pass_scheme = BLF-CRYPT
password_query = SELECT address AS user, password_hash AS password FROM mailboxes WHERE address = '%u' AND status = 'active'
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
user_query = SELECT '/var/mail/vhosts/' || d.name || '/' || m.local_part AS home, 'maildir:/var/mail/vhosts/' || d.name || '/' || m.local_part || '/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=' || CAST(m.quota_mb AS TEXT) || 'M' AS quota_rule FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active' AND d.status='active' UNION SELECT '/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__' AS home, 'maildir:/var/mail/vhosts/' || lower(substr('%u', instr('%u', '@') + 1)) || '/__unregistered__/Maildir' AS mail, 5000 AS uid, 5000 AS gid, '*:storage=1024M' AS quota_rule WHERE EXISTS (SELECT 1 FROM system_settings WHERE key='catchAllEnabled' AND value='true') AND EXISTS (SELECT 1 FROM domains WHERE name=lower(substr('%u', instr('%u', '@') + 1)) AND status='active') AND NOT EXISTS (SELECT 1 FROM mailboxes m JOIN domains d ON d.id=m.domain_id WHERE d.name=lower(substr('%u', instr('%u', '@') + 1)) AND m.local_part=lower(CASE WHEN instr(substr('%u', 1, instr('%u', '@') - 1), '+') > 0 THEN substr(substr('%u', 1, instr('%u', '@') - 1), 1, instr(substr('%u', 1, instr('%u', '@') - 1), '+') - 1) ELSE substr('%u', 1, instr('%u', '@') - 1) END) AND m.status='active')
+29
View File
@@ -10,21 +10,42 @@ ssl_cert = </etc/ssl/certs/ssl-cert-snakeoil.pem
ssl_key = </etc/ssl/private/ssl-cert-snakeoil.key
mail_home = /var/mail/vhosts/%d/%n
mail_max_userip_connections = 10
recipient_delimiter = +
plugin {
quota = maildir:User quota
quota_rule = *:storage=1G
# auth_policy_server is configured via dovecot-sql.conf.ext user_query
}
auth_policy_server_url = http://127.0.0.1:8080/auth-policy
auth_policy_server_api_header = Content-Type: application/json
auth_policy_hash_mech = sha256
auth_policy_hash_truncate = 12
auth_policy_hash_nonce = __LANQIN_AUTH_POLICY_HASH_NONCE__
auth_policy_request_attributes = login=%{requested_username} remote=%{rip} protocol=%s
namespace inbox {
inbox = yes
mailbox Drafts {
auto = subscribe
special_use = \Drafts
}
mailbox Sent {
auto = subscribe
special_use = \Sent
}
mailbox Trash {
auto = subscribe
special_use = \Trash
}
mailbox Archive {
auto = subscribe
special_use = \Archive
}
mailbox Spam {
auto = subscribe
special_use = \Junk
}
}
@@ -38,6 +59,14 @@ userdb {
args = /etc/dovecot/dovecot-sql.conf.ext
}
protocol imap {
mail_plugins = quota imap_quota
}
protocol pop3 {
mail_plugins = quota
}
service imap-login {
inet_listener imaps {
port = 993
+9 -1
View File
@@ -4,8 +4,15 @@ set -eu
: "${LANQIN_TLS_KEY_FILE:=}"
addgroup --system --gid 5000 vmail 2>/dev/null || true
adduser --system --uid 5000 --gid 5000 --home /var/mail/vhosts --no-create-home vmail 2>/dev/null || true
mkdir -p /var/mail/vhosts
mkdir -p /data /var/mail/vhosts
chown -R 5000:5000 /var/mail/vhosts
AUTH_POLICY_NONCE_FILE="${LANQIN_AUTH_POLICY_NONCE_FILE:-/data/dovecot-auth-policy-nonce}"
mkdir -p "$(dirname "$AUTH_POLICY_NONCE_FILE")"
if [ ! -s "$AUTH_POLICY_NONCE_FILE" ]; then
od -An -tx1 -N32 /dev/urandom | tr -d ' \n' > "$AUTH_POLICY_NONCE_FILE"
fi
chmod 600 "$AUTH_POLICY_NONCE_FILE" 2>/dev/null || true
AUTH_POLICY_HASH_NONCE="$(cat "$AUTH_POLICY_NONCE_FILE")"
TLS_CERT=/etc/ssl/certs/ssl-cert-snakeoil.pem
TLS_KEY=/etc/ssl/private/ssl-cert-snakeoil.key
if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
@@ -18,4 +25,5 @@ if [ -n "$LANQIN_TLS_CERT_FILE" ] || [ -n "$LANQIN_TLS_KEY_FILE" ]; then
fi
sed -i "s#^ssl_cert = <.*#ssl_cert = <${TLS_CERT}#" /etc/dovecot/dovecot.conf
sed -i "s#^ssl_key = <.*#ssl_key = <${TLS_KEY}#" /etc/dovecot/dovecot.conf
sed -i "s#^auth_policy_hash_nonce = .*#auth_policy_hash_nonce = ${AUTH_POLICY_HASH_NONCE}#" /etc/dovecot/dovecot.conf
exec dovecot -F
+1 -1
View File
@@ -11,5 +11,5 @@ COPY master.cf /etc/postfix/master.cf
COPY sqlite-*.cf /etc/postfix/
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 25 465 587
EXPOSE 25
CMD ["/entrypoint.sh"]
+4 -6
View File
@@ -14,13 +14,11 @@ virtual_transport = lmtp:inet:dovecot:24
virtual_mailbox_base = /var/mail/vhosts
smtpd_banner = $myhostname ESMTP LanQin Email
smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
# Submission auth via Dovecot.
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:dovecot:12345
smtpd_sasl_auth_enable = yes
# 465/587 提交由 LanQin API 处理Postfix 25 只负责入站和内部 relay
smtpd_sasl_auth_enable = no
smtpd_tls_cert_file = /etc/ssl/certs/ssl-cert-snakeoil.pem
smtpd_tls_key_file = /etc/ssl/private/ssl-cert-snakeoil.key
smtpd_tls_security_level = may
-12
View File
@@ -1,16 +1,4 @@
smtp inet n - n - - smtpd
submission inet n - n - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=may
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
smtps inet n - n - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o sender_bcc_maps=sqlite:/etc/postfix/sqlite-sender-bcc.cf
pickup unix n - n 60 1 pickup
cleanup unix n - n - 0 cleanup
qmgr unix n - n 300 1 qmgr
-2
View File
@@ -1,2 +0,0 @@
dbpath = /data/lanqin.db
query = SELECT local_part || '+Sent@' || substr(address, instr(address, '@') + 1) FROM mailboxes WHERE lower(address)=lower('%s') AND status='active'
Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB