Compare commits

...

20 Commits

Author SHA1 Message Date
LanQin ed68abb00d feat(mail): 支持外部 IMAP 邮件翻译
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
- 新增外部账号邮件翻译接口与路由。
- 前端翻译请求优先走外部账号邮件翻译接口。
2026-06-27 13:30:31 +08:00
LanQin b8d4553036 feat(mail): 增加邮件翻译功能
- 新增邮件翻译接口与响应类型,支持按目标语言翻译当前邮件内容。
- 配置化控制翻译开关与最大字符数,避免超长内容影响请求。
- 在邮件详情页增加翻译提示与译文切换,支持简体中文、繁體中文和英文。
- 补充翻译响应解析与截断逻辑的单元测试。
2026-06-27 13:22:45 +08:00
LanQin 587bd77f6a docs(readme): 补充中文文档并更新英文说明
- 新增 `README.zh-CN.md`,完善简体中文使用说明。
- 重写 `README.md` 为英文版,并补充中英文入口链接。
- 统一整理功能特性、部署说明、环境变量和架构介绍。
2026-06-27 01:38:48 +08:00
LanQin ef7af15010 i18n(web): 新增界面语言切换与自动翻译支持
- 新增语言状态管理、存储同步与 DOM 自动翻译能力,支持简体中文、繁體中文和英文。
- 在邮件页加入语言切换入口,并接入全局语言同步。
- 补充大量邮件界面文案翻译映射,提升多语言可用性。
2026-06-27 01:36:15 +08:00
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
34 changed files with 6160 additions and 479 deletions
+122 -88
View File
@@ -1,5 +1,8 @@
# LanQin Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.md)
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.zh-CN.md)
![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)
@@ -9,49 +12,61 @@
![Dovecot](https://img.shields.io/badge/Dovecot-4B8BBE)
![Rspamd](https://img.shields.io/badge/Rspamd-FFD045)
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 APIWebNginxPostfixDovecot、Rspamd。
LanQin Email is a self-hosted full-stack webmail solution. The frontend is built with React + TypeScript + shadcn/ui, the backend uses Go + SQLite, and deployment can run as a single all-in-one container with API, Web, Nginx, Postfix, Dovecot, and Rspamd integrated.
## 功能特性
Community: [Telegram group](https://t.me/+EhII7MSyi3QwNDQ5)
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
- **邮件服务栈**Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
## Features
## 目录结构
- **Webmail client**: multiple mailbox switching, folders, reading and composing messages, drafts, scheduled sending, attachments, search, labels, stars, move/delete, read/unread status.
- **Mailbox enhancements**: contacts, signatures, inbox rules, sender blacklist, mail statistics, archive read messages, empty Trash/Spam.
- **Multi-domain / multi-mailbox**: domain management, DKIM key generation, DNS record display and checks, mailbox accounts, alias forwarding, catch-all toggle.
- **Accounts and permissions**: login/registration, session management, TOTP two-factor authentication, Cloudflare Turnstile, user self-service mailbox requests, permission groups/RBAC.
- **Admin panel**: overview checklist, user/permission group/domain/mailbox/alias/all-message management, system settings, mail templates, SMTP testing.
- **Mail service stack**: Postfix delivery, Dovecot IMAP/POP3, Rspamd anti-spam and DKIM signing, Maildir-to-SQLite sync.
- **Deployment friendly**: default all-in-one single container, plus a multi-container stack for debugging Postfix/Dovecot/Rspamd.
## UI Preview
| Webmail reading and list | Compose · rich-text toolbar |
| --- | --- |
| ![Webmail reading and list](./docs/screenshots/mail-preview.png) | ![Compose rich-text toolbar](./docs/screenshots/compose-preview.png) |
| Switch mailboxes, folders, search, labels, stars, and message reading panel. | Rich-text toolbar supports fonts, headings, bold, italic, underline, colors, highlights, lists, alignment, quotes, code blocks, attachments, emoji, and scheduled sending. |
| Admin panel · system overview | Third-party client configuration |
| ![Admin panel system overview](./docs/screenshots/admin-preview.png) | ![Third-party client configuration](./docs/screenshots/client-preview.png) |
| Manage users, permission groups, domains, mailboxes, aliases, system settings, and send audits. | View IMAP / POP3 / SMTP servers, ports, security modes, and account information in one place. |
## Repository Structure
```text
.
├── apps/api # Go APISQLite schema、邮件同步与业务逻辑
├── apps/web # React/Vite Webmail 与管理后台
├── deploy # Docker Compose、镜像构建、Postfix/Dovecot/Rspamd 配置
└── .github/workflows # Docker 镜像发布流水线
├── apps/api # Go API, SQLite schema, mail sync, and business logic
├── apps/web # React/Vite Webmail and admin panel
├── deploy # Docker Compose, image build, Postfix/Dovecot/Rspamd config
└── .github/workflows # Docker image release workflows
```
## 环境要求
## Requirements
### 开发环境
### Development
- Go 1.25+
- Node.js 20+
- pnpm 10.28.2(可通过 corepack 启用)
- pnpm 10.28.2 (can be enabled through corepack)
### 部署环境
### Deployment
- Docker Engine
- Docker Compose v2
- 可解析的邮件域名,以及可用的 25 / 465 / 587 / 993 / 995 等端口
- A resolvable mail domain, plus available ports such as 25 / 465 / 587 / 993 / 995
> 公网收发邮件还需要正确配置 MXSPFDKIM、DMARC,并确认云厂商未封禁 SMTP 端口。
> Public email sending and receiving also requires correct MX, SPF, DKIM, and DMARC records, and you should confirm that your cloud provider does not block SMTP ports.
## 快速开始
## Quick Start
### 本地开发
### Local Development
后端:
Backend:
```bash
cd apps/api
@@ -60,7 +75,7 @@ go test ./...
go run ./cmd/server
```
前端(新终端):
Frontend (new terminal):
```bash
cd apps/web
@@ -70,40 +85,40 @@ pnpm install
pnpm run dev
```
访问:
Open:
- Web`http://localhost:5173`
- API`http://localhost:8080`
- Web: `http://localhost:5173`
- API: `http://localhost:8080`
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
The default admin email is `admin@lanqin.local`. For development, explicitly set `LANQIN_ADMIN_PASSWORD`; if it is not set, the backend generates a random password on first startup and prints it to the logs.
### Docker 部署(单容器)
### Docker Deployment (single container)
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
A server only needs the Compose files and configuration under `deploy/`; building from source is not required:
```bash
cd deploy
cp .env.example .env
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
# Edit .env: domain, public URL, admin email, admin password, etc.
docker compose pull
docker compose up -d
```
常用命令:
Common commands:
```bash
# 查看日志
# View logs
docker compose logs -f lanqin-email
# 更新镜像并重启
# Pull the latest image and restart
docker compose pull
docker compose up -d
# 停止服务
# Stop services
docker compose down
```
如需在完整源码仓库中本地构建镜像:
To build the image locally from the full source repository:
```bash
cd deploy
@@ -111,49 +126,55 @@ cp .env.example .env
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
更多部署细节见 [`deploy/README.md`](./deploy/README.md)
See [`deploy/README.md`](./deploy/README.md) for more deployment details.
## 首次部署清单
## First Deployment Checklist
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. 在域名管理中复制并配置 MXSPFDKIM、DMARC 记录,然后点击 DNS 检测。
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FATurnstile、自助申请邮箱。
6. 使用后台 SMTP 测试与 Webmail 收发测试确认链路正常。
1. Edit `deploy/.env`: at minimum, change `LANQIN_PUBLIC_HOSTNAME`, `LANQIN_PUBLIC_BASE_URL`, `LANQIN_ADMIN_EMAIL`, and `LANQIN_ADMIN_PASSWORD`.
2. In production, mount real TLS certificates and set `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`.
3. Log in to the admin panel and add your mail domain.
4. Copy and configure MX, SPF, DKIM, and DMARC records from domain management, then run the DNS check.
5. Create mailbox accounts, alias forwarding, or permission groups; enable registration, 2FA, Turnstile, and self-service mailbox requests as needed.
6. Use the admin SMTP test and Webmail send/receive tests to confirm the full path works.
## 关键环境变量
## Key Environment Variables
完整配置见 [`deploy/.env.example`](./deploy/.env.example)。常用变量如下:
See [`deploy/.env.example`](./deploy/.env.example) for the full configuration. Common variables:
| 变量 | 说明 | 默认/示例 |
| Variable | Description | Default / 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_IMAGE` | All-in-one image | `ghcr.io/lanqin996/lanqin-email:latest` |
| `LANQIN_PUBLIC_HOSTNAME` | Mail server hostname; affects Postfix/DNS display/links | `mail.example.com` |
| `LANQIN_PUBLIC_BASE_URL` | Public Webmail URL | `https://mail.example.com` |
| `LANQIN_ADMIN_EMAIL` | Initial admin email | `admin@example.com` |
| `LANQIN_ADMIN_PASSWORD` | Initial admin password; must be changed in production | `ChangeMe123!` |
| `LANQIN_DB_PATH` | SQLite database path | `/data/lanqin.db` |
| `LANQIN_ALLOW_INSECURE_HTTP` | Allow non-HTTPS cookies; useful for local debugging | `false` |
| `LANQIN_OPEN_REGISTRATION` | Enable public registration | `false` |
| `LANQIN_TWO_FACTOR_ENABLED` | Global 2FA feature toggle | `false` |
| `LANQIN_TURNSTILE_ENABLED` | Enable Turnstile | `false` |
| `LANQIN_SMTP_HOST` / `LANQIN_SMTP_PORT` | Webmail outbound SMTP | `127.0.0.1` / `25` |
| `LANQIN_MAILDIR_ROOT` | Maildir root directory | `/var/mail/vhosts` |
| `LANQIN_CATCH_ALL_ENABLED` | Whether unregistered recipient addresses go into all messages | `false` |
| `LANQIN_USER_MAILBOX_APPLY_ENABLED` | Allow users to request mailboxes by themselves | `false` |
| `LANQIN_EXTERNAL_IMAP_ENABLED` | Enable external IMAP access; also configurable in Admin > System Settings > External IMAP | `false` |
| `LANQIN_EXTERNAL_IMAP_SECRET_KEY` | Encryption key for external IMAP passwords; required before enabling access; also configurable in admin | Random long string |
| `LANQIN_EXTERNAL_IMAP_SYNC_SECONDS` | Sync interval for external IMAP local-storage mode; also configurable in admin | `300` |
| `LANQIN_EXTERNAL_IMAP_ALLOW_PRIVATE_HOSTS` | Allow external IMAP to connect to private/localhost hosts; also configurable in admin | `false` |
| `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_GMAIL_CLIENT_SECRET` | Gmail external IMAP OAuth2; callback is `/api/external-imap-oauth/gmail/callback` | Empty |
| `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_ID` / `LANQIN_EXTERNAL_IMAP_OUTLOOK_CLIENT_SECRET` | Microsoft 365 / Outlook external IMAP OAuth2; callback is `/api/external-imap-oauth/outlook/callback` | Empty |
## 架构
## Architecture
```text
┌────────────────────────────────────────────────────────────┐
lanqin-email 单容器
│ lanqin-email single container
│ │
│ ┌─────────┐ ┌────────────┐ ┌──────────────┐ │
│ │ Nginx │ ───▶ │ Go API │ ───▶ │ SQLite /data │ │
│ │ Web 静态│ │ Webmail API│ └──────┬───────┘ │
└─────────┘ └─────┬──────┘ │ │
│ Maildir sync │ maps │
│ │ Web │ │ Webmail API│ └──────┬───────┘ │
│ static │ └─────┬──────┘ │ │
└─────────┘ │ Maildir sync │ maps │
│ ┌─────────┐ ┌─────▼──────┐ ┌──────▼───────┐ │
│ │ Rspamd │ ◀───▶ │ Postfix │ ───▶ │ Dovecot/LMTP │ │
│ │ DKIM/AS │ │ SMTP/MTA │ │ IMAP/POP3 │ │
@@ -161,46 +182,59 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
└────────────────────────────────────────────────────────────┘
```
邮件流转:
Mail flow:
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/587IMAP 993POP3 995 连接;生产环境请配置匹配 `LANQIN_PUBLIC_HOSTNAME` 的证书。
1. **Receiving**: Postfix receives mail → Rspamd scores/marks it → Dovecot writes to Maildir → API worker syncs it into SQLite → Webmail displays it.
2. **Sending**: Webmail calls the API → API builds MIME → SMTP submits to Postfix or an external SMTP server → mail is delivered to the destination.
3. **Local delivery**: In development, internal mailboxes can send directly into the recipient Inbox; if `LANQIN_SMTP_HOST` is not configured, external recipients are not actually delivered.
4. **Third-party clients**: Connect with SMTP 465/587, IMAP 993, or POP3 995; in production, configure certificates that match `LANQIN_PUBLIC_HOSTNAME`.
5. **External mailbox access**: Users can add external IMAP accounts in personal mailbox management. Local-storage mode syncs mail into the database; remote-direct mode reads from the remote server each time and does not write into local mail tables.
## 开发与验证
## Development and Verification
```bash
# API 测试
# API tests
cd apps/api
go test ./...
# Web 检查与构建
# Web checks and build
cd apps/web
pnpm run check
# 单容器源码构建验证
# Single-container source build verification
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## 生产注意事项
## Production Notes
- 生产环境必须修改默认管理员密码,并妥善保管 `.env`SQLite 数据库、Maildir DKIM 私钥。
- Web 可放在宿主机 Nginx/宝塔/边缘网关后,但 SMTP/IMAP/POP3 证书需要单独挂载给容器内 Postfix/Dovecot。
- 云厂商常默认封禁 25 端口;无法收发公网邮件时先检查端口、安全组、防火墙与反向 DNS
- SQLite 适合单机部署;多节点部署前需要迁移数据库,并同步调整 Postfix/Dovecot 查询配置。
- In production, always change the default admin password and protect `.env`, the SQLite database, Maildir, and DKIM private keys.
- The Web UI can sit behind host Nginx / aaPanel / an edge gateway, but SMTP/IMAP/POP3 certificates must be mounted separately for Postfix/Dovecot inside the container.
- Cloud providers often block port 25 by default; if public email does not send or receive, first check ports, security groups, firewalls, and reverse DNS.
- SQLite is suitable for single-node deployments; before multi-node deployment, migrate the database and adjust Postfix/Dovecot query configuration accordingly.
## SMTP 提交
## SMTP Submission
- 第三方客户端的 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` 去重。
- Third-party client SMTP submission on `465/587` is handled by the LanQin API process.
- Before enabling SMTP submission, configure `LANQIN_TLS_CERT_FILE` / `LANQIN_TLS_KEY_FILE`; the API will not expose 465/587 externally with a localhost self-signed certificate.
- Postfix only keeps port `25` for public inbound mail and internal/external relay.
- Webmail/API and third-party client sends are first written into Sent, then enter the send queue.
- The send queue is relayed by a LanQin API background worker to `LANQIN_SMTP_HOST:LANQIN_SMTP_PORT`; failures are audited and retried with backoff.
- v1 supports sending from the user's own mailbox. For send-as, use an enabled alias forwarding source that points to the user's mailbox, or configure `send_as_grants` in the database.
- If the client later writes its own Sent copy through IMAP APPEND, Maildir sync deduplicates by `Message-ID` within the Sent folder.
## License
[MIT](./LICENSE)
## Star History
<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>
Friends: [LINUX DO](https://linux.do/) — a new ideal community
+242
View File
@@ -0,0 +1,242 @@
# LanQin Email
[![English](https://img.shields.io/badge/English-README-blue)](./README.md)
[![简体中文](https://img.shields.io/badge/%E7%AE%80%E4%BD%93%E4%B8%AD%E6%96%87-README-green)](./README.zh-CN.md)
![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)
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、Rspamd。
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
## 功能特性
- **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 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
```
访问:
- Web`http://localhost:5173`
- API`http://localhost:8080`
默认管理员邮箱为 `admin@lanqin.local`。建议开发时显式设置 `LANQIN_ADMIN_PASSWORD`;如果未设置,后端首次启动会随机生成密码并输出到日志。
### Docker 部署(单容器)
服务器只需要 `deploy/` 下的 Compose 文件和配置,不需要源码构建:
```bash
cd deploy
cp .env.example .env
# 修改 .env:域名、访问地址、管理员邮箱、管理员密码等
docker compose pull
docker compose up -d
```
常用命令:
```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` | 空 |
## 架构
```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 接收邮件 → 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 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
## 开发与验证
```bash
# API 测试
cd apps/api
go test ./...
# Web 检查与构建
cd apps/web
pnpm run check
# 单容器源码构建验证
cd deploy
docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
```
## 生产注意事项
- 生产环境必须修改默认管理员密码,并妥善保管 `.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/) —— 新的理想型社区
+3
View File
@@ -13,6 +13,8 @@ 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
@@ -22,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
+37
View File
@@ -2,6 +2,10 @@ 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=
@@ -26,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=
+9 -1
View File
@@ -1149,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,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, id, mailboxID, folder, role, a.newUIDValidity(), 1, 1, 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
}
+90 -5
View File
@@ -15,6 +15,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
@@ -28,7 +29,9 @@ type App struct {
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) {
@@ -49,6 +52,7 @@ 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(), maildirHealth: newMaildirSyncHealthTracker()}
a.externalIMAP = a
if err := a.configureSQLite(context.Background()); err != nil {
db.Close()
return nil, err
@@ -71,15 +75,24 @@ func New(cfg Config, logger *slog.Logger) (*App, error) {
}
workerCtx, cancel := context.WithCancel(context.Background())
a.workerCancel = cancel
go a.scheduledSendWorker(workerCtx)
a.startWorker(func() { a.scheduledSendWorker(workerCtx) })
if strings.TrimSpace(a.cfg.MaildirRoot) != "" {
go a.maildirWorker(workerCtx)
a.startWorker(func() { a.maildirWorker(workerCtx) })
}
go a.sendQueueWorker(workerCtx)
go a.smtpEventsCleanupWorker(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
@@ -87,6 +100,7 @@ func (a *App) Close() error {
if a.workerCancel != nil {
a.workerCancel()
}
a.workerWG.Wait()
return a.db.Close()
}
@@ -202,6 +216,7 @@ 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,
@@ -343,6 +358,70 @@ func (a *App) migrate(ctx context.Context) error {
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,
@@ -448,6 +527,12 @@ func (a *App) migrate(ctx context.Context) error {
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
}
@@ -1131,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,uid_validity,uid_next,highest_modseq,created_at) VALUES(?,?,?,?,?,?,?,?)`, newID("fld"), id, f.name, f.role, a.newUIDValidity(), 1, 1, 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
}
+658 -43
View File
@@ -13,6 +13,7 @@ import (
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"io"
"log/slog"
"math/big"
@@ -30,6 +31,7 @@ import (
"github.com/emersion/go-sasl"
smtpclient "github.com/emersion/go-smtp"
"golang.org/x/crypto/bcrypt"
"golang.org/x/oauth2"
)
func newTestApp(t *testing.T) *App {
@@ -47,6 +49,11 @@ func newTestApp(t *testing.T) *App {
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
}
return newTestAppWithConfig(t, cfg)
}
func newTestAppWithConfig(t *testing.T, cfg Config) *App {
t.Helper()
a, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatal(err)
@@ -55,6 +62,13 @@ func newTestApp(t *testing.T) *App {
return a
}
func stopTestWorkers(a *App) {
if a != nil && a.workerCancel != nil {
a.workerCancel()
a.workerWG.Wait()
}
}
func defaultAdminUserAndMailbox(t *testing.T, a *App) (*User, *Mailbox) {
t.Helper()
ctx := context.Background()
@@ -294,28 +308,36 @@ func updateRegularPermissionGroupWithLimits(t *testing.T, admin *testClient, per
func systemSettingsPayload(settings SystemSettings) map[string]any {
return map[string]any{
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": settings.CatchAllEnabled,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
"userMailboxApplyEnabled": settings.UserMailboxApplyEnabled,
"userMailboxDomainIds": settings.UserMailboxDomainIDs,
"reservedMailboxPrefixes": settings.ReservedMailboxPrefixes,
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": settings.CatchAllEnabled,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
"userMailboxApplyEnabled": settings.UserMailboxApplyEnabled,
"userMailboxDomainIds": settings.UserMailboxDomainIDs,
"reservedMailboxPrefixes": settings.ReservedMailboxPrefixes,
"externalImapEnabled": settings.ExternalIMAPEnabled,
"externalImapSecretKey": "",
"externalImapSyncSeconds": settings.ExternalIMAPSyncSeconds,
"externalImapAllowPrivateHosts": settings.ExternalIMAPAllowPrivateHosts,
"externalImapGmailClientId": settings.ExternalIMAPGmailClientID,
"externalImapGmailClientSecret": "",
"externalImapOutlookClientId": settings.ExternalIMAPOutlookClientID,
"externalImapOutlookClientSecret": "",
}
}
@@ -423,6 +445,290 @@ func TestAuthAdminAndLocalDeliveryFlow(t *testing.T) {
}
}
func TestExternalIMAPAccountEncryptsPasswordAndDoesNotReturnSecret(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true,
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var created ExternalIMAPAccount
payload := map[string]any{"mailboxId": mb.ID, "name": "Gmail", "host": "imap.gmail.com", "port": 993, "tlsMode": "tls", "username": "user@gmail.com", "password": "app-password", "storageMode": "remote", "syncReadState": true, "enabled": true}
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
t.Fatalf("create external imap code=%d account=%+v", code, created)
}
raw, err := json.Marshal(created)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "app-password") {
t.Fatalf("external account response leaked password: %s", string(raw))
}
var ciphertext string
if err := a.db.QueryRow(`SELECT password_ciphertext FROM external_imap_accounts WHERE id=?`, created.ID).Scan(&ciphertext); err != nil {
t.Fatal(err)
}
if ciphertext == "" || ciphertext == "app-password" {
t.Fatalf("password was not encrypted: %q", ciphertext)
}
plain, err := a.decryptExternalIMAPPassword(ciphertext)
if err != nil || plain != "app-password" {
t.Fatalf("decrypt password=%q err=%v", plain, err)
}
var list struct {
Items []map[string]any `json:"items"`
}
if code := admin.do("GET", "/api/me/external-imap-accounts?mailboxId="+mb.ID, nil, &list); code != http.StatusOK || len(list.Items) != 1 {
t.Fatalf("list external imap code=%d items=%+v", code, list.Items)
}
if _, ok := list.Items[0]["password"]; ok {
t.Fatalf("list response exposed password field: %+v", list.Items[0])
}
}
func TestExternalIMAPDisabledByDefaultAndAdminSettings(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
payload := map[string]any{"mailboxId": mb.ID, "name": "Disabled", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "user@example.com", "password": "secret", "storageMode": "remote"}
var body map[string]any
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &body); code != http.StatusForbidden {
t.Fatalf("external imap should be disabled by default code=%d body=%v", code, body)
}
var public PublicSettings
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || public.ExternalIMAPEnabled {
t.Fatalf("public settings should expose disabled external imap code=%d settings=%+v", code, public)
}
var settings SystemSettings
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
update := systemSettingsPayload(settings)
update["externalImapEnabled"] = true
if code := admin.do("POST", "/api/admin/settings", update, &body); code != http.StatusBadRequest {
t.Fatalf("enable without secret should fail code=%d body=%v", code, body)
}
update["externalImapSecretKey"] = "test-secret"
update["externalImapSyncSeconds"] = 120
update["externalImapAllowPrivateHosts"] = true
update["externalImapGmailClientId"] = "gmail-client"
update["externalImapGmailClientSecret"] = "gmail-secret"
update["externalImapOutlookClientId"] = "outlook-client"
update["externalImapOutlookClientSecret"] = "outlook-secret"
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.ExternalIMAPEnabled || !settings.ExternalIMAPSecretSet || settings.ExternalIMAPSyncSeconds != 120 || !settings.ExternalIMAPAllowPrivateHosts || !settings.ExternalIMAPGmailClientSecretSet || !settings.ExternalIMAPOutlookClientSecretSet {
t.Fatalf("enable external imap code=%d settings=%+v", code, settings)
}
if settings.ExternalIMAPGmailClientID != "gmail-client" || settings.ExternalIMAPOutlookClientID != "outlook-client" {
t.Fatalf("oauth client ids not saved: %+v", settings)
}
if a.cfg.ExternalIMAPSecretKey != "test-secret" || a.cfg.ExternalIMAPGmailClientSecret != "gmail-secret" || a.cfg.ExternalIMAPOutlookClientSecret != "outlook-secret" {
t.Fatalf("secret settings not persisted in config")
}
if code := admin.do("GET", "/api/public/settings", nil, &public); code != http.StatusOK || !public.ExternalIMAPEnabled {
t.Fatalf("public settings should expose enabled external imap code=%d settings=%+v", code, public)
}
}
func TestExternalIMAPRejectsPrivateHostsByDefault(t *testing.T) {
a := newTestApp(t)
a.cfg.ExternalIMAPEnabled = true
a.cfg.ExternalIMAPSecretKey = "test-secret"
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var out map[string]any
payload := map[string]any{"mailboxId": mb.ID, "name": "Local", "host": "127.0.0.1", "port": 143, "tlsMode": "plain", "username": "local", "password": "secret", "storageMode": "remote"}
if code := admin.do("POST", "/api/me/external-imap-accounts", payload, &out); code != http.StatusBadRequest {
t.Fatalf("private host should be rejected code=%d body=%v", code, out)
}
}
func TestExternalIMAPOAuthStateDoesNotDefaultToLocalMailbox(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPOutlookClientID: "client-id",
ExternalIMAPOutlookClientSecret: "client-secret",
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
_, mb := defaultAdminUserAndMailbox(t, a)
var start struct {
URL string `json:"url"`
}
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "storageMode": "local", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
t.Fatalf("start oauth code=%d url=%q", code, start.URL)
}
stateValue := mustOAuthStateFromURL(t, start.URL)
state, err := a.decryptExternalIMAPOAuthState(stateValue)
if err != nil {
t.Fatal(err)
}
if state.Email != "" {
t.Fatalf("oauth state defaulted to local mailbox email: %q", state.Email)
}
if code := admin.do("POST", "/api/me/external-imap-oauth/outlook/start", map[string]any{"mailboxId": mb.ID, "email": "User@Example.COM", "storageMode": "remote", "syncReadState": true, "enabled": true}, &start); code != http.StatusOK {
t.Fatalf("start oauth with email code=%d url=%q", code, start.URL)
}
stateValue = mustOAuthStateFromURL(t, start.URL)
state, err = a.decryptExternalIMAPOAuthState(stateValue)
if err != nil {
t.Fatal(err)
}
if state.Email != "user@example.com" {
t.Fatalf("oauth state did not preserve requested external email, got %q", state.Email)
}
}
func TestExternalIMAPOAuthEmailFromIDToken(t *testing.T) {
token := (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
"id_token": testIDToken(map[string]any{"preferred_username": "User@Example.COM"}),
})
email, err := externalIMAPOAuthEmail(externalIMAPOAuthOutlook, token)
if err != nil {
t.Fatal(err)
}
if email != "user@example.com" {
t.Fatalf("unexpected outlook oauth email %q", email)
}
token = (&oauth2.Token{AccessToken: "access"}).WithExtra(map[string]any{
"id_token": testIDToken(map[string]any{"email": "Person@Gmail.COM"}),
})
email, err = externalIMAPOAuthEmail(externalIMAPOAuthGmail, token)
if err != nil {
t.Fatal(err)
}
if email != "person@gmail.com" {
t.Fatalf("unexpected gmail oauth email %q", email)
}
}
func TestExternalIMAPXOAUTH2ClientFormat(t *testing.T) {
client := newExternalIMAPXOAUTH2Client("user@example.com", "access-token")
mech, initialResponse, err := client.Start()
if err != nil {
t.Fatal(err)
}
if mech != "XOAUTH2" {
t.Fatalf("mechanism=%q, want XOAUTH2", mech)
}
want := "user=user@example.com\x01auth=Bearer access-token\x01\x01"
if string(initialResponse) != want {
t.Fatalf("initial response=%q, want %q", string(initialResponse), want)
}
}
func mustOAuthStateFromURL(t *testing.T, rawURL string) string {
t.Helper()
u, err := url.Parse(rawURL)
if err != nil {
t.Fatal(err)
}
state := u.Query().Get("state")
if state == "" {
t.Fatalf("oauth url missing state: %s", rawURL)
}
return state
}
func testIDToken(claims map[string]any) string {
header, _ := json.Marshal(map[string]any{"alg": "none"})
payload, _ := json.Marshal(claims)
return base64.RawURLEncoding.EncodeToString(header) + "." + base64.RawURLEncoding.EncodeToString(payload) + "."
}
func TestExternalIMAPAccountOwnershipIsolation(t *testing.T) {
dir := t.TempDir()
a := newTestAppWithConfig(t, Config{
Addr: ":0",
DBPath: filepath.Join(dir, "lanqin.db"),
DataDir: filepath.Join(dir, "data"),
CookieName: "lanqin_test",
SessionTTLHours: 24,
AdminEmail: "admin@lanqin.local",
AdminPassword: "ChangeMe123!",
PublicHostname: "mail.example.test",
PublicBaseURL: "http://localhost:5173",
AllowInsecureHTTP: true,
ExternalIMAPEnabled: true,
ExternalIMAPSecretKey: "test-secret",
ExternalIMAPAllowPrivateHosts: true,
})
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, nil); code != http.StatusOK {
t.Fatalf("login admin code=%d", code)
}
domainID := mustDefaultDomainID(t, a)
owner := createTestMailbox(t, admin, domainID, "ximap-owner", "Owner", "Password123!", nil)
other := createTestMailbox(t, admin, domainID, "ximap-other", "Other", "Password123!", nil)
ownerClient := &testClient{t: t, server: ts}
if code := ownerClient.do("POST", "/api/auth/login", map[string]string{"email": owner.Address, "password": "Password123!"}, nil); code != http.StatusOK {
t.Fatalf("login owner code=%d", code)
}
otherClient := &testClient{t: t, server: ts}
if code := otherClient.do("POST", "/api/auth/login", map[string]string{"email": other.Address, "password": "Password123!"}, nil); code != http.StatusOK {
t.Fatalf("login other code=%d", code)
}
var created ExternalIMAPAccount
payload := map[string]any{"mailboxId": owner.ID, "name": "Owner external", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "owner@example.com", "password": "secret", "storageMode": "remote"}
if code := ownerClient.do("POST", "/api/me/external-imap-accounts", payload, &created); code != http.StatusCreated {
t.Fatalf("create code=%d account=%+v", code, created)
}
var denied map[string]any
if code := otherClient.do("POST", "/api/me/external-imap-accounts/"+created.ID, map[string]any{"mailboxId": other.ID, "name": "steal", "host": "imap.example.com", "port": 993, "tlsMode": "tls", "username": "other@example.com", "storageMode": "remote"}, &denied); code != http.StatusNotFound {
t.Fatalf("cross-user update should be hidden code=%d body=%v", code, denied)
}
if code := otherClient.do("DELETE", "/api/me/external-imap-accounts/"+created.ID, nil, &denied); code != http.StatusNotFound {
t.Fatalf("cross-user delete should be hidden code=%d body=%v", code, denied)
}
}
func TestParseMailAuthenticationResults(t *testing.T) {
header := textproto.MIMEHeader{}
header.Add("Authentication-Results", "mx.example.test; spf=pass smtp.mailfrom=sender.example; dkim=fail (bad signature) header.d=sender.example; dmarc=none")
@@ -998,6 +1304,182 @@ func TestUserCanSelectMultipleMailboxes(t *testing.T) {
}
}
func TestCustomMailFoldersCreateAndMove(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var bad map[string]any
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "Inbox"}, &bad); code != http.StatusBadRequest {
t.Fatalf("system folder create should be rejected code=%d body=%v", code, bad)
}
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "../bad"}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid folder create should be rejected code=%d body=%v", code, bad)
}
var custom MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "客户归档"}, &custom); code != http.StatusCreated || custom.Name != "客户归档" || custom.Role != "客户归档" {
t.Fatalf("custom folder create code=%d folder=%+v", code, custom)
}
var folders struct {
Items []MailFolder `json:"items"`
}
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || !folderListContains(folders.Items, "客户归档") {
t.Fatalf("folder list code=%d items=%+v", code, folders.Items)
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "custom folder", "text": "body"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d msg=%+v", code, sent)
}
var ok map[string]any
if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "客户归档"}, &ok); code != http.StatusOK {
t.Fatalf("move to custom folder code=%d body=%v", code, ok)
}
var list struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/mail/messages?folder="+url.QueryEscape("客户归档"), nil, &list); code != http.StatusOK || len(list.Items) != 1 || list.Items[0].ID != sent.ID {
t.Fatalf("custom folder messages code=%d items=%+v", code, list.Items)
}
if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "bad/name"}, &bad); code != http.StatusBadRequest {
t.Fatalf("invalid move folder should be rejected code=%d body=%v", code, bad)
}
}
func TestCustomMailFoldersDeleteMovesMessagesToInbox(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
var custom MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": "临时项目"}, &custom); code != http.StatusCreated {
t.Fatalf("create custom folder code=%d folder=%+v", code, custom)
}
var sent MailMessage
if code := admin.do("POST", "/api/mail/send", map[string]any{"to": []string{"person@example.test"}, "subject": "delete folder keeps message", "text": "body"}, &sent); code != http.StatusCreated {
t.Fatalf("send code=%d msg=%+v", code, sent)
}
var ok map[string]any
if code := admin.do("POST", "/api/mail/messages/"+sent.ID+"/move", map[string]string{"folder": "临时项目"}, &ok); code != http.StatusOK {
t.Fatalf("move to custom folder code=%d body=%v", code, ok)
}
if code := admin.do("DELETE", "/api/mail/folders/"+custom.ID, nil, &ok); code != http.StatusOK {
t.Fatalf("delete custom folder code=%d body=%v", code, ok)
}
var folders struct {
Items []MailFolder `json:"items"`
}
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK || folderListContains(folders.Items, "临时项目") {
t.Fatalf("folder should be deleted code=%d items=%+v", code, folders.Items)
}
var inbox struct {
Items []MailMessage `json:"items"`
}
if code := admin.do("GET", "/api/mail/messages?folder=Inbox&q="+url.QueryEscape("delete folder keeps message"), nil, &inbox); code != http.StatusOK || len(inbox.Items) == 0 || inbox.Items[0].ID != sent.ID {
t.Fatalf("message should be moved to inbox code=%d items=%+v", code, inbox.Items)
}
var bad map[string]any
var inboxID string
for _, item := range folders.Items {
if item.Name == "Inbox" {
inboxID = item.ID
break
}
}
if inboxID == "" {
t.Fatalf("inbox id not found")
}
if code := admin.do("DELETE", "/api/mail/folders/"+inboxID, nil, &bad); code != http.StatusBadRequest {
t.Fatalf("delete system folder should be rejected code=%d body=%v", code, bad)
}
}
func TestCustomMailFoldersReorder(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
defer ts.Close()
admin := &testClient{t: t, server: ts}
var login map[string]any
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d body=%v", code, login)
}
createFolder := func(name string) MailFolder {
t.Helper()
var folder MailFolder
if code := admin.do("POST", "/api/mail/folders", map[string]string{"name": name}, &folder); code != http.StatusCreated {
t.Fatalf("create folder %s code=%d folder=%+v", name, code, folder)
}
return folder
}
customer := createFolder("客户")
bills := createFolder("账单")
project := createFolder("项目")
var ok map[string]any
if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, customer.ID, bills.ID}}, &ok); code != http.StatusOK {
t.Fatalf("reorder code=%d body=%v", code, ok)
}
if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folders": []map[string]any{
{"id": customer.ID, "sortOrder": 500},
{"id": project.ID, "sortOrder": 2500},
{"id": bills.ID, "sortOrder": 3500},
}}, &ok); code != http.StatusOK {
t.Fatalf("reorder with explicit sort order code=%d body=%v", code, ok)
}
var folders struct {
Items []MailFolder `json:"items"`
}
if code := admin.do("GET", "/api/mail/folders", nil, &folders); code != http.StatusOK {
t.Fatalf("list folders code=%d items=%+v", code, folders.Items)
}
got := customFolderNames(folders.Items)
want := []string{"客户", "项目", "账单"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("custom folder order=%v want=%v", got, want)
}
if folders.Items[0].ID != customer.ID {
t.Fatalf("customer folder should be before inbox after explicit sort order, first=%+v", folders.Items[0])
}
var inboxID string
for _, item := range folders.Items {
if item.Name == "Inbox" {
inboxID = item.ID
break
}
}
if inboxID == "" {
t.Fatalf("inbox not found in folders: %+v", folders.Items)
}
var bad map[string]any
if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, inboxID, customer.ID, bills.ID}}, &bad); code != http.StatusBadRequest {
t.Fatalf("reorder with system folder should be rejected code=%d body=%v", code, bad)
}
domain := createTestDomain(t, admin, "folders.test")
other := createTestMailbox(t, admin, domain.ID, "other", "Other", "Password123!", nil)
otherFolderID, err := a.ensureFolder(context.Background(), other.ID, "其他")
if err != nil {
t.Fatal(err)
}
if code := admin.do("POST", "/api/mail/folders/reorder", map[string]any{"folderIds": []string{project.ID, customer.ID, otherFolderID}}, &bad); code != http.StatusBadRequest {
t.Fatalf("reorder with other mailbox folder should be rejected code=%d body=%v", code, bad)
}
}
func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
a := newTestApp(t)
ts := httptest.NewServer(a.Router())
@@ -1035,27 +1517,8 @@ func TestCatchAllStoresUnregisteredMailForAdminOnly(t *testing.T) {
if code := admin.do("GET", "/api/admin/settings", nil, &settings); code != http.StatusOK {
t.Fatalf("get settings code=%d", code)
}
update := map[string]any{
"publicHostname": settings.PublicHostname,
"publicBaseUrl": settings.PublicBaseURL,
"smtpHost": settings.SMTPHost,
"smtpPort": settings.SMTPPort,
"smtpUsername": settings.SMTPUsername,
"smtpPassword": "",
"smtpRequireTls": settings.SMTPRequireTLS,
"maildirRoot": settings.MaildirRoot,
"maildirScanSeconds": settings.MaildirScanSeconds,
"sessionTtlHours": settings.SessionTTLHours,
"allowInsecureHttp": settings.AllowInsecureHTTP,
"openRegistration": settings.OpenRegistration,
"twoFactorEnabled": settings.TwoFactorEnabled,
"turnstileEnabled": settings.TurnstileEnabled,
"turnstileSiteKey": settings.TurnstileSiteKey,
"turnstileSecretKey": "",
"catchAllEnabled": true,
"mailAutoRefresh": settings.MailAutoRefresh,
"mailRefreshSeconds": settings.MailRefreshSeconds,
}
update := systemSettingsPayload(settings)
update["catchAllEnabled"] = true
if code := admin.do("POST", "/api/admin/settings", update, &settings); code != http.StatusOK || !settings.CatchAllEnabled {
t.Fatalf("enable catch-all code=%d settings=%+v", code, settings)
}
@@ -1188,6 +1651,7 @@ func TestMailSendRollsBackSentCopyWhenQueueInsertFails(t *testing.T) {
func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
@@ -1237,6 +1701,7 @@ func TestSendQueueRecoversStaleSendingItems(t *testing.T) {
func TestSendQueueStaleDeliveredMarkerDoesNotRedeliver(t *testing.T) {
a := newTestApp(t)
stopTestWorkers(a)
host, port, received := startCapturingSMTP(t, 1)
a.cfg.SMTPHost = host
a.cfg.SMTPPort = port
@@ -1365,6 +1830,137 @@ func TestSendQueueAPIPermissionIsolation(t *testing.T) {
}
}
func TestSendQueueAPIFiltersStableCursorAndMessageDetailLink(t *testing.T) {
a := newTestApp(t)
a.cfg.SMTPHost = "127.0.0.1"
a.cfg.SMTPPort = "25"
ts := httptest.NewServer(a.Router())
defer ts.Close()
client := &testClient{t: t, server: ts}
var login map[string]any
if code := client.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
t.Fatalf("login code=%d", code)
}
user, mb := defaultAdminUserAndMailbox(t, a)
now := a.now().UTC()
sentFolderID, err := a.ensureFolder(context.Background(), mb.ID, "Sent")
if err != nil {
t.Fatal(err)
}
sentMsg := storedMessage{
MailboxID: mb.ID,
FolderID: sentFolderID,
MessageUID: "uid-queue-detail",
MessageID: "<queue-detail@example.test>",
Subject: "queue detail",
From: mb.Address,
To: []string{"detail@example.test"},
SentAt: now,
ReceivedAt: now,
Snippet: "detail",
BodyText: "detail",
IsRead: true,
}
sentID, err := a.insertMessage(context.Background(), sentMsg, nil)
if err != nil {
t.Fatal(err)
}
firstID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
SentMessageID: sentID,
MessageID: "<queue-detail@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"detail@example.test"},
MIMEBytes: []byte("Subject: detail\r\n\r\nbody"),
Now: now.Add(-2 * time.Hour),
})
if err != nil {
t.Fatal(err)
}
secondID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: "<queue-other@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"other@example.test"},
MIMEBytes: []byte("Subject: other\r\n\r\nbody"),
Now: now.Add(-1 * time.Hour),
})
if err != nil {
t.Fatal(err)
}
thirdID, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: "<queue-latest@example.test>",
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{"latest@example.test"},
MIMEBytes: []byte("Subject: latest\r\n\r\nbody"),
Now: now,
})
if err != nil {
t.Fatal(err)
}
for i := 0; i < 28; i++ {
if _, err := a.enqueueSend(context.Background(), sendQueueInput{
UserID: user.ID,
MailboxID: mb.ID,
MessageID: fmt.Sprintf("<queue-extra-%02d@example.test>", i),
Source: sendSourceWebmail,
MailFrom: mb.Address,
HeaderFrom: mb.Address,
Recipients: []string{fmt.Sprintf("extra-%02d@example.test", i)},
MIMEBytes: []byte("Subject: extra\r\n\r\nbody"),
Now: now.Add(time.Duration(-24-i) * time.Hour),
}); err != nil {
t.Fatal(err)
}
}
var byMessage struct {
Items []SendQueueEntry `json:"items"`
}
if code := client.do("GET", "/api/mail/send-queue?messageId="+url.QueryEscape("<queue-detail@example.test>"), nil, &byMessage); code != http.StatusOK || len(byMessage.Items) != 1 || byMessage.Items[0].ID != firstID {
t.Fatalf("message filter code=%d items=%+v", code, byMessage.Items)
}
var byRecipient struct {
Items []SendQueueEntry `json:"items"`
}
if code := client.do("GET", "/api/mail/send-queue?recipient="+url.QueryEscape("other@example.test"), nil, &byRecipient); code != http.StatusOK || len(byRecipient.Items) != 1 || byRecipient.Items[0].ID != secondID {
t.Fatalf("recipient filter code=%d items=%+v", code, byRecipient.Items)
}
var byTime struct {
Items []SendQueueEntry `json:"items"`
}
from := now.Add(-90 * time.Minute).Format(time.RFC3339Nano)
to := now.Add(30 * time.Minute).Format(time.RFC3339Nano)
if code := client.do("GET", "/api/mail/send-queue?from="+url.QueryEscape(from)+"&to="+url.QueryEscape(to), nil, &byTime); code != http.StatusOK || len(byTime.Items) != 2 || byTime.Items[0].ID != thirdID || byTime.Items[1].ID != secondID {
t.Fatalf("time filter code=%d items=%+v", code, byTime.Items)
}
var firstPage struct {
Items []SendQueueEntry `json:"items"`
NextCursor string `json:"nextCursor"`
}
if code := client.do("GET", "/api/mail/send-queue?cursor=0", nil, &firstPage); code != http.StatusOK || len(firstPage.Items) != 30 || firstPage.NextCursor == "" {
t.Fatalf("first page code=%d cursor=%q items=%+v", code, firstPage.NextCursor, firstPage.Items)
}
if _, _, _, err := parseSendQueueCursor(firstPage.NextCursor); err != nil {
t.Fatalf("next cursor is not stable cursor: %q err=%v", firstPage.NextCursor, err)
}
var detail MailMessage
if code := client.do("GET", "/api/mail/messages/"+sentID+"?markRead=0", nil, &detail); code != http.StatusOK || detail.SendQueueID != firstID || detail.SendQueueStatus == "" {
t.Fatalf("message detail queue link code=%d detail=%+v", code, detail)
}
}
func TestSendQueueAPIRetryAndCancel(t *testing.T) {
a := newTestApp(t)
host, port, received := startCapturingSMTP(t, 1)
@@ -3745,6 +4341,25 @@ func containsString(items []string, needle string) bool {
return false
}
func folderListContains(items []MailFolder, name string) bool {
for _, item := range items {
if item.Name == name {
return true
}
}
return false
}
func customFolderNames(items []MailFolder) []string {
names := []string{}
for _, item := range items {
if !isSystemFolderName(item.Name) {
names = append(names, item.Name)
}
}
return names
}
func withoutPermissions(items []string, removed ...string) []string {
removedSet := map[string]bool{}
for _, item := range removed {
+86 -66
View File
@@ -8,77 +8,97 @@ 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
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
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
MailTranslateEnabled bool
MailTranslateMaxChars int
}
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),
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"),
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", ""),
MailTranslateEnabled: getenvBool("LANQIN_MAIL_TRANSLATE_ENABLED", true),
MailTranslateMaxChars: getenvInt("LANQIN_MAIL_TRANSLATE_MAX_CHARS", 8000),
}
}
File diff suppressed because it is too large Load Diff
+32
View File
@@ -44,6 +44,38 @@ func (a *App) migrateIMAPMetadata(ctx context.Context) error {
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 {
+412 -12
View File
@@ -22,6 +22,8 @@ import (
// mailMessagesPageSize is the max number of messages returned per page in mail listing.
const mailMessagesPageSize = 30
const customFolderDefaultSortOrderBase = 100000
type AttachmentInput struct {
Filename string `json:"filename"`
ContentType string `json:"contentType"`
@@ -87,10 +89,18 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.uid_validity,f.uid_next,f.highest_modseq
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.mailbox_id=? 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, f.name`, mb.ID)
WHERE f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
ORDER BY CASE
WHEN lower(f.name)='inbox' THEN 1000
WHEN lower(f.name)='sent' THEN 5000
WHEN lower(f.name)='drafts' THEN 6000
WHEN lower(f.name)='archive' THEN 7000
WHEN lower(f.name)='spam' THEN 8000
WHEN lower(f.name)='trash' THEN 9000
ELSE f.sort_order
END, f.created_at,f.name`, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
@@ -99,7 +109,7 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
items := []MailFolder{}
for rows.Next() {
var f MailFolder
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
if err := rows.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
@@ -108,6 +118,232 @@ func (a *App) handleMailFolders(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"items": items})
}
func (a *App) handleReorderMailFolders(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
FolderIDs []string `json:"folderIds"`
Folders []struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
} `json:"folders"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
seen := map[string]bool{}
if len(req.Folders) == 0 {
for i, id := range req.FolderIDs {
id = strings.TrimSpace(id)
req.Folders = append(req.Folders, struct {
ID string `json:"id"`
SortOrder int `json:"sortOrder"`
}{ID: id, SortOrder: customFolderDefaultSortOrderBase + i + 1})
}
}
for i := range req.Folders {
req.Folders[i].ID = strings.TrimSpace(req.Folders[i].ID)
if req.Folders[i].ID == "" || req.Folders[i].SortOrder <= 0 || seen[req.Folders[i].ID] {
badRequest(w, errors.New("invalid folder order"))
return
}
seen[req.Folders[i].ID] = true
}
if len(req.Folders) == 0 {
badRequest(w, errors.New("folderIds is required"))
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name FROM folders WHERE mailbox_id=?`, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folders")
return
}
customIDs := map[string]bool{}
for rows.Next() {
var id, name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
if !isSystemFolderName(name) {
customIDs[id] = true
}
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folders")
return
}
rows.Close()
if len(customIDs) != len(req.Folders) {
badRequest(w, errors.New("invalid folder order"))
return
}
for _, item := range req.Folders {
if !customIDs[item.ID] {
badRequest(w, errors.New("invalid folder order"))
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
return
}
defer tx.Rollback()
for _, item := range req.Folders {
res, err := tx.ExecContext(r.Context(), `UPDATE folders SET sort_order=? WHERE id=? AND mailbox_id=?`, item.SortOrder, item.ID, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
return
}
if affected, err := res.RowsAffected(); err != nil || affected != 1 {
badRequest(w, errors.New("invalid folder order"))
return
}
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to reorder folders")
return
}
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) handleCreateMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
var req struct {
Name string `json:"name"`
}
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
name, err := normalizeCustomFolderName(req.Name)
if err != nil {
badRequest(w, err)
return
}
if isSystemFolderName(name) {
badRequest(w, errors.New("system folder already exists"))
return
}
folderID, err := a.ensureCustomFolder(r.Context(), mb.ID, name)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create folder")
return
}
folder, err := a.folderByID(r.Context(), folderID, mb.ID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
respondJSON(w, http.StatusCreated, folder)
}
func (a *App) handleDeleteMailFolder(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
respondError(w, http.StatusNotFound, "mailbox not found")
return
}
folderID := strings.TrimSpace(chi.URLParam(r, "id"))
if folderID == "" {
badRequest(w, errors.New("folder id is required"))
return
}
var folderName string
if err := a.db.QueryRowContext(r.Context(), `SELECT name FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID).Scan(&folderName); err != nil {
if errors.Is(err, sql.ErrNoRows) {
respondError(w, http.StatusNotFound, "folder not found")
return
}
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
}
if isSystemFolderName(folderName) {
badRequest(w, errors.New("system folders cannot be deleted"))
return
}
inboxID, err := a.ensureFolder(r.Context(), mb.ID, "Inbox")
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load inbox")
return
}
now := a.now().UTC().Format(time.RFC3339Nano)
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
defer tx.Rollback()
rows, err := tx.QueryContext(r.Context(), `SELECT id FROM messages WHERE mailbox_id=? AND folder_id=? ORDER BY received_at,id`, mb.ID, folderID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder messages")
return
}
var messageIDs []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
messageIDs = append(messageIDs, id)
}
if err := rows.Err(); err != nil {
rows.Close()
respondError(w, http.StatusInternalServerError, "failed to scan folder messages")
return
}
rows.Close()
for _, messageID := range messageIDs {
meta, err := a.nextIMAPMetadata(r.Context(), tx, inboxID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to allocate message uid")
return
}
if _, err := tx.ExecContext(r.Context(), `UPDATE messages SET folder_id=?,imap_uid=?,imap_modseq=?,updated_at=? WHERE id=?`, inboxID, meta.UID, meta.ModSeq, now, messageID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to move folder messages")
return
}
}
if _, err := tx.ExecContext(r.Context(), `DELETE FROM folders WHERE id=? AND mailbox_id=?`, folderID, mb.ID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
if err := tx.Commit(); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete folder")
return
}
_, _ = a.bumpFolderModSeq(r.Context(), inboxID)
respondJSON(w, http.StatusOK, map[string]any{"ok": true, "moved": len(messageIDs)})
}
func (a *App) ensureCustomFolder(ctx context.Context, mailboxID, name string) (string, error) {
return a.ensureFolder(ctx, mailboxID, name)
}
func (a *App) nextCustomFolderSortOrder(ctx context.Context, mailboxID string) (int, error) {
var maxOrder int
if err := a.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(sort_order),0) FROM folders WHERE mailbox_id=? AND lower(name) NOT IN ('inbox','sent','drafts','archive','spam','trash')`, mailboxID).Scan(&maxOrder); err != nil {
return 0, err
}
if maxOrder < customFolderDefaultSortOrderBase {
maxOrder = customFolderDefaultSortOrderBase
}
return maxOrder + 1, nil
}
func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
mb, err := a.mailboxForCurrentUser(r)
if err != nil {
@@ -126,6 +362,12 @@ func (a *App) handleMailMessages(w http.ResponseWriter, r *http.Request) {
if folder == "" {
folder = "Inbox"
}
if normalized, err := normalizeFolderNameForUser(folder); err != nil {
badRequest(w, err)
return
} else {
folder = normalized
}
folderID, err := a.ensureFolder(r.Context(), mb.ID, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
@@ -922,9 +1164,10 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
cursor, _ := strconv.Atoi(r.URL.Query().Get("cursor"))
if cursor < 0 {
cursor = 0
cursorCreatedAt, cursorID, offsetCursor, err := parseSendQueueCursor(r.URL.Query().Get("cursor"))
if err != nil {
badRequest(w, err)
return
}
limit := 30
args := []any{user.ID, mb.ID}
@@ -937,9 +1180,44 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
where += ` AND sq.status=?`
args = append(args, status)
}
args = append(args, limit+1, cursor)
rows, err := a.db.QueryContext(r.Context(), `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE `+where+` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ? OFFSET ?`, args...)
if messageID := strings.TrimSpace(r.URL.Query().Get("messageId")); messageID != "" {
where += ` AND (sq.message_id=? OR sq.sent_message_id=? OR m.message_id=?)`
args = append(args, messageID, messageID, messageID)
}
if recipient := normalizeEmail(r.URL.Query().Get("recipient")); recipient != "" {
where += ` AND sq.recipients_json LIKE ?`
args = append(args, "%"+recipient+"%")
}
if from := strings.TrimSpace(r.URL.Query().Get("from")); from != "" {
t, err := parseTimeQuery(from)
if err != nil {
badRequest(w, errors.New("invalid from time"))
return
}
where += ` AND sq.created_at>=?`
args = append(args, t.UTC().Format(time.RFC3339Nano))
}
if to := strings.TrimSpace(r.URL.Query().Get("to")); to != "" {
t, err := parseTimeQuery(to)
if err != nil {
badRequest(w, errors.New("invalid to time"))
return
}
where += ` AND sq.created_at<=?`
args = append(args, t.UTC().Format(time.RFC3339Nano))
}
if cursorCreatedAt != "" && cursorID != "" {
where += ` AND (sq.created_at < ? OR (sq.created_at = ? AND sq.id < ?))`
args = append(args, cursorCreatedAt, cursorCreatedAt, cursorID)
}
args = append(args, limit+1)
query := `SELECT sq.id,sq.mailbox_id,sq.sent_message_id,sq.message_id,COALESCE(m.subject,''),sq.source,sq.mail_from,sq.header_from,sq.recipients_json,sq.status,sq.attempt_count,sq.max_attempts,sq.next_attempt_at,sq.last_error,sq.created_at,sq.updated_at,sq.delivered_at
FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id LEFT JOIN messages m ON m.id=sq.sent_message_id WHERE ` + where + ` ORDER BY sq.created_at DESC, sq.id DESC LIMIT ?`
if offsetCursor > 0 {
args = append(args, offsetCursor)
query += ` OFFSET ?`
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load send queue")
return
@@ -961,7 +1239,8 @@ func (a *App) handleSendQueue(w http.ResponseWriter, r *http.Request) {
next := ""
if len(items) > limit {
items = items[:limit]
next = strconv.Itoa(cursor + limit)
last := items[len(items)-1]
next = encodeSendQueueCursor(last.CreatedAt, last.ID)
}
respondJSON(w, http.StatusOK, map[string]any{"items": items, "nextCursor": next})
}
@@ -1107,12 +1386,62 @@ func (a *App) loadSendQueueEntryForUser(ctx context.Context, id, userID string)
return scanSendQueueEntry(row)
}
type sendQueueCursor struct {
CreatedAt string `json:"createdAt"`
ID string `json:"id"`
}
func encodeSendQueueCursor(createdAt time.Time, id string) string {
payload, _ := json.Marshal(sendQueueCursor{CreatedAt: createdAt.UTC().Format(time.RFC3339Nano), ID: id})
return base64.RawURLEncoding.EncodeToString(payload)
}
func parseSendQueueCursor(raw string) (createdAt string, id string, offset int, err error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", "", 0, nil
}
if n, convErr := strconv.Atoi(raw); convErr == nil {
if n < 0 {
return "", "", 0, errors.New("invalid cursor")
}
return "", "", n, nil
}
data, decodeErr := base64.RawURLEncoding.DecodeString(raw)
if decodeErr != nil {
return "", "", 0, errors.New("invalid cursor")
}
var cursor sendQueueCursor
if err := json.Unmarshal(data, &cursor); err != nil {
return "", "", 0, errors.New("invalid cursor")
}
t, err := parseTimeQuery(cursor.CreatedAt)
if err != nil || strings.TrimSpace(cursor.ID) == "" {
return "", "", 0, errors.New("invalid cursor")
}
return t.UTC().Format(time.RFC3339Nano), strings.TrimSpace(cursor.ID), 0, nil
}
func (a *App) sendQueueBelongsToUser(ctx context.Context, id, userID string) bool {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(1) FROM send_queue sq JOIN mailboxes mb ON mb.id=sq.mailbox_id WHERE sq.id=? AND mb.user_id=?`, id, userID).Scan(&count)
return count > 0
}
func parseTimeQuery(raw string) (time.Time, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return time.Time{}, errors.New("time is required")
}
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
return t, nil
}
if t, err := time.Parse("2006-01-02", raw); err == nil {
return t, nil
}
return time.Time{}, errors.New("invalid time")
}
func validSendQueueStatus(status string) bool {
switch status {
case sendQueueStatusQueued, sendQueueStatusSending, sendQueueStatusDelivered, sendQueueStatusFailed, sendQueueStatusCanceled:
@@ -1429,7 +1758,12 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, req.Folder)
folder, err := normalizeFolderNameForUser(req.Folder)
if err != nil {
badRequest(w, err)
return
}
folderID, err := a.ensureFolder(r.Context(), msg.MailboxID, folder)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load folder")
return
@@ -1441,6 +1775,69 @@ func (a *App) handleMove(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *App) folderByID(ctx context.Context, folderID, mailboxID string) (*MailFolder, error) {
row := a.db.QueryRowContext(ctx, `SELECT f.id,f.name,f.role,
COALESCE(SUM(CASE WHEN m.is_read=0 THEN 1 ELSE 0 END),0) AS unread,
COUNT(m.id) AS total,
f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq
FROM folders f LEFT JOIN messages m ON m.folder_id=f.id
WHERE f.id=? AND f.mailbox_id=? GROUP BY f.id,f.name,f.role,f.sort_order,f.uid_validity,f.uid_next,f.highest_modseq`, folderID, mailboxID)
var f MailFolder
if err := row.Scan(&f.ID, &f.Name, &f.Role, &f.UnreadCount, &f.TotalCount, &f.SortOrder, &f.UIDValidity, &f.UIDNext, &f.HighestModSeq); err != nil {
return nil, err
}
return &f, nil
}
func normalizeCustomFolderName(raw string) (string, error) {
name := strings.Join(strings.Fields(raw), " ")
if name == "" {
return "", errors.New("folder name is required")
}
if len([]rune(name)) > 48 {
return "", errors.New("folder name is too long")
}
if strings.ContainsAny(name, `/\:`) || strings.Contains(name, "..") {
return "", errors.New("folder name contains invalid characters")
}
for _, r := range name {
if r < 32 || r == 127 {
return "", errors.New("folder name contains invalid characters")
}
}
return name, nil
}
func normalizeFolderNameForUser(raw string) (string, error) {
name := strings.TrimSpace(raw)
if isSystemFolderName(name) {
switch strings.ToLower(name) {
case "inbox":
return "Inbox", nil
case "sent":
return "Sent", nil
case "drafts":
return "Drafts", nil
case "archive":
return "Archive", nil
case "spam":
return "Spam", nil
case "trash":
return "Trash", nil
}
}
return normalizeCustomFolderName(raw)
}
func isSystemFolderName(name string) bool {
switch strings.ToLower(strings.TrimSpace(name)) {
case "inbox", "sent", "drafts", "archive", "spam", "trash":
return true
default:
return false
}
}
func (a *App) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), false)
if err != nil {
@@ -1605,6 +2002,9 @@ func (a *App) messageByID(ctx context.Context, id string, includeBody bool) (*Ma
return nil, err
}
msg.Labels = labels
if includeBody {
_ = a.db.QueryRowContext(ctx, `SELECT id,status FROM send_queue WHERE sent_message_id=? ORDER BY created_at DESC,id DESC LIMIT 1`, id).Scan(&msg.SendQueueID, &msg.SendQueueStatus)
}
return &msg, nil
}
+221
View File
@@ -0,0 +1,221 @@
package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"unicode/utf8"
"github.com/go-chi/chi/v5"
)
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
type translateMailMessageRequest struct {
TargetLanguage string `json:"targetLanguage"`
}
type translateMailMessageResponse struct {
TranslatedText string `json:"translatedText"`
SourceLanguage string `json:"sourceLanguage,omitempty"`
TargetLanguage string `json:"targetLanguage"`
Truncated bool `json:"truncated"`
}
func (a *App) handleTranslateMailMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
var req translateMailMessageRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
target := normalizeTranslateTarget(req.TargetLanguage)
if target == "" {
respondError(w, http.StatusBadRequest, "unsupported target language")
return
}
msg, err := a.loadMessageForRequest(r, chi.URLParam(r, "id"), true)
if err != nil {
respondError(w, http.StatusNotFound, "message not found")
return
}
text := strings.TrimSpace(msg.BodyText)
if text == "" {
text = strings.TrimSpace(msg.Snippet)
}
if text == "" {
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("mail translation failed", "message_id", msg.ID, "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
func (a *App) handleTranslateExternalIMAPMessage(w http.ResponseWriter, r *http.Request) {
if !a.cfg.MailTranslateEnabled {
respondError(w, http.StatusForbidden, "mail translation is disabled")
return
}
var req translateMailMessageRequest
if err := decodeJSON(r, &req); err != nil {
badRequest(w, err)
return
}
target := normalizeTranslateTarget(req.TargetLanguage)
if target == "" {
respondError(w, http.StatusBadRequest, "unsupported target language")
return
}
account, ok := a.externalIMAPAccountForMailRequest(w, r)
if !ok {
return
}
folder, uid, ok := decodeExternalRemoteID(w, chi.URLParam(r, "remoteId"))
if !ok {
return
}
client, err := a.externalIMAP.openExternalIMAPClient(r.Context(), account)
if err != nil {
respondError(w, http.StatusBadRequest, "connection failed: "+err.Error())
return
}
defer client.Close()
raw, remote, err := client.FetchRaw(r.Context(), folder, uid)
if err != nil {
respondError(w, http.StatusBadRequest, "failed to load remote message")
return
}
stored, _, err := a.parseMaildirMessage(raw, account.Username)
text := ""
if err == nil {
text = strings.TrimSpace(stored.BodyText)
if text == "" {
text = strings.TrimSpace(stored.Snippet)
}
}
if text == "" {
text = strings.TrimSpace(remote.Snippet)
}
if text == "" {
respondError(w, http.StatusBadRequest, "message has no translatable text")
return
}
maxChars := a.cfg.MailTranslateMaxChars
if maxChars <= 0 {
maxChars = 8000
}
text, truncated := truncateRunes(text, maxChars)
translated, source, err := googleFreeTranslate(r.Context(), text, target)
if err != nil {
a.log.Warn("external mail translation failed", "account_id", account.ID, "remote_id", chi.URLParam(r, "remoteId"), "target", target, "error", err)
respondError(w, http.StatusBadGateway, "translation failed")
return
}
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
}
func normalizeTranslateTarget(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "zh", "zh-cn", "zh-hans", "zh_cn":
return "zh-CN"
case "zh-tw", "zh-hant", "zh_hk", "zh-hk", "zh-mo":
return "zh-TW"
case "en", "en-us", "en-gb":
return "en"
default:
return ""
}
}
func truncateRunes(value string, max int) (string, bool) {
if max <= 0 || utf8.RuneCountInString(value) <= max {
return value, false
}
out := make([]rune, 0, max)
for i, r := range value {
if len(out) >= max {
return string(out), i < len(value)
}
out = append(out, r)
}
return string(out), false
}
func googleFreeTranslate(ctx context.Context, text, target string) (string, string, error) {
ctx, cancel := context.WithTimeout(ctx, 12*time.Second)
defer cancel()
params := url.Values{}
params.Set("client", "gtx")
params.Set("sl", "auto")
params.Set("tl", target)
params.Set("dt", "t")
params.Set("q", text)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleTranslateEndpoint+"?"+params.Encode(), nil)
if err != nil {
return "", "", err
}
req.Header.Set("User-Agent", "Mozilla/5.0")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", "", err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1024))
return "", "", fmt.Errorf("google translate status %d", res.StatusCode)
}
var raw any
if err := json.NewDecoder(io.LimitReader(res.Body, 4*1024*1024)).Decode(&raw); err != nil {
return "", "", err
}
translated, source := parseGoogleTranslateResponse(raw)
translated = strings.TrimSpace(translated)
if translated == "" {
return "", source, errors.New("empty translation")
}
return translated, source, nil
}
func parseGoogleTranslateResponse(raw any) (string, string) {
root, _ := raw.([]any)
var b strings.Builder
if len(root) > 0 {
if sentences, ok := root[0].([]any); ok {
for _, item := range sentences {
parts, ok := item.([]any)
if !ok || len(parts) == 0 {
continue
}
if s, ok := parts[0].(string); ok {
b.WriteString(s)
}
}
}
}
source := ""
if len(root) > 2 {
if s, ok := root[2].(string); ok {
source = s
}
}
return b.String(), source
}
@@ -0,0 +1,32 @@
package app
import "testing"
func TestParseGoogleTranslateResponse(t *testing.T) {
raw := []any{
[]any{
[]any{"你好", "Hello", nil, nil, float64(3)},
[]any{",世界", ", world", nil, nil, float64(3)},
},
nil,
"en",
}
translated, source := parseGoogleTranslateResponse(raw)
if translated != "你好,世界" {
t.Fatalf("translated = %q", translated)
}
if source != "en" {
t.Fatalf("source = %q", source)
}
}
func TestTruncateRunes(t *testing.T) {
got, truncated := truncateRunes("你好world", 4)
if got != "你好wo" || !truncated {
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
}
got, truncated = truncateRunes("你好", 4)
if got != "你好" || truncated {
t.Fatalf("truncateRunes() = %q, %v", got, truncated)
}
}
@@ -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)
}
+31 -3
View File
@@ -120,7 +120,7 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
if strings.EqualFold(folderName, "Inbox") && !state.IsRead {
subdir = "new"
}
if err := ensureMaildirFolderDirs(folderBase); err != nil {
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
filename := maildirFilename(messageID, state.MessageID)
@@ -130,6 +130,10 @@ func (a *App) writeRawMessageToMaildirFolder(ctx context.Context, messageID, fol
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
@@ -213,7 +217,7 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
}
base := filepath.Join(strings.TrimSpace(a.cfg.MaildirRoot), mb.Domain, mb.LocalPart, "Maildir")
folderBase := maildirFolderPath(base, folderName)
if err := ensureMaildirFolderDirs(folderBase); err != nil {
if err := ensureMaildirFolderDirs(base, folderBase); err != nil {
return err
}
subdir := "cur"
@@ -225,6 +229,9 @@ func (a *App) moveMessageMaildir(ctx context.Context, messageID, targetFolderID
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 {
@@ -318,6 +325,9 @@ func (a *App) updateMessageMaildirFlags(ctx context.Context, messageID string, r
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
@@ -481,15 +491,33 @@ func (a *App) pathIsUnderMaildirRoot(path string) (bool, error) {
return rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != "..", nil
}
func ensureMaildirFolderDirs(folderBase string) error {
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 == "" {
+21
View File
@@ -59,18 +59,39 @@ func (a *App) Router() http.Handler {
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.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)).Post("/mail/messages/{id}/translate", a.handleTranslateMailMessage)
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(PermissionMailRead), a.requireExternalIMAPEnabled).Post("/mail/external-accounts/{id}/messages/{remoteId}/translate", a.handleTranslateExternalIMAPMessage)
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)
+8 -2
View File
@@ -126,6 +126,12 @@ func (a *App) sendQueueWorker(ctx context.Context) {
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)
}
@@ -232,12 +238,12 @@ func (a *App) recoverStaleSendQueueItems(ctx context.Context) error {
}
continue
}
res, err := a.db.ExecContext(ctx, `UPDATE send_queue SET status=?,next_attempt_at=?,last_error=?,updated_at=? WHERE id=? AND status=?`, sendQueueStatusFailed, now, "send attempt interrupted", now, item.ID, sendQueueStatusSending)
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, sendQueueStatusFailed, sendAuditInputFromQueue(item, "send attempt interrupted"))
a.recordSendAudit(ctx, sendAuditRetry, sendQueueStatusQueued, sendAuditInputFromQueue(item, "send attempt interrupted"))
}
}
return nil
+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)
+76 -29
View File
@@ -60,6 +60,7 @@ type MailFolder struct {
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"`
@@ -76,35 +77,38 @@ 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"`
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"`
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 {
@@ -224,6 +228,49 @@ type MailStatsFolderCount struct {
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"`
+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
+24 -5
View File
@@ -56,18 +56,20 @@ export type AdminOverview = { users: number; activeUsers: number; domains: numbe
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; uidValidity: number; uidNext: number; highestModseq: 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 MailAuthentication = { authenticationResults: string; receivedSpf: string; spf: string; dkim: string; dmarc: string }
export type MailMessage = {
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[]
authentication?: MailAuthentication
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[] }> }
export type ListResponse<T> = { items: T[]; nextCursor?: string }
export type MailTranslation = { translatedText: string; sourceLanguage?: string; targetLanguage: string; truncated: boolean }
export type SendPayload = { mailboxId?: string; to: string[]; cc: string[]; bcc: string[]; subject: string; text: string; html: string; attachments: { filename: string; contentType: string; contentBase64: string }[] }
export type DraftPayload = Omit<SendPayload, "attachments"> & { attachments?: SendPayload["attachments"] }
export type ScheduleSendPayload = SendPayload & { draftId?: string; sendAt: string }
@@ -122,6 +124,15 @@ export type MailRuleAction = { type: "archive" | "trash" | "star" | "mark-read"
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; 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 }
@@ -162,10 +173,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 }
+34 -2
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, SendQueueItem, SendQueueAuditEvent, SendQueueStatus, Contact, MailSignature, MailRule, MailRuleCondition, MailRuleAction, BlockedSender, MailStats, MailboxApplyOptions, MailTemplate, MaildirSyncHealth, SystemSettings, SystemSettingsPayload, PublicSettings, LoginPayload, LoginResponse, RegisterPayload, PermissionGroup, PermissionInfo, PermissionKey, PermissionLimits } from "./api-types"
import type { User, AdminUser, AdminOverview, Domain, Mailbox, Alias, MailFolder, Attachment, MailLabel, MailMessage, MailTranslation, 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,6 +61,15 @@ 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"),
permissionGroups: () => request<ListResponse<PermissionGroup> & { catalog: PermissionInfo[] }>("/api/admin/permission-groups"),
@@ -115,7 +124,24 @@ 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)}` : ""
@@ -138,15 +164,21 @@ export const api = {
return request<ListResponse<MailMessage>>(`/api/mail/starred?${params.toString()}`)
},
message: (id: string, options: { markRead?: boolean } = {}) => request<MailMessage>(`/api/mail/messages/${id}${options.markRead === false ? "?markRead=0" : ""}`),
translateMessage: (id: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/messages/${id}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
translateExternalMessage: (id: string, remoteId: string, targetLanguage: string) => request<MailTranslation>(`/api/mail/external-accounts/${id}/messages/${encodeURIComponent(remoteId)}/translate`, { method: "POST", body: JSON.stringify({ targetLanguage }), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
send: (payload: SendPayload) => request<MailMessage>("/api/mail/send", { method: "POST", body: JSON.stringify(payload), timeoutMs: MAIL_DELIVERY_TIMEOUT_MS }),
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 } = {}) => {
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}` : ""}`)
},
+470
View File
@@ -0,0 +1,470 @@
import * as React from "react"
export type Language = "zh-CN" | "zh-TW" | "en"
export const LANGUAGE_STORAGE_KEY = "lanqin:language"
export const languageOptions: { value: Language; label: string; shortLabel: string; htmlLang: string }[] = [
{ value: "zh-CN", label: "简体中文", shortLabel: "简", htmlLang: "zh-CN" },
{ value: "zh-TW", label: "繁體中文", shortLabel: "繁", htmlLang: "zh-TW" },
{ value: "en", label: "English", shortLabel: "EN", htmlLang: "en" },
]
const languageValues = new Set(languageOptions.map((item) => item.value))
export function getInitialLanguage(): Language {
if (typeof window === "undefined") return "zh-CN"
const stored = window.localStorage.getItem(LANGUAGE_STORAGE_KEY)
if (stored && languageValues.has(stored as Language)) return stored as Language
const browserLanguage = window.navigator.language.toLowerCase()
if (browserLanguage.startsWith("zh-tw") || browserLanguage.startsWith("zh-hk") || browserLanguage.startsWith("zh-mo")) return "zh-TW"
if (browserLanguage.startsWith("en")) return "en"
return "zh-CN"
}
export function setStoredLanguage(language: Language) {
window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language)
window.dispatchEvent(new CustomEvent("lanqin:language", { detail: language }))
}
export function useLanguage() {
const [language, setLanguageState] = React.useState<Language>(getInitialLanguage)
React.useEffect(() => {
function sync() {
setLanguageState(getInitialLanguage())
}
window.addEventListener("storage", sync)
window.addEventListener("lanqin:language", sync)
return () => {
window.removeEventListener("storage", sync)
window.removeEventListener("lanqin:language", sync)
}
}, [])
const setLanguage = React.useCallback((nextLanguage: Language) => {
setStoredLanguage(nextLanguage)
setLanguageState(nextLanguage)
}, [])
return [language, setLanguage] as const
}
type Translation = { "zh-TW": string; en: string }
const exactTranslations: Record<string, Translation> = {
"收件箱": { "zh-TW": "收件匣", en: "Inbox" },
"已发送": { "zh-TW": "已傳送", en: "Sent" },
"草稿箱": { "zh-TW": "草稿匣", en: "Drafts" },
"归档": { "zh-TW": "封存", en: "Archive" },
"垃圾邮件": { "zh-TW": "垃圾郵件", en: "Spam" },
"回收站": { "zh-TW": "垃圾桶", en: "Trash" },
"全部邮件": { "zh-TW": "全部郵件", en: "All mail" },
"未读邮件": { "zh-TW": "未讀郵件", en: "Unread" },
"星标邮件": { "zh-TW": "星號郵件", en: "Starred" },
"有附件": { "zh-TW": "有附件", en: "Has attachments" },
"切换语言": { "zh-TW": "切換語言", en: "Switch language" },
"写邮件": { "zh-TW": "寫郵件", en: "Compose" },
"邮件夹": { "zh-TW": "郵件匣", en: "Folders" },
"外部邮箱": { "zh-TW": "外部信箱", en: "External mailbox" },
"标签": { "zh-TW": "標籤", en: "Labels" },
"暂无标签": { "zh-TW": "暫無標籤", en: "No labels" },
"收起侧栏": { "zh-TW": "收合側欄", en: "Collapse sidebar" },
"选择邮箱": { "zh-TW": "選擇信箱", en: "Select mailbox" },
"没有可用邮箱": { "zh-TW": "沒有可用信箱", en: "No mailboxes available" },
"邮箱地址已复制": { "zh-TW": "信箱地址已複製", en: "Mailbox address copied" },
"打开导航": { "zh-TW": "開啟導覽", en: "Open navigation" },
"邮箱导航": { "zh-TW": "信箱導覽", en: "Mailbox navigation" },
"刷新邮件": { "zh-TW": "重新整理郵件", en: "Refresh mail" },
"自动刷新中": { "zh-TW": "自動重新整理中", en: "Auto-refreshing" },
"自动刷新中...": { "zh-TW": "自動重新整理中...", en: "Auto-refreshing..." },
"自动刷新已开启": { "zh-TW": "自動重新整理已開啟", en: "Auto-refresh enabled" },
"全部已读": { "zh-TW": "全部已讀", en: "Mark all read" },
"搜索邮件": { "zh-TW": "搜尋郵件", en: "Search mail" },
"搜索远端邮件": { "zh-TW": "搜尋遠端郵件", en: "Search remote mail" },
"搜索发送队列": { "zh-TW": "搜尋傳送佇列", en: "Search send queue" },
"搜索待发送": { "zh-TW": "搜尋待傳送", en: "Search scheduled mail" },
"选择当前页邮件": { "zh-TW": "選擇目前頁面的郵件", en: "Select messages on this page" },
"加载中...": { "zh-TW": "載入中...", en: "Loading..." },
"加载更多": { "zh-TW": "載入更多", en: "Load more" },
"选择一封邮件阅读": { "zh-TW": "選擇一封郵件閱讀", en: "Select a message to read" },
"回复": { "zh-TW": "回覆", en: "Reply" },
"转发": { "zh-TW": "轉寄", en: "Forward" },
"投递时间线": { "zh-TW": "投遞時間軸", en: "Delivery timeline" },
"取消归档": { "zh-TW": "取消封存", en: "Unarchive" },
"删除": { "zh-TW": "刪除", en: "Delete" },
"附件": { "zh-TW": "附件", en: "Attachments" },
"确认": { "zh-TW": "確認", en: "Confirm" },
"取消": { "zh-TW": "取消", en: "Cancel" },
"关闭": { "zh-TW": "關閉", en: "Close" },
"打开": { "zh-TW": "開啟", en: "Open" },
"刷新": { "zh-TW": "重新整理", en: "Refresh" },
"新建文件夹": { "zh-TW": "新增資料夾", en: "New folder" },
"文件夹名称": { "zh-TW": "資料夾名稱", en: "Folder name" },
"创建": { "zh-TW": "建立", en: "Create" },
"创建中...": { "zh-TW": "建立中...", en: "Creating..." },
"例如:客户、账单、项目归档": { "zh-TW": "例如:客戶、帳單、專案封存", en: "e.g. Clients, invoices, project archive" },
"新建标签": { "zh-TW": "新增標籤", en: "New label" },
"删除标签失败": { "zh-TW": "刪除標籤失敗", en: "Failed to delete label" },
"添加标签失败": { "zh-TW": "新增標籤失敗", en: "Failed to add label" },
"移除标签失败": { "zh-TW": "移除標籤失敗", en: "Failed to remove label" },
"创建标签失败": { "zh-TW": "建立標籤失敗", en: "Failed to create label" },
"添加标签": { "zh-TW": "新增標籤", en: "Add label" },
"请先在侧栏新建标签": { "zh-TW": "請先在側欄新增標籤", en: "Create a label in the sidebar first" },
"无": { "zh-TW": "無", en: "None" },
"无主题": { "zh-TW": "無主旨", en: "No subject" },
"(无主题)": { "zh-TW": "(無主旨)", en: "(No subject)" },
"未知发件人": { "zh-TW": "未知寄件者", en: "Unknown sender" },
"未知发件人地址": { "zh-TW": "未知寄件者地址", en: "Unknown sender address" },
"发件人": { "zh-TW": "寄件者", en: "Sender" },
"发件人地址": { "zh-TW": "寄件者地址", en: "Sender address" },
"收件人": { "zh-TW": "收件者", en: "Recipients" },
"抄送": { "zh-TW": "副本", en: "Cc" },
"密送": { "zh-TW": "密件副本", en: "Bcc" },
"投递邮箱": { "zh-TW": "投遞信箱", en: "Delivered mailbox" },
"发送时间": { "zh-TW": "傳送時間", en: "Send time" },
"接收时间": { "zh-TW": "接收時間", en: "Received time" },
"未填写收件人": { "zh-TW": "未填寫收件者", en: "No recipients" },
"邮件正文": { "zh-TW": "郵件內文", en: "Message body" },
"邮件详情": { "zh-TW": "郵件詳情", en: "Message details" },
"上一封": { "zh-TW": "上一封", en: "Previous" },
"下一封": { "zh-TW": "下一封", en: "Next" },
"更多操作": { "zh-TW": "更多操作", en: "More actions" },
"邮件不存在": { "zh-TW": "郵件不存在", en: "Message not found" },
"标为已读": { "zh-TW": "標為已讀", en: "Mark as read" },
"标为未读": { "zh-TW": "標為未讀", en: "Mark as unread" },
"添加星标": { "zh-TW": "加上星號", en: "Add star" },
"取消星标": { "zh-TW": "移除星號", en: "Remove star" },
"移入回收站": { "zh-TW": "移至垃圾桶", en: "Move to trash" },
"移入垃圾邮件": { "zh-TW": "移至垃圾郵件", en: "Move to spam" },
"批量操作": { "zh-TW": "批次操作", en: "Bulk actions" },
"移动到": { "zh-TW": "移動到", en: "Move to" },
"移到最上": { "zh-TW": "移到最上方", en: "Move to top" },
"上移一位": { "zh-TW": "上移一位", en: "Move up" },
"下移一位": { "zh-TW": "下移一位", en: "Move down" },
"移到最下": { "zh-TW": "移到最下方", en: "Move to bottom" },
"删除文件夹": { "zh-TW": "刪除資料夾", en: "Delete folder" },
"远端文件夹没有邮件": { "zh-TW": "遠端資料夾沒有郵件", en: "No messages in remote folder" },
"当前筛选条件下没有远端邮件": { "zh-TW": "目前篩選條件下沒有遠端郵件", en: "No remote messages match the current filters" },
"没有待发送邮件": { "zh-TW": "沒有待傳送郵件", en: "No scheduled mail" },
"当前搜索没有匹配的定时邮件": { "zh-TW": "目前搜尋沒有符合的定時郵件", en: "No scheduled messages match your search" },
"发送队列为空": { "zh-TW": "傳送佇列為空", en: "Send queue is empty" },
"当前搜索没有匹配的发送任务": { "zh-TW": "目前搜尋沒有符合的傳送任務", en: "No send tasks match your search" },
"当前筛选条件下没有邮件": { "zh-TW": "目前篩選條件下沒有郵件", en: "No messages match the current filters" },
"暂无星标邮件": { "zh-TW": "暫無星號郵件", en: "No starred mail" },
"当前标签没有邮件": { "zh-TW": "目前標籤沒有郵件", en: "No messages with this label" },
"收件箱暂时为空": { "zh-TW": "收件匣暫時為空", en: "Inbox is empty" },
"还没有草稿": { "zh-TW": "還沒有草稿", en: "No drafts yet" },
"还没有已发送邮件": { "zh-TW": "還沒有已傳送郵件", en: "No sent mail yet" },
"回收站是空的": { "zh-TW": "垃圾桶是空的", en: "Trash is empty" },
"暂无垃圾邮件": { "zh-TW": "暫無垃圾郵件", en: "No spam" },
"当前文件夹没有邮件": { "zh-TW": "目前資料夾沒有郵件", en: "No messages in this folder" },
"待发送": { "zh-TW": "待傳送", en: "Scheduled" },
"发送队列": { "zh-TW": "傳送佇列", en: "Send queue" },
"全部状态": { "zh-TW": "全部狀態", en: "All statuses" },
"排队中": { "zh-TW": "佇列中", en: "Queued" },
"发送中": { "zh-TW": "傳送中", en: "Sending" },
"发送失败": { "zh-TW": "傳送失敗", en: "Failed" },
"已投递": { "zh-TW": "已投遞", en: "Delivered" },
"已取消": { "zh-TW": "已取消", en: "Canceled" },
"等待发送": { "zh-TW": "等待傳送", en: "Pending" },
"清除": { "zh-TW": "清除", en: "Clear" },
"未记录收件人": { "zh-TW": "未記錄收件者", en: "No recorded recipients" },
"时间线": { "zh-TW": "時間軸", en: "Timeline" },
"重试": { "zh-TW": "重試", en: "Retry" },
"暂无投递事件": { "zh-TW": "暫無投遞事件", en: "No delivery events" },
"队列事件": { "zh-TW": "佇列事件", en: "Queue event" },
"定时发送": { "zh-TW": "定時傳送", en: "Scheduled send" },
"未知": { "zh-TW": "未知", en: "Unknown" },
"同步": { "zh-TW": "同步", en: "Sync" },
"直连": { "zh-TW": "直連", en: "Direct" },
"还没有可用邮箱": { "zh-TW": "還沒有可用信箱", en: "No mailbox available" },
"请在个人中心申请邮箱,或联系管理员为当前账号分配邮箱。": { "zh-TW": "請在個人中心申請信箱,或聯絡管理員為目前帳號分配信箱。", en: "Apply for a mailbox in Profile, or contact an administrator to assign one to this account." },
"前往个人中心": { "zh-TW": "前往個人中心", en: "Go to profile" },
"无邮箱前台权限": { "zh-TW": "無信箱前台權限", en: "No mailbox access" },
"当前账号未开启邮箱前台访问权限。": { "zh-TW": "目前帳號未開啟信箱前台存取權限。", en: "Mailbox access is not enabled for this account." },
"无邮件查看权限": { "zh-TW": "無郵件檢視權限", en: "No mail read permission" },
"当前账号可以访问邮箱前台,但未开启邮件查看权限。": { "zh-TW": "目前帳號可以存取信箱前台,但未開啟郵件檢視權限。", en: "This account can access mailbox UI, but mail reading is not enabled." },
"无定时发送权限": { "zh-TW": "無定時傳送權限", en: "No scheduled send permission" },
"当前账号不能查看或管理定时发送任务。": { "zh-TW": "目前帳號不能檢視或管理定時傳送任務。", en: "This account cannot view or manage scheduled send tasks." },
"无发送队列权限": { "zh-TW": "無傳送佇列權限", en: "No send queue permission" },
"当前账号不能查看发送队列。": { "zh-TW": "目前帳號不能檢視傳送佇列。", en: "This account cannot view the send queue." },
"操作失败": { "zh-TW": "操作失敗", en: "Operation failed" },
"请稍后重试": { "zh-TW": "請稍後重試", en: "Please try again later" },
"已取消定时发送": { "zh-TW": "已取消定時傳送", en: "Scheduled send canceled" },
"已移除失败记录": { "zh-TW": "已移除失敗記錄", en: "Failed record removed" },
"文件夹已创建": { "zh-TW": "資料夾已建立", en: "Folder created" },
"创建文件夹失败": { "zh-TW": "建立資料夾失敗", en: "Failed to create folder" },
"文件夹排序失败": { "zh-TW": "資料夾排序失敗", en: "Failed to reorder folders" },
"文件夹已删除": { "zh-TW": "資料夾已刪除", en: "Folder deleted" },
"删除文件夹失败": { "zh-TW": "刪除資料夾失敗", en: "Failed to delete folder" },
"已重新加入发送队列": { "zh-TW": "已重新加入傳送佇列", en: "Added back to send queue" },
"重试失败": { "zh-TW": "重試失敗", en: "Retry failed" },
"已取消发送任务": { "zh-TW": "已取消傳送任務", en: "Send task canceled" },
"取消失败": { "zh-TW": "取消失敗", en: "Cancel failed" },
"当前没有未读邮件": { "zh-TW": "目前沒有未讀郵件", en: "No unread messages" },
"删除所选邮件?": { "zh-TW": "刪除所選郵件?", en: "Delete selected messages?" },
"删除邮件": { "zh-TW": "刪除郵件", en: "Delete messages" },
"批量操作失败": { "zh-TW": "批次操作失敗", en: "Bulk action failed" },
"删除这封邮件?": { "zh-TW": "刪除這封郵件?", en: "Delete this message?" },
"这封草稿已在待发送队列中": { "zh-TW": "這封草稿已在待傳送佇列中", en: "This draft is already scheduled" },
"请先取消定时发送,再继续编辑。": { "zh-TW": "請先取消定時傳送,再繼續編輯。", en: "Cancel the scheduled send before editing." },
"打开草稿失败": { "zh-TW": "開啟草稿失敗", en: "Failed to open draft" },
"文件夹内的邮件会移回收件箱,不会被删除。": { "zh-TW": "資料夾內的郵件會移回收件匣,不會被刪除。", en: "Messages in this folder will be moved back to Inbox, not deleted." },
"编辑草稿": { "zh-TW": "編輯草稿", en: "Edit draft" },
"打开邮件": { "zh-TW": "開啟郵件", en: "Open message" },
"发件邮箱": { "zh-TW": "寄件信箱", en: "From mailbox" },
"未选择": { "zh-TW": "未選擇", en: "Not selected" },
"分别发送": { "zh-TW": "分別傳送", en: "Send separately" },
"主 题": { "zh-TW": "主 旨", en: "Subject" },
"输入主题": { "zh-TW": "輸入主旨", en: "Enter subject" },
"写信": { "zh-TW": "寫信", en: "Compose" },
"正在保存草稿...": { "zh-TW": "正在儲存草稿...", en: "Saving draft..." },
"草稿保存失败": { "zh-TW": "草稿儲存失敗", en: "Failed to save draft" },
"发送": { "zh-TW": "傳送", en: "Send" },
"发送中...": { "zh-TW": "傳送中...", en: "Sending..." },
"发送成功": { "zh-TW": "傳送成功", en: "Sent" },
"定时": { "zh-TW": "定時", en: "Schedule" },
"继续": { "zh-TW": "繼續", en: "Continue" },
"继续发送": { "zh-TW": "繼續傳送", en: "Continue sending" },
"继续分别发送": { "zh-TW": "繼續分別傳送", en: "Continue sending separately" },
"继续定时发送": { "zh-TW": "繼續定時傳送", en: "Continue scheduling" },
"请选择发件邮箱": { "zh-TW": "請選擇寄件信箱", en: "Select a from mailbox" },
"确认发送这封邮件?": { "zh-TW": "確認傳送這封郵件?", en: "Send this message?" },
"确认定时发送?": { "zh-TW": "確認定時傳送?", en: "Schedule this message?" },
"请选择发送时间": { "zh-TW": "請選擇傳送時間", en: "Select a send time" },
"发送时间需要晚于当前时间": { "zh-TW": "傳送時間需要晚於目前時間", en: "Send time must be in the future" },
"确认定时": { "zh-TW": "確認定時", en: "Confirm schedule" },
"正在设置...": { "zh-TW": "正在設定...", en: "Scheduling..." },
"输入正文": { "zh-TW": "輸入內文", en: "Write your message" },
"正文": { "zh-TW": "內文", en: "Body" },
"撤销": { "zh-TW": "復原", en: "Undo" },
"重做": { "zh-TW": "重做", en: "Redo" },
"插入": { "zh-TW": "插入", en: "Insert" },
"链接": { "zh-TW": "連結", en: "Link" },
"图片链接": { "zh-TW": "圖片連結", en: "Image link" },
"分隔线": { "zh-TW": "分隔線", en: "Divider" },
"日程": { "zh-TW": "行程", en: "Event" },
"表情": { "zh-TW": "表情", en: "Emoji" },
"格式": { "zh-TW": "格式", en: "Format" },
"预览": { "zh-TW": "預覽", en: "Preview" },
"签名": { "zh-TW": "簽名", en: "Signature" },
"清除格式": { "zh-TW": "清除格式", en: "Clear formatting" },
"加粗": { "zh-TW": "粗體", en: "Bold" },
"斜体": { "zh-TW": "斜體", en: "Italic" },
"下划线": { "zh-TW": "底線", en: "Underline" },
"删除线": { "zh-TW": "刪除線", en: "Strikethrough" },
"文字颜色": { "zh-TW": "文字顏色", en: "Text color" },
"高亮": { "zh-TW": "醒目提示", en: "Highlight" },
"无序列表": { "zh-TW": "項目符號清單", en: "Bulleted list" },
"有序列表": { "zh-TW": "編號清單", en: "Numbered list" },
"减少缩进": { "zh-TW": "減少縮排", en: "Decrease indent" },
"增加缩进": { "zh-TW": "增加縮排", en: "Increase indent" },
"左对齐": { "zh-TW": "靠左對齊", en: "Align left" },
"居中": { "zh-TW": "置中", en: "Center" },
"右对齐": { "zh-TW": "靠右對齊", en: "Align right" },
"引用": { "zh-TW": "引用", en: "Quote" },
"代码块": { "zh-TW": "程式碼區塊", en: "Code block" },
"邮件预览": { "zh-TW": "郵件預覽", en: "Message preview" },
"编辑链接": { "zh-TW": "編輯連結", en: "Edit link" },
"插入链接": { "zh-TW": "插入連結", en: "Insert link" },
"编辑图片": { "zh-TW": "編輯圖片", en: "Edit image" },
"插入图片": { "zh-TW": "插入圖片", en: "Insert image" },
"链接地址": { "zh-TW": "連結地址", en: "Link URL" },
"图片地址": { "zh-TW": "圖片地址", en: "Image URL" },
"显示文字": { "zh-TW": "顯示文字", en: "Display text" },
"默认使用链接地址": { "zh-TW": "預設使用連結地址", en: "Defaults to the URL" },
"替代文字": { "zh-TW": "替代文字", en: "Alt text" },
"图片说明": { "zh-TW": "圖片說明", en: "Image description" },
"更新": { "zh-TW": "更新", en: "Update" },
"默认字体": { "zh-TW": "預設字體", en: "Default font" },
"微软雅黑": { "zh-TW": "微軟正黑體", en: "Microsoft YaHei" },
"小号": { "zh-TW": "小號", en: "Small" },
"中号": { "zh-TW": "中號", en: "Medium" },
"大号": { "zh-TW": "大號", en: "Large" },
"默认": { "zh-TW": "預設", en: "Default" },
"红色": { "zh-TW": "紅色", en: "Red" },
"蓝色": { "zh-TW": "藍色", en: "Blue" },
"绿色": { "zh-TW": "綠色", en: "Green" },
"紫色": { "zh-TW": "紫色", en: "Purple" },
"黄色": { "zh-TW": "黃色", en: "Yellow" },
"粉色": { "zh-TW": "粉色", en: "Pink" },
"无高亮": { "zh-TW": "無醒目提示", en: "No highlight" },
"新建日程": { "zh-TW": "新增行程", en: "New event" },
"输入日程主题": { "zh-TW": "輸入行程主旨", en: "Enter event title" },
"请输入日程主题": { "zh-TW": "請輸入行程主旨", en: "Enter an event title" },
"开始": { "zh-TW": "開始", en: "Start" },
"全天": { "zh-TW": "全天", en: "All day" },
"持续": { "zh-TW": "持續", en: "Duration" },
"自定义": { "zh-TW": "自訂", en: "Custom" },
"提醒": { "zh-TW": "提醒", en: "Reminder" },
"重复": { "zh-TW": "重複", en: "Repeat" },
"农历": { "zh-TW": "農曆", en: "Lunar" },
"位置": { "zh-TW": "位置", en: "Location" },
"请输入位置": { "zh-TW": "請輸入位置", en: "Enter location" },
"描述": { "zh-TW": "描述", en: "Description" },
"输入描述": { "zh-TW": "輸入描述", en: "Enter description" },
"确定": { "zh-TW": "確定", en: "OK" },
"准时": { "zh-TW": "準時", en: "On time" },
"永不": { "zh-TW": "永不", en: "Never" },
"每天": { "zh-TW": "每天", en: "Daily" },
"每周": { "zh-TW": "每週", en: "Weekly" },
"每月": { "zh-TW": "每月", en: "Monthly" },
"每年": { "zh-TW": "每年", en: "Yearly" },
"明早 9 点": { "zh-TW": "明早 9 點", en: "Tomorrow 9 AM" },
"下周一 9 点": { "zh-TW": "下週一 9 點", en: "Next Monday 9 AM" },
"时间": { "zh-TW": "時間", en: "Time" },
}
const templateTranslations: { pattern: RegExp; "zh-TW": string; en: string }[] = [
{ pattern: /^已刷新 (.+)$/, "zh-TW": "已重新整理 $1", en: "Refreshed $1" },
{ pattern: /^已选 (\d+) 封$/, "zh-TW": "已選 $1 封", en: "$1 selected" },
{ pattern: /^(\d+) \/ (\d+) 封邮件$/, "zh-TW": "$1 / $2 封郵件", en: "$1 / $2 messages" },
{ pattern: /^(\d+) \/ (\d+) 封$/, "zh-TW": "$1 / $2 封", en: "$1 / $2 messages" },
{ pattern: /^(\d+) \/ (\d+) 封定时邮件$/, "zh-TW": "$1 / $2 封定時郵件", en: "$1 / $2 scheduled messages" },
{ pattern: /^(\d+) \/ (\d+) 个发送任务$/, "zh-TW": "$1 / $2 個傳送任務", en: "$1 / $2 send tasks" },
{ pattern: /^收到 (\d+) 封新邮件$/, "zh-TW": "收到 $1 封新郵件", en: "$1 new messages" },
{ pattern: /^新邮件:(.+)$/, "zh-TW": "新郵件:$1", en: "New message: $1" },
{ pattern: /^(.+) 等发来新邮件$/, "zh-TW": "$1 等寄來新郵件", en: "$1 and others sent new mail" },
{ pattern: /^已标记 (\d+) 封邮件为已读$/, "zh-TW": "已標記 $1 封郵件為已讀", en: "Marked $1 messages as read" },
{ pattern: /^已处理 (\d+) 封邮件$/, "zh-TW": "已處理 $1 封郵件", en: "Processed $1 messages" },
{ pattern: /^将删除当前选中的 (\d+) 封邮件,此操作无法从邮件列表中恢复。$/, "zh-TW": "將刪除目前選取的 $1 封郵件,此操作無法從郵件清單中復原。", en: "This will delete the $1 selected messages. This cannot be undone from the message list." },
{ pattern: /^邮件“(.+)”将被删除。$/, "zh-TW": "郵件「$1」將被刪除。", en: "Message “$1” will be deleted." },
{ pattern: /^删除文件夹“(.+)”?$/, "zh-TW": "刪除資料夾「$1」?", en: "Delete folder “$1”?" },
{ pattern: /^删除标签 (.+)$/, "zh-TW": "刪除標籤 $1", en: "Delete label $1" },
{ pattern: /^移除标签 (.+)$/, "zh-TW": "移除標籤 $1", en: "Remove label $1" },
{ pattern: /^移除 (.+)$/, "zh-TW": "移除 $1", en: "Remove $1" },
{ pattern: /^已将 (\d+) 封邮件移回收件箱$/, "zh-TW": "已將 $1 封郵件移回收件匣", en: "Moved $1 messages back to Inbox" },
{ pattern: /^发给 (.+)$/, "zh-TW": "寄給 $1", en: "To $1" },
{ pattern: /^来源:(.+)$/, "zh-TW": "來源:$1", en: "Source: $1" },
{ pattern: /^尝试:(\d+)\/(\d+)$/, "zh-TW": "嘗試:$1/$2", en: "Attempts: $1/$2" },
{ pattern: /^下次:(.+)$/, "zh-TW": "下次:$1", en: "Next: $1" },
{ pattern: /^投递于 (.+)$/, "zh-TW": "投遞於 $1", en: "Delivered at $1" },
{ pattern: /^尝试次数:(\d+)$/, "zh-TW": "嘗試次數:$1", en: "Attempts: $1" },
{ pattern: /^已分别发送 (\d+) 封邮件$/, "zh-TW": "已分別傳送 $1 封郵件", en: "Sent $1 messages separately" },
{ pattern: /^已定时发送 (.+)$/, "zh-TW": "已定時傳送 $1", en: "Scheduled for $1" },
{ pattern: /^草稿已保存 (.+)$/, "zh-TW": "草稿已儲存 $1", en: "Draft saved $1" },
{ pattern: /^发送时间:(.+)$/, "zh-TW": "傳送時間:$1", en: "Send time: $1" },
{ pattern: /^当前单个附件上限 (.+)$/, "zh-TW": "目前單個附件上限 $1", en: "Current per-attachment limit: $1" },
{ pattern: /^(\d+)分钟$/, "zh-TW": "$1分鐘", en: "$1 min" },
{ pattern: /^(\d+)分钟前$/, "zh-TW": "$1分鐘前", en: "$1 min before" },
{ pattern: /^(\d+) 分钟后$/, "zh-TW": "$1 分鐘後", en: "In $1 min" },
{ pattern: /^(\d+)小时$/, "zh-TW": "$1小時", en: "$1 hr" },
{ pattern: /^(\d+)小时前$/, "zh-TW": "$1小時前", en: "$1 hr before" },
{ pattern: /^(\d+) 小时后$/, "zh-TW": "$1 小時後", en: "In $1 hr" },
{ pattern: /^(\d+)天$/, "zh-TW": "$1天", en: "$1 day" },
{ pattern: /^(\d+)天前$/, "zh-TW": "$1天前", en: "$1 day before" },
{ pattern: /^(.+)前$/, "zh-TW": "$1前", en: "$1 before" },
]
export function translateUiText(value: string, language: Language): string {
if (language === "zh-CN" || !value) return value
const match = value.match(/^(\s*)([\s\S]*?)(\s*)$/)
const leading = match?.[1] || ""
const text = match?.[2] || value
const trailing = match?.[3] || ""
if (!text) return value
const translated = translateCore(text, language)
return `${leading}${translated}${trailing}`
}
function translateCore(text: string, language: Exclude<Language, "zh-CN">): string {
const exact = exactTranslations[text]
if (exact) return exact[language]
for (const rule of templateTranslations) {
if (rule.pattern.test(text)) return text.replace(rule.pattern, rule[language])
}
return text
}
const textSources = new WeakMap<Text, string>()
const textLastApplied = new WeakMap<Text, string>()
const attrSources = new WeakMap<Element, Partial<Record<string, string>>>()
const attrLastApplied = new WeakMap<Element, Partial<Record<string, string>>>()
const translatableAttributes = ["placeholder", "title", "aria-label"] as const
let translateTimer: number | undefined
export function LanguageDomSync() {
const [language] = useLanguage()
React.useEffect(() => {
document.documentElement.lang = languageOptions.find((item) => item.value === language)?.htmlLang || language
scheduleLocalize(language)
const observer = new MutationObserver(() => scheduleLocalize(language))
observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: [...translatableAttributes] })
return () => {
observer.disconnect()
if (translateTimer) window.clearTimeout(translateTimer)
}
}, [language])
return null
}
function scheduleLocalize(language: Language) {
if (typeof window === "undefined") return
if (translateTimer) window.clearTimeout(translateTimer)
translateTimer = window.setTimeout(() => localizeDocument(language), 0)
}
function localizeDocument(language: Language) {
if (!document.body) return
localizeElement(document.body, language)
}
function localizeElement(root: Element, language: Language) {
if (shouldSkipElement(root)) return
localizeAttributes(root, language)
for (const child of Array.from(root.childNodes)) {
if (child.nodeType === Node.TEXT_NODE) localizeTextNode(child as Text, language)
else if (child.nodeType === Node.ELEMENT_NODE) localizeElement(child as Element, language)
}
}
function localizeTextNode(node: Text, language: Language) {
if (!node.parentElement || shouldSkipElement(node.parentElement)) return
const current = node.textContent || ""
if (!current.trim()) return
let source = textSources.get(node)
const last = textLastApplied.get(node)
if (!source || (last !== undefined && current !== last && shouldTranslate(current))) {
source = current
textSources.set(node, source)
}
if (!source || !shouldTranslate(source)) return
const next = translateUiText(source, language)
textLastApplied.set(node, next)
if (current !== next) node.textContent = next
}
function localizeAttributes(element: Element, language: Language) {
if (shouldSkipElement(element)) return
for (const attr of translatableAttributes) {
const current = element.getAttribute(attr)
if (!current || !current.trim()) continue
let sources = attrSources.get(element)
if (!sources) {
sources = {}
attrSources.set(element, sources)
}
let applied = attrLastApplied.get(element)
if (!applied) {
applied = {}
attrLastApplied.set(element, applied)
}
if (!sources[attr] || (applied[attr] !== undefined && current !== applied[attr] && shouldTranslate(current))) sources[attr] = current
const source = sources[attr]
if (!source || !shouldTranslate(source)) continue
const next = translateUiText(source, language)
applied[attr] = next
if (current !== next) element.setAttribute(attr, next)
}
}
function shouldSkipElement(element: Element) {
const tag = element.tagName.toLowerCase()
if (["script", "style", "code", "pre", "textarea", "option"].includes(tag)) return true
return Boolean(element.closest("[data-lanqin-i18n-ignore], [contenteditable='true'], .ProseMirror"))
}
function shouldTranslate(value: string) {
const text = value.trim()
if (!text) return false
return Boolean(exactTranslations[text]) || templateTranslations.some((rule) => rule.pattern.test(text))
}
+2
View File
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { Navigate, RouterProvider, createBrowserRouter } from "react-router-dom"
import { Toaster } from "@/components/ui/toaster"
import { LanguageDomSync } from "@/lib/language"
import { ProtectedLayout } from "@/components/protected-layout"
import { AdminOnly } from "@/components/admin-only"
import { LoginPage } from "@/pages/login"
@@ -32,6 +33,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Toaster />
<LanguageDomSync />
</QueryClientProvider>
</React.StrictMode>,
)
+72 -1
View File
@@ -52,6 +52,7 @@ const sectionPermissions: Record<Section, PermissionKey[]> = {
settings: ["admin.settings.view", "admin.templates.view"],
}
const projectRepositoryUrl = "https://github.com/LanQin996/LanQin-Email"
const projectTelegramUrl = "https://t.me/+EhII7MSyi3QwNDQ5"
const projectTag = import.meta.env.VITE_APP_VERSION || ""
const projectReleaseUrl = import.meta.env.VITE_RELEASE_URL || (projectTag ? `${projectRepositoryUrl}/releases/tag/${projectTag}` : "")
const defaultPermissionLimits: PermissionLimits = { maxAttachmentMb: 25, smtpDailyLimit: 200, smtpMinuteLimit: 20, imapMinuteLimit: 200, pop3MinuteLimit: 150 }
@@ -979,7 +980,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const canUpdateTemplates = hasPermission(user, "admin.templates.update")
const canResetTemplates = hasPermission(user, "admin.templates.reset")
const templates = useQuery({ queryKey: ["admin", "mail-templates"], queryFn: api.mailTemplates, enabled: canViewTemplates })
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "templates" | "security" | "about">("base")
const [settingsTab, setSettingsTab] = React.useState<"base" | "smtp" | "storage" | "mail" | "externalImap" | "templates" | "security" | "about">("base")
const maildirHealth = useQuery({ queryKey: ["admin", "maildir-sync", "health"], queryFn: api.maildirSyncHealth, enabled: canSettingsView && settingsTab === "storage" })
const [smtpRequireTls, setSmtpRequireTls] = React.useState(false)
const [allowInsecureHttp, setAllowInsecureHttp] = React.useState(true)
@@ -990,6 +991,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
const [mailAutoRefresh, setMailAutoRefresh] = React.useState(true)
const [userMailboxApplyEnabled, setUserMailboxApplyEnabled] = React.useState(false)
const [userMailboxDomainIds, setUserMailboxDomainIds] = React.useState<string[]>([])
const [externalImapEnabled, setExternalImapEnabled] = React.useState(false)
const [externalImapAllowPrivateHosts, setExternalImapAllowPrivateHosts] = React.useState(false)
React.useEffect(() => {
if (!settings) return
setSmtpRequireTls(settings.smtpRequireTls)
@@ -1001,6 +1004,8 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
setMailAutoRefresh(settings.mailAutoRefresh)
setUserMailboxApplyEnabled(settings.userMailboxApplyEnabled)
setUserMailboxDomainIds(settings.userMailboxDomainIds || [])
setExternalImapEnabled(settings.externalImapEnabled)
setExternalImapAllowPrivateHosts(settings.externalImapAllowPrivateHosts)
}, [settings])
const save = useMutation({
mutationFn: (form: FormData) => api.updateSystemSettings({
@@ -1026,6 +1031,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
userMailboxApplyEnabled,
userMailboxDomainIds,
reservedMailboxPrefixes: fieldValue(form, "reservedMailboxPrefixes", settings?.reservedMailboxPrefixes || ""),
externalImapEnabled,
externalImapSecretKey: fieldValue(form, "externalImapSecretKey", ""),
externalImapSyncSeconds: fieldNumber(form, "externalImapSyncSeconds", settings?.externalImapSyncSeconds || 300),
externalImapAllowPrivateHosts,
externalImapGmailClientId: fieldValue(form, "externalImapGmailClientId", settings?.externalImapGmailClientId || ""),
externalImapGmailClientSecret: fieldValue(form, "externalImapGmailClientSecret", ""),
externalImapOutlookClientId: fieldValue(form, "externalImapOutlookClientId", settings?.externalImapOutlookClientId || ""),
externalImapOutlookClientSecret: fieldValue(form, "externalImapOutlookClientSecret", ""),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["admin", "settings"] })
@@ -1059,6 +1072,14 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
settings.userMailboxApplyEnabled,
(settings.userMailboxDomainIds || []).join(","),
settings.reservedMailboxPrefixes,
settings.externalImapEnabled,
settings.externalImapSecretSet,
settings.externalImapSyncSeconds,
settings.externalImapAllowPrivateHosts,
settings.externalImapGmailClientId,
settings.externalImapGmailClientSecretSet,
settings.externalImapOutlookClientId,
settings.externalImapOutlookClientSecretSet,
].join("|") : "loading"
const tabs: { key: typeof settingsTab; label: string }[] = [
...(canSettingsView ? [
@@ -1066,6 +1087,7 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
{ key: "smtp" as const, label: "SMTP" },
{ key: "storage" as const, label: "存储" },
{ key: "mail" as const, label: "邮件" },
{ key: "externalImap" as const, label: "外部 IMAP" },
] : []),
...(canViewTemplates ? [{ key: "templates" as const, label: "模板" }] : []),
...(canSettingsView ? [{ key: "security" as const, label: "安全" }] : []),
@@ -1174,6 +1196,49 @@ function SystemSettingsSection({ settings, domains }: { settings?: SystemSetting
</CardContent>
</Card>}
{settingsTab === "externalImap" && <Card>
<CardHeader>
<CardTitle> IMAP </CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border bg-muted/30 p-4 text-sm text-muted-foreground">
IMAP
</div>
<SwitchRow label="启用外部 IMAP" checked={externalImapEnabled} onCheckedChange={setExternalImapEnabled} />
{externalImapEnabled && (
<div className="space-y-5 border-t pt-5">
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapSecretKey" label={settings?.externalImapSecretSet ? "密码加密密钥(留空不变)" : "密码加密密钥"} type="password" required={!settings?.externalImapSecretSet} />
<Field name="externalImapSyncSeconds" label="后台同步间隔秒数" type="number" min={30} defaultValue={String(settings?.externalImapSyncSeconds || 300)} />
</div>
<SwitchRow label="允许 localhost / 内网 / link-local IMAP 主机" checked={externalImapAllowPrivateHosts} onCheckedChange={setExternalImapAllowPrivateHosts} />
<Separator />
<div className="space-y-3">
<div>
<div className="font-medium">Gmail OAuth2</div>
<div className="text-xs text-muted-foreground">{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/gmail/callback</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapGmailClientId" label="Gmail Client ID" defaultValue={settings?.externalImapGmailClientId || ""} required={false} />
<Field name="externalImapGmailClientSecret" label={settings?.externalImapGmailClientSecretSet ? "Gmail Client Secret(留空不变)" : "Gmail Client Secret"} type="password" required={false} />
</div>
</div>
<Separator />
<div className="space-y-3">
<div>
<div className="font-medium">Microsoft 365 / Outlook OAuth2</div>
<div className="text-xs text-muted-foreground">{(settings?.publicBaseUrl || "${LANQIN_PUBLIC_BASE_URL}").replace(/\/$/, "")}/api/external-imap-oauth/outlook/callback</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Field name="externalImapOutlookClientId" label="Outlook Client ID" defaultValue={settings?.externalImapOutlookClientId || ""} required={false} />
<Field name="externalImapOutlookClientSecret" label={settings?.externalImapOutlookClientSecretSet ? "Outlook Client Secret(留空不变)" : "Outlook Client Secret"} type="password" required={false} />
</div>
</div>
</div>
)}
</CardContent>
</Card>}
{settingsTab === "templates" && canViewTemplates && <MailTemplatesPanel templates={templates.data?.items || []} loading={templates.isLoading} canUpdate={canUpdateTemplates} canReset={canResetTemplates} />}
{settingsTab === "security" && <Card>
@@ -1387,6 +1452,12 @@ function AboutProjectCard() {
Issues
</a>
</Button>
<Button type="button" variant="outline" className="h-11 justify-start px-4 text-base font-normal" asChild>
<a href={projectTelegramUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-5 w-5 text-sky-500" />
Telegram
</a>
</Button>
</div>
</AboutRow>
<AboutRow label="支持">
File diff suppressed because it is too large Load Diff
+360 -9
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, PermissionLimits } 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"
@@ -64,6 +64,7 @@ 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)
@@ -101,6 +102,16 @@ export function ProfilePage() {
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 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 })
@@ -198,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
@@ -291,7 +337,32 @@ export function ProfilePage() {
</div>
)
function renderTab() {
if (tab === "mailboxes") return <MailboxManagement mailboxes={canAccessMail ? mailboxes.data?.items || [] : []} applyOptions={mailboxApplyOptions.data} applyPending={applyMailbox.isPending} selectedMailboxId={mailboxId} onSelect={setMailboxId} onCopy={copy} onOpen={(id) => { if (!canAccessMail) return; 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} />
@@ -485,10 +556,57 @@ function LimitBadge({ label, value, unit }: { label: string; value?: number; uni
)
}
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> }) {
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-4">
<div className="space-y-6">
<div className="flex justify-end">
{canApply && <ApplyMailboxDialog options={applyOptions} pending={applyPending} onApply={onApply} />}
</div>
@@ -496,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>
)
}
@@ -546,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)
@@ -820,6 +1160,7 @@ const textConditionOperators: RuleConditionOperator[] = ["contains", "not-contai
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 }) {
@@ -982,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 />
+20
View File
@@ -132,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=
# =========================
# 系统
# =========================
+1
View File
@@ -129,6 +129,7 @@ docker compose -f docker-compose.stack.yml -f docker-compose.stack.build.yml up
- Go API 是 Webmail 和管理后台入口;浏览器不直接连接 SMTP/IMAP/POP3。
- Go API 会读取 `LANQIN_MAILDIR_ROOT=/var/mail/vhosts`,周期扫描 Maildir,把 Postfix/Dovecot 入站邮件同步成 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 证书
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