Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a05a7ee503 | |||
| ecc3e8a246 | |||
| ed68abb00d | |||
| b8d4553036 | |||
| 587bd77f6a | |||
| ef7af15010 |
@@ -35,7 +35,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
@@ -73,6 +73,8 @@ jobs:
|
||||
run: |
|
||||
tag="${GITHUB_REF_NAME}"
|
||||
version="${tag#v}"
|
||||
version="${version#.}"
|
||||
version="${version#-}"
|
||||
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${tag}"
|
||||
echo "tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# LanQin Email
|
||||
|
||||
[](./README.md)
|
||||
[](./README.zh-CN.md)
|
||||
|
||||

|
||||

|
||||

|
||||
@@ -9,61 +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.
|
||||
|
||||
交流群组:[Telegram 群组](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||
Community: [Telegram group](https://t.me/+EhII7MSyi3QwNDQ5)
|
||||
|
||||
## 功能特性
|
||||
## Features
|
||||
|
||||
- **Webmail 客户端**:多邮箱切换、文件夹、邮件读写、草稿、定时发送、附件、搜索、标签、星标、移动/删除、已读/未读。
|
||||
- **邮箱增强**:联系人、签名、收件规则、发件人黑名单、邮件统计、归档已读、清空回收站/垃圾邮件。
|
||||
- **多域名/多邮箱**:域名管理、DKIM 密钥生成、DNS 记录展示与检测、邮箱账号、别名转发、无人收件开关。
|
||||
- **账号与权限**:登录/注册、会话管理、TOTP 两步验证、Cloudflare Turnstile、用户自助申请邮箱、权限组/RBAC。
|
||||
- **管理员面板**:概览清单、用户/权限组/域名/邮箱/别名/全部邮件管理、系统设置、邮件模板、SMTP 测试。
|
||||
- **邮件服务栈**:Postfix 投递、Dovecot IMAP/POP3、Rspamd 反垃圾与 DKIM 签名、Maildir 到 SQLite 同步。
|
||||
- **部署友好**:默认 all-in-one 单容器,也提供多容器 stack 方便调试 Postfix/Dovecot/Rspamd。
|
||||
- **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 邮件阅读与列表 | 写邮件 · 富文本编辑工具栏 |
|
||||
| Webmail reading and list | Compose · rich-text toolbar |
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
| 多邮箱切换、文件夹、搜索、标签、星标与邮件阅读面板。 | 富文本工具栏支持字体、标题、加粗、斜体、下划线、颜色、高亮、列表、对齐、引用、代码块、附件、表情与定时发送。 |
|
||||
| 管理后台 · 系统概览 | 第三方客户端配置 |
|
||||
|  |  |
|
||||
| 管理用户、权限组、域名、邮箱、别名、系统设置与发送审计。 | 一键查看 IMAP / POP3 / SMTP 服务器、端口、安全方式与账号信息。 |
|
||||
|  |  |
|
||||
| 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
|
||||
@@ -72,7 +75,7 @@ go test ./...
|
||||
go run ./cmd/server
|
||||
```
|
||||
|
||||
前端(新终端):
|
||||
Frontend (new terminal):
|
||||
|
||||
```bash
|
||||
cd apps/web
|
||||
@@ -82,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
|
||||
@@ -123,55 +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_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` | 空 |
|
||||
| `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 │ │
|
||||
@@ -179,53 +182,52 @@ 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` 的证书。
|
||||
5. **外部邮箱接入**:个人邮箱管理可添加外部 IMAP 账号。本地存储模式会同步入库;远端直连模式每次读取远端,不写入本地邮件表。
|
||||
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 趋势
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=LanQin996%2FLanQin-Email&type=date&legend=top-left">
|
||||
<picture>
|
||||
@@ -235,5 +237,4 @@ docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
友情链接:[LINUX DO](https://linux.do/) —— 新的理想型社区
|
||||
|
||||
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/) —— 新的理想型社区
|
||||
|
||||
@@ -49,6 +49,8 @@ type Config struct {
|
||||
ExternalIMAPGmailClientSecret string
|
||||
ExternalIMAPOutlookClientID string
|
||||
ExternalIMAPOutlookClientSecret string
|
||||
MailTranslateEnabled bool
|
||||
MailTranslateMaxChars int
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
@@ -95,6 +97,8 @@ func LoadConfig() Config {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
const googleTranslateEndpoint = "https://translate.googleapis.com/translate_a/single"
|
||||
|
||||
type translateMailMessageRequest struct {
|
||||
TargetLanguage string `json:"targetLanguage"`
|
||||
}
|
||||
|
||||
type translateMailMessageResponse struct {
|
||||
TranslatedText string `json:"translatedText"`
|
||||
TranslatedHTML string `json:"translatedHtml,omitempty"`
|
||||
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
|
||||
}
|
||||
translatedHTML := ""
|
||||
if strings.TrimSpace(msg.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, msg.BodyHTML, target, maxChars)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, 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
|
||||
}
|
||||
translatedHTML := ""
|
||||
if err == nil && strings.TrimSpace(stored.BodyHTML) != "" {
|
||||
translatedHTML, _ = translateHTMLTextNodes(r.Context(), a.policy, stored.BodyHTML, target, maxChars)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, translateMailMessageResponse{TranslatedText: translated, TranslatedHTML: translatedHTML, SourceLanguage: source, TargetLanguage: target, Truncated: truncated})
|
||||
}
|
||||
|
||||
func translateHTMLTextNodes(ctx context.Context, policy *HTMLPolicy, bodyHTML, target string, maxChars int) (string, error) {
|
||||
nodes, err := html.ParseFragment(strings.NewReader(bodyHTML), nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
remaining := maxChars
|
||||
var translateNode func(*html.Node) error
|
||||
translateNode = func(n *html.Node) error {
|
||||
if n.Type == html.ElementNode && shouldSkipHTMLTranslationElement(n.Data) {
|
||||
return nil
|
||||
}
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" && containsTranslatableLetter(text) && remaining > 0 {
|
||||
limited, _ := truncateRunes(text, remaining)
|
||||
remaining -= utf8.RuneCountInString(limited)
|
||||
translated, _, err := googleFreeTranslate(ctx, limited, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n.Data = strings.Replace(n.Data, text, translated, 1)
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if err := translateNode(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if err := translateNode(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
for _, n := range nodes {
|
||||
if err := html.Render(&b, n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if policy != nil {
|
||||
return policy.Sanitize(b.String()), nil
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func shouldSkipHTMLTranslationElement(tag string) bool {
|
||||
switch strings.ToLower(tag) {
|
||||
case "script", "style", "code", "pre", "textarea":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func containsTranslatableLetter(value string) bool {
|
||||
for _, r := range value {
|
||||
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '\u4e00' && r <= '\u9fff') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -84,10 +84,12 @@ func (a *App) Router() http.Handler {
|
||||
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)
|
||||
|
||||
@@ -69,6 +69,7 @@ export type MailMessage = {
|
||||
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; translatedHtml?: 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 }
|
||||
|
||||
@@ -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, ExternalImapAccount, ExternalImapAccountPayload, ExternalImapFolder, ExternalImapOAuthProvider, ExternalImapOAuthStartPayload, ExternalImapSyncRun, 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
|
||||
@@ -164,6 +164,8 @@ 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 }),
|
||||
|
||||
@@ -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>,
|
||||
)
|
||||
|
||||
+103
-3
@@ -17,6 +17,7 @@ import { api, ExternalImapAccount, ExternalImapFolder, ListResponse, Mailbox, Ma
|
||||
import { cn, decodeMimeHeader, formatBytes, formatDate, formatDateTime, generateLabelColor } from "@/lib/utils"
|
||||
import { applyTheme, getInitialTheme } from "@/lib/theme"
|
||||
import { useDisplayMode } from "@/lib/display-mode"
|
||||
import { Language, languageOptions, useLanguage } from "@/lib/language"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
@@ -102,6 +103,7 @@ export function MailPage() {
|
||||
const [expandedExternalAccountIds, setExpandedExternalAccountIds] = React.useState<string[]>([])
|
||||
const [externalFolder, setExternalFolder] = React.useState("INBOX")
|
||||
const [darkMode, setDarkMode] = React.useState(getInitialTheme)
|
||||
const [language, setLanguage] = useLanguage()
|
||||
const [displayMode] = useDisplayMode()
|
||||
const isMobile = useIsMobile()
|
||||
const [refreshing, setRefreshing] = React.useState(false)
|
||||
@@ -980,6 +982,8 @@ export function MailPage() {
|
||||
email={me.data?.user.email || selectedMailbox?.address}
|
||||
darkMode={darkMode}
|
||||
onToggleTheme={() => setDarkMode((value) => !value)}
|
||||
language={language}
|
||||
onLanguageChange={setLanguage}
|
||||
onSettings={openSettings}
|
||||
/>
|
||||
<div className={cn("mt-2 flex gap-2", sidebarCollapsed && "justify-center")}>
|
||||
@@ -1278,6 +1282,7 @@ export function MailPage() {
|
||||
canOrganize={canOrganizeMail && mailView !== "external"}
|
||||
canManageLabels={canManageLabels && mailView !== "external"}
|
||||
canDownloadAttachments={canDownloadAttachments}
|
||||
language={language}
|
||||
/>
|
||||
) : (
|
||||
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||
@@ -1336,7 +1341,7 @@ export function MailPage() {
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="p-6">
|
||||
<MailHtmlFrame message={selected} />
|
||||
<TranslatableMailBody message={selected} language={language} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex items-center justify-between rounded-md border p-3 text-sm hover:bg-accent" href={attachmentHref(selected, a.id)} key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex items-center justify-between rounded-md border p-3 text-sm text-muted-foreground" key={a.id}><span className="flex items-center gap-2"><Paperclip className="h-4 w-4" />{a.filename}</span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
@@ -2212,6 +2217,7 @@ function CompactMailView({
|
||||
canOrganize,
|
||||
canManageLabels,
|
||||
canDownloadAttachments,
|
||||
language,
|
||||
}: {
|
||||
title: string
|
||||
icon?: React.ReactNode
|
||||
@@ -2251,6 +2257,7 @@ function CompactMailView({
|
||||
canOrganize: boolean
|
||||
canManageLabels: boolean
|
||||
canDownloadAttachments: boolean
|
||||
language: Language
|
||||
}) {
|
||||
const selectedIndex = selectedId ? messages.findIndex((message) => message.id === selectedId) : -1
|
||||
const previousMessage = selectedIndex > 0 ? messages[selectedIndex - 1] : undefined
|
||||
@@ -2280,6 +2287,7 @@ function CompactMailView({
|
||||
canOrganize={canOrganize}
|
||||
canManageLabels={canManageLabels}
|
||||
canDownloadAttachments={canDownloadAttachments}
|
||||
language={language}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2343,6 +2351,7 @@ function CompactMessageDetail({
|
||||
canOrganize,
|
||||
canManageLabels,
|
||||
canDownloadAttachments,
|
||||
language,
|
||||
}: {
|
||||
selected?: MailMessage
|
||||
loading: boolean
|
||||
@@ -2365,6 +2374,7 @@ function CompactMessageDetail({
|
||||
canOrganize: boolean
|
||||
canManageLabels: boolean
|
||||
canDownloadAttachments: boolean
|
||||
language: Language
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col bg-background">
|
||||
@@ -2446,7 +2456,7 @@ function CompactMessageDetail({
|
||||
/>
|
||||
</div>
|
||||
<div className="py-6 sm:py-8">
|
||||
<MailHtmlFrame message={selected} />
|
||||
<TranslatableMailBody message={selected} language={language} />
|
||||
{selected.attachments && selected.attachments.length > 0 && <div className="mt-8 rounded-lg border p-4"><div className="mb-3 font-medium">附件</div><div className="space-y-2">{selected.attachments.map((a) => canDownloadAttachments ? <a className="flex flex-col gap-1 rounded-md border p-3 text-sm hover:bg-accent sm:flex-row sm:items-center sm:justify-between" href={attachmentHref(selected, a.id)} key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span className="text-muted-foreground">{formatBytes(a.sizeBytes)}</span></a> : <div className="flex flex-col gap-1 rounded-md border p-3 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between" key={a.id}><span className="flex min-w-0 items-center gap-2"><Paperclip className="h-4 w-4 shrink-0" /><span className="truncate">{a.filename}</span></span><span>{formatBytes(a.sizeBytes)}</span></div>)}</div></div>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2456,6 +2466,80 @@ function CompactMessageDetail({
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
|
||||
function TranslatableMailBody({ message, language }: { message: MailMessage; language: Language }) {
|
||||
const [translatedText, setTranslatedText] = React.useState("")
|
||||
const [translatedHtml, setTranslatedHtml] = React.useState("")
|
||||
const [showTranslated, setShowTranslated] = React.useState(false)
|
||||
const [truncated, setTruncated] = React.useState(false)
|
||||
const { toast } = useToast()
|
||||
const targetLanguage = normalizeTranslationLanguage(language)
|
||||
const sourceText = React.useMemo(() => (message.bodyText || stripHtml(message.bodyHtml || message.snippet || "")).trim(), [message.bodyHtml, message.bodyText, message.snippet])
|
||||
const shouldShow = targetLanguage && shouldOfferMessageTranslation(sourceText, language)
|
||||
const translatedMessage = React.useMemo<MailMessage>(() => ({ ...message, bodyText: translatedText, bodyHtml: translatedHtml }), [message, translatedHtml, translatedText])
|
||||
const translate = useMutation({
|
||||
mutationFn: () => message.externalAccountId ? api.translateExternalMessage(message.externalAccountId, message.id, targetLanguage!) : api.translateMessage(message.id, targetLanguage!),
|
||||
onSuccess: (result) => {
|
||||
setTranslatedText(result.translatedText)
|
||||
setTranslatedHtml(result.translatedHtml || "")
|
||||
setTruncated(result.truncated)
|
||||
setShowTranslated(true)
|
||||
},
|
||||
onError: (error) => toast({ title: "翻译失败", description: error instanceof Error ? error.message : "请稍后重试" }),
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
setTranslatedText("")
|
||||
setTranslatedHtml("")
|
||||
setShowTranslated(false)
|
||||
setTruncated(false)
|
||||
translate.reset()
|
||||
}, [message.id, language])
|
||||
|
||||
return (
|
||||
<>
|
||||
{(shouldShow || translatedText) && (
|
||||
<div className="mb-4 rounded-lg border bg-muted/30 p-3 text-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-muted-foreground">
|
||||
{translatedText ? `已翻译为 ${translationTargetLabel(language)},当前${showTranslated ? "显示译文" : "显示原文"}` : `检测到邮件可能不是当前语言,可翻译为 ${translationTargetLabel(language)}`}
|
||||
{truncated && <span className="ml-1">(内容较长,仅翻译前半部分)</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{translatedText && <Button type="button" variant="ghost" size="sm" onClick={() => setShowTranslated((value) => !value)}>{showTranslated ? "显示原文" : "显示译文"}</Button>}
|
||||
<Button type="button" variant="outline" size="sm" disabled={translate.isPending} onClick={() => translate.mutate()}>{translate.isPending ? "翻译中..." : translatedText ? "重新翻译" : "翻译"}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{translatedText && showTranslated ? <MailHtmlFrame message={translatedMessage} /> : <MailHtmlFrame message={message} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeTranslationLanguage(language: Language) {
|
||||
if (language === "zh-CN") return "zh-CN"
|
||||
if (language === "zh-TW") return "zh-TW"
|
||||
if (language === "en") return "en"
|
||||
return ""
|
||||
}
|
||||
|
||||
function translationTargetLabel(language: Language) {
|
||||
if (language === "zh-CN") return "简体中文"
|
||||
if (language === "zh-TW") return "繁體中文"
|
||||
return "English"
|
||||
}
|
||||
|
||||
function shouldOfferMessageTranslation(text: string, language: Language) {
|
||||
if (!text.trim()) return false
|
||||
const cjkCount = (text.match(/[\u4e00-\u9fff]/g) || []).length
|
||||
const latinCount = (text.match(/[a-zA-Z]/g) || []).length
|
||||
if (language === "zh-CN" || language === "zh-TW") return latinCount > 80 && latinCount > cjkCount * 3
|
||||
if (language === "en") return cjkCount > 20
|
||||
return false
|
||||
}
|
||||
|
||||
function MailHtmlFrame({ message }: { message: MailMessage }) {
|
||||
const iframeRef = React.useRef<HTMLIFrameElement>(null)
|
||||
const [height, setHeight] = React.useState(260)
|
||||
@@ -2591,8 +2675,9 @@ function NewLabelButton({ collapsed, pending, onCreate, editing, onEditingChange
|
||||
)
|
||||
}
|
||||
|
||||
function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; onToggleTheme: () => void; onSettings: () => void }) {
|
||||
function AccountHeader({ collapsed, name, email, darkMode, language, onToggleTheme, onLanguageChange, onSettings }: { collapsed: boolean; name: string; email?: string; darkMode: boolean; language: Language; onToggleTheme: () => void; onLanguageChange: (language: Language) => void; onSettings: () => void }) {
|
||||
const displayName = cleanAccountName(name, email)
|
||||
const currentLanguage = languageOptions.find((item) => item.value === language) || languageOptions[0]
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
@@ -2616,6 +2701,21 @@ function AccountHeader({ collapsed, name, email, darkMode, onToggleTheme, onSett
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8 rounded-md text-muted-foreground" onClick={onToggleTheme}>
|
||||
{darkMode ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8 rounded-md text-muted-foreground" aria-label="切换语言" title="切换语言">
|
||||
<span className="text-sm font-medium leading-none">{currentLanguage.shortLabel}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
{languageOptions.map((item) => (
|
||||
<DropdownMenuItem key={item.value} onSelect={() => onLanguageChange(item.value)} className="gap-2">
|
||||
<span className="min-w-0 flex-1">{item.label}</span>
|
||||
<Check className={cn("h-4 w-4 text-emerald-500", item.value === language ? "opacity-100" : "opacity-0")} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button type="button" variant="ghost" size="icon" className="size-8 rounded-md text-muted-foreground" onClick={onSettings}>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user