Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed68abb00d | |||
| b8d4553036 | |||
| 587bd77f6a | |||
| ef7af15010 | |||
| bce259ca8a | |||
| 707f11687d | |||
| cc75382869 | |||
| efcdc691de | |||
| 081cfad02d | |||
| 39f5249008 | |||
| 11734bf119 | |||
| 2558aa96ed | |||
| 98e7190512 | |||
| 4ab7815886 | |||
| 361daf8693 | |||
| d2a762a426 | |||
| 7799d5d5b2 | |||
| 07054ca342 | |||
| 84657f91f9 | |||
| a5f5adf752 |
@@ -1,5 +1,8 @@
|
||||
# LanQin Email
|
||||
|
||||
[](./README.md)
|
||||
[](./README.zh-CN.md)
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -9,49 +12,61 @@
|
||||

|
||||

|
||||
|
||||
LanQin Email 是一个自建邮箱 Webmail 全栈方案:前端使用 React + TypeScript + shadcn/ui,后端使用 Go + SQLite,部署时可用单容器集成 API、Web、Nginx、Postfix、Dovecot、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 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| 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 |
|
||||
|  |  |
|
||||
| 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 API、SQLite 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
|
||||
|
||||
> 公网收发邮件还需要正确配置 MX、SPF、DKIM、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. 在域名管理中复制并配置 MX、SPF、DKIM、DMARC 记录,然后点击 DNS 检测。
|
||||
5. 创建邮箱账号、别名转发或权限组,按需开启注册、2FA、Turnstile、自助申请邮箱。
|
||||
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/587、IMAP 993、POP3 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
@@ -0,0 +1,242 @@
|
||||
# LanQin Email
|
||||
|
||||
[](./README.md)
|
||||
[](./README.zh-CN.md)
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
|
||||
| 管理后台 · 系统概览 | 第三方客户端配置 |
|
||||
|  |  |
|
||||
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 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/) —— 新的理想型社区
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}` : ""}`)
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
@@ -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="支持">
|
||||
|
||||
+978
-116
File diff suppressed because it is too large
Load Diff
@@ -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 />
|
||||
|
||||
@@ -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=
|
||||
|
||||
# =========================
|
||||
# 系统
|
||||
# =========================
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user