Author SHA1 Message Date
dsh c881146a40 feat(web): 多语言界面(中/英/日)与用户语言偏好字段
- 新增 internal/i18n 包:以原始中文字符串为 key 的翻译目录(zh 恒等,
  en 兜底,ja 缺译回退 en),t/tf 模板函数与 Accept-Language 解析
  (按 q 值排序,兜底英语)
- User 模型新增 language 字段(auto|en|zh|ja,默认 auto),
  AutoMigrate 自动加列;UserStore 新增 UpdateLanguage
- 语言解析:登录用户按偏好(auto 时取浏览器 Accept-Language),
  登录/封禁页按浏览器语言;所有 c.HTML 渲染注入 Lang 模板数据
- 全部页面模板(用户页 + 管理后台 12 页)接入翻译:导航、文件夹、
  邮件列表/阅读/写信、设置、登录、封禁页、表单、表格、JS 确认框等;
  html lang 属性随语言切换;日期函数(shortDate/time12/time12m)
  与 folderLabel 改为语言感知(上午/下午 vs AM/PM vs 午前/午後)
- 设置页新增「界面语言」选择(自动/English/中文/日本語),
  /settings 表单支持单独提交语言偏好;管理后台用户表单可预设语言
- 封禁原因等入库中文文案在展示时经 t 翻译(未知 key 原样回退)
- 测试:i18n 单测(解析/回退/译文)、render 测试补 Lang 数据、
  时间格式化测试适配新签名;全量 go test 通过
2026-08-20 00:21:10 -04:00
kevin d1ffd10408 up: readme 2026-08-20 11:54:46 +08:00
kevin 975fd34f61 up: readme 2026-08-20 11:54:26 +08:00
kevin 5abe5c4528 Merge pull request 'fix(db): AutoMigrate 前清理旧模型遗留的同名非唯一索引,修复 MySQL 启动失败' (#14) from dsh/mailgo:fix/legacy-ban-index into main
Reviewed-on: kevin/mailgo#14
2026-08-19 23:41:04 -04:00
38 changed files with 2747 additions and 1020 deletions
+279 -252
View File
@@ -1,174 +1,186 @@
# MailGo
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设计。
> English | [中文](README_cn.md)
## 功能特性
A lightweight mail system written in Go, integrating SMTP / IMAP / POP3 protocol servers and a web admin interface.
Web frontend layout: top navigation + left folder sidebar + mail list (three-column design).
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(QQ/Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **Web 邮箱**:QQ 邮箱风格界面,支持收件箱 / 已发送 / 草稿箱、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计
- **协议调用日志**SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询);其他客户端造成的已读/星标/删除变化也实时同步(IMAP STORE/EXPUNGE、POP3 删除、Web 标已读/删除)
- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新;支持「断开并封禁」一键封禁该 IP 全部在线连接(封禁 180 天,可随时解封)
- **外部认证**OAuth2Google / GitHub)、LDAP(可选,默认关闭)
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
- **多数据库**:默认 SQLite,可切换 MySQL
- **跨平台**Linux 生产部署 + Windows 本地调试
## Features
## 界面预览
- **Mail protocols**: SMTP (send), IMAP (sync), POP3 (receive), all with TLS support
- **External delivery**: authenticated users can send mail to external addresses (Gmail/Outlook, etc.), with a built-in outbound queue, **concurrent worker pool delivery** (default 4 workers + max 2 concurrent connections per recipient domain), direct MX delivery, STARTTLS, exponential backoff retry, bounce notifications and DKIM signing
- **Multi-language UI (i18n)**: Chinese / English / Japanese, selectable in user settings (or auto-follow the browser language via Accept-Language; English is the fallback), covering all user and admin pages, 12-hour time formats (AM/PM) and error messages
- **Web mail**: folder data-driven (Web and IMAP share the same MailboxService — whatever IMAP LIST returns is what the UI shows), system folders (Inbox / Sent / Drafts / Trash) plus custom folder management, delete moves to Trash (with restore / permanent delete / empty Trash), unread badges and search filters, select-all / batch delete, sender avatars, rich text editing (Quill.js), attachment upload/download
- **Admin console**: domain management, user management, automatic DKIM key generation, DNS configuration hints, full mail browsing, outbound queue management, IP ban management, dashboard statistics, all timestamps displayed as 12-hour format (AM/PM) in the web timezone
- **Protocol call logs**: every SMTP/IMAP/POP3 connection automatically records source IP, username, success/failure, failure reason and operation summary; filterable by protocol/status/IP/user/time for analyzing password brute-force, relay abuse and other attacks (kept 30 days by default, auto-cleaned)
- **IMAP new-mail push**: real-time push after local delivery (SMTP/Web compose) — clients hanging on IDLE receive new-mail notifications instantly (no polling); read/star/delete changes made by other clients are also synced in real time (IMAP STORE/EXPUNGE, POP3 delete, Web mark-read/delete)
- **Live connection monitoring**: admin console shows active SMTP/IMAP/POP3 connections (source IP, username, TLS, duration), auto-refreshing every 5 seconds; "Disconnect and ban" one-click bans all connections of that IP (ban for 180 days, unban anytime)
- **External auth**: OAuth2 (Google / GitHub), LDAP (optional, disabled by default)
- **Security**: BCrypt password hashing, automatic IP ban on login failures (**tiered bans**: the first 3 times reaching the failure threshold only count without banning, from the 4th time on it bans with escalating durations 30 min → 3 h → 3 months → 6 months, cleared on successful login; **no grace for enumeration attacks**: failures with a non-existent username skip the grace period and are banned on first trigger), outbound rate limiting (anti-abuse), relay denied to unauthenticated users (anti-open-relay), admins can unban
- **Multiple databases**: SQLite by default, switchable to MySQL
- **Cross-platform**: Linux production deployment + Windows local debugging
| 收件箱 | 邮件阅读 |
|--------|----------|
| ![收件箱](docs/screenshots/inbox.png) | ![邮件阅读](docs/screenshots/view.png) |
## Screenshots
| 写信 | 设置 | 登录 |
|------|------|------|
| ![写信](docs/screenshots/compose.png) | ![设置](docs/screenshots/settings.png) | ![登录](docs/screenshots/login.png) |
| Inbox | Mail view |
|-------|-----------|
| ![Inbox](docs/screenshots/inbox.png) | ![Mail view](docs/screenshots/view.png) |
> 截图使用演示数据渲染,实际界面以部署为准。
| Compose | Settings | Login |
|---------|----------|-------|
| ![Compose](docs/screenshots/compose.png) | ![Settings](docs/screenshots/settings.png) | ![Login](docs/screenshots/login.png) |
## 快速开始
> Screenshots are rendered with demo data; the actual UI may differ from your deployment.
### 编译
## Quick Start
### Build
```bash
go build -o mailgo .
```
### 启动
### Start
```bash
./mailgo
```
首次启动会自动创建配置文件和数据库,默认管理员账户:
On first start the config file and database are created automatically, along with the default admin account:
| 项目 | 值 |
| Item | Value |
|------|-------|
| Email | `admin@example.com` |
| Initial password | Randomly generated (16 chars, digits + upper/lowercase letters), printed once in the startup log; or pre-set via the `MAILGO_ADMIN_PASSWORD` environment variable |
> ⚠️ This account is flagged "must change password on first login" — change it immediately on the Settings page.
### Access
| Page | URL |
|------|-----|
| 邮箱 | `admin@example.com` |
| 密码 | `admin` |
> ⚠️ 请在生产环境中立即修改默认密码。
### 访问
| 页面 | 地址 |
|------|------|
| 用户邮箱 | `http://localhost:8080/` |
| 管理后台 | `http://localhost:8080/admin` |
| User mailbox | `http://localhost:8080/` |
| Admin console | `http://localhost:8080/admin` |
---
## 配置文件
## Configuration File
配置文件路径(TOML 格式):
Config file path (TOML format):
| 系统 | 路径 |
|------|------|
| System | Path |
|--------|------|
| Linux | `/etc/mail_go/mail_go.toml` |
| Windows | `./win/etc/mail_go/mail_go.toml` |
首次启动自动生成,缺失字段自动补全。修改后需重启服务生效。
Auto-generated on first start; missing fields are auto-filled. A restart is required for changes to take effect.
### 完整配置参考
### Full Configuration Reference
```toml
[database]
driver = "sqlite" # sqlite | mysql
dsn = "/srv/mail_go/mail.db" # SQLite: 文件路径; MySQL: DSN 连接串
dsn = "/srv/mail_go/mail.db" # SQLite: file path; MySQL: DSN string
[storage]
base_dir = "/srv/mail_go" # 数据根目录
attach_dir = "/srv/mail_go/attachments" # 附件存储目录
base_dir = "/srv/mail_go" # data root directory
attach_dir = "/srv/mail_go/attachments" # attachment storage directory
[web]
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
# 分别意味着会话可被伪造/所有登录态失效)
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
# 仅本地 HTTP 调试时才改为 false
protocol_log_keep_days = 30 # SMTP/IMAP/POP3 协议调用日志保留天数,
# 超出后由后台任务自动清理;0 表示不清理
addr = ":8080" # listen address; TCP port or Unix socket
secret_key = "" # web session signing key; if empty, a random
# key is generated on first start and written
# to this file (back it up: leaking it allows
# forging sessions, losing it invalidates all)
cookie_secure = true # session cookie sent over HTTPS only (Secure
# flag); set to false only for local HTTP debug
protocol_log_keep_days = 30 # retention days for SMTP/IMAP/POP3 protocol
# call logs; expired entries are cleaned up by a
# background task; 0 disables cleanup
[smtp]
addr = ":25" # SMTP 明文端口
tls_addr = ":465" # SMTPS 端口(需配置 TLS 证书)
domain = "example.com" # 邮件域名
tls_cert = "" # TLS 证书路径(留空则不启用 TLS
tls_key = "" # TLS 私钥路径
max_message_bytes = 67108864 # 单封邮件最大 64MB
addr = ":25" # SMTP plaintext port
tls_addr = ":465" # SMTPS port (requires TLS certs)
domain = "example.com" # mail domain
tls_cert = "" # TLS certificate path (empty = TLS disabled)
tls_key = "" # TLS private key path
max_message_bytes = 67108864 # max 64MB per message
[imap]
addr = ":143" # IMAP 明文端口
tls_addr = ":993" # IMAPS 端口
addr = ":143" # IMAP plaintext port
tls_addr = ":993" # IMAPS port
tls_cert = ""
tls_key = ""
[pop3]
addr = ":110" # POP3 明文端口
tls_addr = ":995" # POP3S 端口
addr = ":110" # POP3 plaintext port
tls_addr = ":995" # POP3S port
tls_cert = ""
tls_key = ""
[auth]
oauth2_enabled = false # 是否启用 OAuth2 登录
oauth2_enabled = false # enable OAuth2 login
oauth2_provider = "" # google | github
oauth2_client_id = ""
oauth2_client_secret = ""
oauth2_redirect_url = ""
ldap_enabled = false # 是否启用 LDAP 登录
ldap_server = "" # 例: ldap://localhost:389
ldap_bind_dn = "" # 例: cn=admin,dc=example,dc=com
ldap_enabled = false # enable LDAP login
ldap_server = "" # e.g. ldap://localhost:389
ldap_bind_dn = "" # e.g. cn=admin,dc=example,dc=com
ldap_bind_password = ""
ldap_search_base = "" # 例: ou=users,dc=example,dc=com
ldap_search_filter = "" # 例: (uid=%s)
ldap_search_base = "" # e.g. ou=users,dc=example,dc=com
ldap_search_filter = "" # e.g. (uid=%s)
ldap_use_tls = false
[ban]
max_fail_attempts = 5 # 登录失败次数阈值
ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增:
# 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限)
# 前 3 次达到阈值只计数不封禁,成功登录后清零
max_fail_attempts = 5 # login failure threshold
ban_duration_min = 30 # 1st ban duration (minutes); then escalates:
# 2nd 3h → 3rd 3 months → 4th+ 6 months (cap)
# first 3 threshold hits only count, no ban;
# cleared on successful login
[caddy]
data_dir = "" # Caddy 数据目录(含 certificates/ 的那个),
# 供后台一键导入证书;留空自动探测
# /var/lib/caddy/.local/share/caddy 等常见位置
data_dir = "" # Caddy data directory (the one containing
# certificates/), used for one-click cert import
# in the admin console; auto-detected when empty
# (e.g. /var/lib/caddy/.local/share/caddy)
[outbound]
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
poll_interval = 15 # 外发队列扫描间隔(秒)
workers = 4 # 并发投递 worker 数(多线程并行发送,
# 大量邮件时吞吐提升;0/1 为串行)
batch_size = 50 # 每次扫描最多取出的待投递邮件数
max_concurrent_per_domain = 2 # 同一收件域(或中继)的最大并发连接数,
# 防被判定为滥发;0 表示不限制
max_attempts = 12 # 单封邮件最大投递尝试次数
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
max_recipients = 50 # 单封邮件最大外部收件人数
max_per_min = 30 # 每用户每分钟最大外发数
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
connect_timeout = 30 # 连接远程 MX 超时(秒)
relay_host = "" # 智能主机(smarthost),留空则直投 MX
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 465 端口是否使用 STARTTLS
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
# 默认验证证书(保护中继凭据),仅自签证书
# 内网中继且明确知晓风险时才改为 true
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
hostname = "" # EHLO hostname; defaults to [smtp] domain
poll_interval = 15 # outbound queue scan interval (seconds)
workers = 4 # concurrent delivery workers (parallel sends,
# improves throughput for bulk mail; 0/1 serial)
batch_size = 50 # max messages picked per scan
max_concurrent_per_domain = 2 # max concurrent connections to one recipient
# domain (or relay), prevents spam-lookalike
# bursts; 0 = unlimited
max_attempts = 12 # max delivery attempts per message
retry_base_min = 5 # retry backoff base (minutes), exponential:
# 5/10/20/40...
max_recipients = 50 # max external recipients per message
max_per_min = 30 # max outgoing messages per user per minute
max_per_day = 500 # max outgoing messages per user per day;
# 0 disables external delivery
connect_timeout = 30 # remote MX connect timeout (seconds)
relay_host = "" # smarthost; empty = direct MX delivery
relay_port = 587 # 465 = implicit TLS, other ports use STARTTLS
relay_user = "" # relay auth username (AUTH PLAIN)
relay_password = "" # relay auth password
relay_starttls = true # use STARTTLS on non-465 ports
relay_tls_insecure = false # skip relay TLS certificate verification; certs
# are verified by default (protects relay
# credentials); only set to true for self-signed
# intranet relays when you accept the risk
ip_family = "ipv4" # outbound address family: ipv4 (default) | ipv6 | auto
source_ip = "" # outbound source IP binding (e.g. static IPv6);
# empty = kernel picks
```
---
## 常见配置场景
## Common Configuration Scenarios
### 1. 切换到 MySQL
### 1. Switching to MySQL
```toml
[database]
@@ -176,9 +188,9 @@ driver = "mysql"
dsn = "mailgo:YourPassword@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=Local"
```
MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
MySQL DSN format: `user:password@tcp(host:port)/dbname?params`
> 数据库和用户需提前在 MySQL 中创建:
> The database and user must be created in MySQL first:
> ```sql
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
@@ -186,26 +198,26 @@ MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
> FLUSH PRIVILEGES;
> ```
### 2. Web 使用 Unix Socket
### 2. Web over Unix Socket
```toml
[web]
addr = "/run/mail_go/web.sock"
```
`addr` `/` 开头时,Gin 自动以 Unix socket 方式监听。
When `addr` starts with `/`, Gin listens on a Unix socket automatically.
### 3. 指定会话密钥(容器/多实例部署)
### 3. Setting the Session Key (Container / Multi-Instance)
会话签名密钥可通过环境变量 `MAILGO_SECRET_KEY` 覆盖(优先于配置文件,且不会写入磁盘):
The session signing key can be overridden via the `MAILGO_SECRET_KEY` environment variable (takes precedence over the config file and is never written to disk):
```bash
MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go
```
要求:长度至少 16 字节;留空时由配置文件提供(首次启动自动生成)。更换密钥后所有已登录会话立即失效,用户需重新登录。
Requirement: at least 16 bytes; when empty, the value from the config file is used (auto-generated on first start). After changing the key, all logged-in sessions are invalidated immediately and users must log in again.
Nginx 反向代理配置:
Nginx reverse proxy configuration:
```nginx
server {
@@ -222,15 +234,15 @@ server {
}
```
> 确保 socket 目录存在并有写权限:
> Make sure the socket directory exists and is writable:
> ```bash
> mkdir -p /run/mail_go
> chown mailgo:mailgo /run/mail_go
> ```
### 3. 启用 TLS 加密
### 4. Enabling TLS Encryption
SMTP/IMAP/POP3 启用 TLS(三个协议共享同一证书,或分别配置):
Enable TLS for SMTP/IMAP/POP3 (all three protocols can share one certificate, or be configured separately):
```toml
[smtp]
@@ -246,42 +258,42 @@ tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
```
配置 TLS 后,系统会自动启动对应的加密端口(465/993/995)。管理后台添加域名时勾选 TLS 会自动切换端口号。
Once TLS is configured, the corresponding encrypted ports (465/993/995) start automatically. Checking the TLS box when adding a domain in the admin console switches the ports automatically.
> 可用 Let's Encrypt 获取免费证书:
> You can get free certificates from Let's Encrypt:
> ```bash
> certbot certonly --standalone -d mail.example.com
> # 证书路径: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # 私钥路径: /etc/letsencrypt/live/mail.example.com/privkey.pem
> # Certificate: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # Private key: /etc/letsencrypt/live/mail.example.com/privkey.pem
> ```
#### 从 Caddy 一键导入证书
#### One-Click Certificate Import from Caddy
如果本机已用 [Caddy](https://caddyserver.com/) 托管该域名 HTTPS(Caddy 会自动签发并续期证书),
可在管理后台 **域名管理 → 编辑域名** 页面点击 **“从 Caddy 获取证书”** 按钮,
一键把 Caddy 存储中的证书与私钥导入邮件服务(自动启用该域名的 TLS),无需手动复制 PEM 文件。
支持通配符证书(如 `*.example.com` 可匹配 `mail.example.com`)。
证书支持**热加载**:导入(或手动上传)后立即生效,无需重启服务——SMTP/IMAP/POP3
每次 TLS 握手会自动检查并重载变化的证书文件。
If this machine already serves HTTPS for the domain via [Caddy](https://caddyserver.com/) (Caddy issues and renews certificates automatically),
you can click **"Fetch certificate from Caddy"** on the **Domain Management → Edit Domain** page in the admin console
to import the certificate and private key from Caddy's storage into the mail server (automatically enabling TLS for that domain), without manually copying PEM files.
Wildcard certificates are supported (e.g. `*.example.com` matches `mail.example.com`).
Certificates support **hot reload**: they take effect immediately after import (or manual upload) without restarting the service — SMTP/IMAP/POP3
re-check and reload changed certificate files on every TLS handshake.
由于 Caddy 的证书目录仅 `caddy` 用户可读,install.sh 会安装一个 root 权限的证书同步任务
`mailgo-caddy-sync.{path,timer}`),把 Caddy 证书树镜像到 `/srv/mail_go/tls/caddy`
证书续期后自动同步,mail_go 始终可读;另外还会授予 ACL 权限作为直接读取的兜底。
安装时自动配置,也可手动执行:
Because Caddy's certificate directory is only readable by the `caddy` user, install.sh installs a root-privileged certificate sync job
(`mailgo-caddy-sync.{path,timer}`) that mirrors Caddy's certificate tree to `/srv/mail_go/tls/caddy`,
so mail_go can always read it after renewals; an ACL grant is also applied as a fallback for direct reads.
This is configured automatically during installation, or can be run manually:
```bash
sudo ./install.sh setup-caddy-cert # 自动探测 Caddy 数据目录并配置同步 + ACL
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # 或手动指定数据目录
sudo ./install.sh setup-caddy-cert # auto-detect Caddy data dir, configure sync + ACL
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # or specify the data dir manually
```
Caddy 数据目录不在常见位置,可在配置文件中显式指定:
If Caddy's data directory is not in a common location, specify it explicitly in the config file:
```toml
[caddy]
data_dir = "/var/lib/caddy/.local/share/caddy"
```
### 4. 启用 OAuth2 登录(Google 示例)
### 5. Enabling OAuth2 Login (Google Example)
```toml
[auth]
@@ -292,9 +304,9 @@ oauth2_client_secret = "your-client-secret"
oauth2_redirect_url = "https://mail.example.com/auth/oauth2/callback"
```
> 需要在 [Google Cloud Console](https://console.cloud.google.com/) 创建 OAuth 2.0 客户端,授权回调地址填 `https://你的域名/auth/oauth2/callback`
> You need to create an OAuth 2.0 client in the [Google Cloud Console](https://console.cloud.google.com/) and set the authorized redirect URI to `https://your-domain/auth/oauth2/callback`.
### 5. 启用 LDAP 认证
### 6. Enabling LDAP Authentication
```toml
[auth]
@@ -307,149 +319,164 @@ ldap_search_filter = "(uid=%s)"
ldap_use_tls = true
```
### 6. 对外发送邮件(外部投递)
### 7. Sending Mail to External Recipients (External Delivery)
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
Authenticated users (Web mail / SMTP submission) can send mail to external addresses. The system
delivers directly via the recipient domain's MX records, handling STARTTLS and exponential backoff
retries automatically, and DKIM-signs outgoing mail (using the key generated in the admin domain management).
```toml
[smtp]
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
domain = "example.com" # must be the real mail domain (EHLO/bounce address)
[outbound]
hostname = "mail.example.com" # 建议与邮件主机名一致
hostname = "mail.example.com" # recommended to match the mail hostname
poll_interval = 15
max_attempts = 12
max_per_day = 500 # 设为 0 可完全禁用外部投递
max_per_day = 500 # set to 0 to disable external delivery entirely
```
需要满足的 DNS / 服务器条件(详见 `todo.md` 与后台 DNS 提示页):
Required DNS / server prerequisites (see `todo.md` and the admin DNS hints page for details):
| 项目 | 说明 |
|------|------|
| Item | Description |
|------|-------------|
| MX | `example.com MX 10 mail.example.com` |
| SPF | `example.com TXT "v=spf1 mx -all"` |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<公钥>"`(后台自动生成) |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<public key>"` (auto-generated in the admin console) |
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
| PTR | Server IP reverse-resolves to the mail hostname (request from your hosting provider) |
| Network | Port 25 outbound is not blocked by the cloud provider |
安全策略:外部收件人仅接受已认证用户;`MAIL FROM` 必须与登录用户一致;
每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱;
管理员可在后台「外发队列」查看投递状态、手动重试或取消。
Security policy: external recipients are only accepted from authenticated users; `MAIL FROM` must match
the logged-in user; per-user per-minute/day outbound limits apply; failed mail bounces back to the
sender's inbox; admins can view delivery status in the **Outbound Queue** and manually retry or cancel.
> **IPv4/IPv6**:默认仅使用 IPv4 出站(`ip_family = "ipv4"`),因为很多收件方
> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR
> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`),
> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址
> (避免内核使用轮换的临时隐私地址)。
> **IPv4/IPv6**: Only IPv4 outbound is used by default (`ip_family = "ipv4"`), because many recipients
> (e.g. Gmail) reject IPv6 addresses without PTR, while IPv4 usually has matching forward/reverse PTR.
> To use IPv6: ask your provider to configure PTR for the static address (pointing to `mail.example.com`),
> then set `ip_family = "ipv6"` and bind `source_ip` to that static address
> (to avoid the kernel picking rotating temporary privacy addresses).
### 7. 通过智能主机(smarthost)中继外发
### 8. Relaying Outbound Mail Through a Smarthost
服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录,
MicrosoftOutlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
`[outbound]` 中配置即可,所有外部投递自动改走中继:
When the server IP is on a residential/dynamic IP range, it is often listed in policy lists such as Spamhaus PBL,
and recipients like Microsoft (Outlook/Hotmail) reject the mail outright. In that case, it is recommended to hand
outbound mail to a third-party SMTP relay (Mailgun / SendGrid / Amazon SES / Aliyun Direct Mail, etc.),
configured in `[outbound]` — all external delivery automatically routes through the relay:
```toml
[outbound]
relay_host = "smtp.example-relay.com"
relay_port = 587 # 465 为隐式 TLS
relay_port = 587 # 465 = implicit TLS
relay_user = "your-api-user"
relay_password = "your-api-key"
relay_starttls = true
```
中继使用 AUTH PLAIN 认证;本地收件人仍走本地投递,不受影响。
The relay uses AUTH PLAIN authentication; local recipients still use local delivery, unaffected.
### 9. Setting the Initial Admin Password
When the admin account is created on first start, the initial password is randomly generated by default
and printed once in the startup log; to pre-set it (e.g. for deployment scripts), use the `MAILGO_ADMIN_PASSWORD`
environment variable:
```bash
MAILGO_ADMIN_PASSWORD="$(openssl rand -base64 12)" ./mailgo
```
This variable only takes effect when the admin account is first created (ignored if the account already exists);
either way, the password must be changed on first login.
---
## 端口速查
## Port Quick Reference
| 协议 | 明文端口 | TLS 端口 | 说明 |
|------|---------|---------|------|
| SMTP | 25 | 465 | 邮件发送 |
| IMAP | 143 | 993 | 邮箱同步 |
| POP3 | 110 | 995 | 邮件收取 |
| Web | 8080 | — | Web 界面(可配 Unix socket |
| Protocol | Plaintext port | TLS port | Description |
|----------|----------------|----------|-------------|
| SMTP | 25 | 465 | Mail sending |
| IMAP | 143 | 993 | Mailbox sync |
| POP3 | 110 | 995 | Mail retrieval |
| Web | 8080 | — | Web UI (Unix socket supported) |
---
## 目录结构
## Directory Structure
```
mailgo/
├── main.go # 程序入口
├── main.go # program entry
├── config/
│ ├── config.go # 配置加载与合并
│ └── defaults.go # 默认常量
│ ├── config.go # config loading and merging
│ └── defaults.go # default constants
├── internal/
│ ├── db/
│ │ ├── db.go # 数据库初始化(SQLite/MySQL
│ │ └── models.go # GORM 模型定义
│ │ ├── db.go # database initialization (SQLite/MySQL)
│ │ └── models.go # GORM model definitions
│ ├── store/
│ │ ├── stores.go # Store 聚合器
│ │ ├── user_store.go # 用户数据操作
│ │ ├── mail_store.go # 邮件数据操作
│ │ ├── domain_store.go # 域名数据操作
│ │ ├── attachment_store.go # 附件数据操作
│ │ ├── outbound_store.go # 外发队列数据操作
│ │ ├── ban_store.go # 封禁数据操作
│ │ ── protocol_log_store.go # 协议调用日志数据操作
├── smtp_server/server.go # SMTP 服务
│ │ ├── stores.go # Store aggregator
│ │ ├── user_store.go # user data operations
│ │ ├── mail_store.go # mail data operations
│ │ ├── mailbox_store.go # folder (IMAP mailbox) data operations
│ │ ├── domain_store.go # domain data operations
│ │ ├── attachment_store.go # attachment data operations
│ │ ├── outbound_store.go # outbound queue data operations
│ │ ── ban_store.go # ban data operations
│ └── protocol_log_store.go # protocol call log data operations
│ ├── smtp_server/server.go # SMTP server
│ ├── outbound/
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
│ │ ├── manager.go # 外发队列、重试、限速、退信
│ │ └── sign.go # DKIM 签名
│ │ ├── mailer.go # MX lookup and SMTP outbound client
│ │ ├── manager.go # outbound queue, retry, rate limiting, bounces
│ │ └── sign.go # DKIM signing
│ ├── imap_server/
│ │ ├── server.go # IMAP 服务(监听器/能力/跨会话推送)
│ │ ── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE
├── pop3_server/server.go # POP3 服务
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
│ ├── storage/attachment.go # 附件文件存储
│ ├── dkim/keys.go # DKIM 密钥生成
│ │ ├── server.go # IMAP server (listener/capabilities/cross-session push)
│ │ ── service.go # MailboxService (shared by IMAP sessions and Web)
│ └── session.go # IMAP Session implementation (SELECT/FETCH/STORE/EXPUNGE)
│ ├── pop3_server/server.go # POP3 server
│ ├── connhub/hub.go # protocol connection registry (live connection monitoring)
│ ├── storage/attachment.go # attachment file storage
│ ├── dkim/keys.go # DKIM key generation
│ ├── auth/
│ │ ├── provider.go # 认证接口
│ │ ├── oauth2.go # OAuth2 认证
│ │ └── ldap.go # LDAP 认证
│ │ ├── provider.go # auth interface
│ │ ├── oauth2.go # OAuth2 authentication
│ │ └── ldap.go # LDAP authentication
│ └── web/
│ ├── server.go # Web 路由与模板加载
│ ├── server.go # web routing and template loading
│ ├── handlers/
│ │ ├── auth.go # 登录/登出/OAuth2/LDAP
│ │ ├── mail.go # 邮箱操作
│ │ └── admin.go # 管理后台
│ │ ├── auth.go # login/logout/OAuth2/LDAP
│ │ ├── mail.go # mailbox operations
│ │ └── admin.go # admin console
│ ├── middleware/
│ │ ├── auth.go # 会话认证
│ │ ├── admin.go # 管理员权限
│ │ └── ban.go # IP 封禁检查
│ └── templates/ # HTML 模板
│ ├── *.html # 用户页面
│ └── admin/*.html # 管理页面
│ │ ├── auth.go # session authentication
│ │ ├── admin.go # admin authorization
│ │ └── ban.go # IP ban check
│ └── templates/ # HTML templates
│ ├── *.html # user pages
│ └── admin/*.html # admin pages
└── .gitignore
```
### 运行时数据目录(Linux
### Runtime Data Directories (Linux)
| 路径 | 说明 |
|------|------|
| `/etc/mail_go/` | 配置文件 |
| `/srv/mail_go/` | 数据库 + 附件存储 |
| Path | Description |
|------|-------------|
| `/etc/mail_go/` | config file |
| `/srv/mail_go/` | database + attachment storage |
### 运行时数据目录(Windows 调试)
### Runtime Data Directories (Windows Debug)
| 路径 | 说明 |
|------|------|
| `./win/etc/mail_go/` | 配置文件 |
| `./win/srv/mail_go/` | 数据库 + 附件存储 |
| Path | Description |
|------|-------------|
| `./win/etc/mail_go/` | config file |
| `./win/srv/mail_go/` | database + attachment storage |
---
## Linux 服务部署
## Linux Service Deployment
### systemd 服务文件
### systemd Unit File
创建 `/etc/systemd/system/mailgo.service`
Create `/etc/systemd/system/mailgo.service`:
```ini
[Unit]
@@ -466,7 +493,7 @@ Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# 安全加固
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
@@ -476,68 +503,68 @@ ReadWritePaths=/srv/mail_go /run/mail_go /etc/mail_go
WantedBy=multi-user.target
```
### 启动服务
### Starting the Service
```bash
# 创建系统用户
# Create the system user
sudo useradd -r -s /sbin/nologin -d /srv/mail_go mailgo
# 设置目录权限
# Set directory ownership
sudo chown -R mailgo:mailgo /srv/mail_go /etc/mail_go
# 启用并启动
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable mailgo
sudo systemctl start mailgo
# 查看日志
# View logs
sudo journalctl -u mailgo -f
```
---
## DNS 配置
## DNS Configuration
邮件服务需要以下 DNS 记录(管理后台 → DNS 提示 页面可查看完整配置):
The mail service requires the following DNS records (the admin console → DNS hints page shows the full configuration):
| 记录类型 | 名称 | 值 | 说明 |
|---------|------|-----|------|
| A | mail | 服务器 IP | 邮件服务器地址 |
| MX | @ | mail.example.com | 邮件路由 |
| TXT | @ | `v=spf1 mx ~all` | SPF 反垃圾 |
| TXT | default._domainkey | DKIM 公钥(后台自动生成) | DKIM 签名验证 |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC 策略 |
| Type | Name | Value | Description |
|------|------|-------|-------------|
| A | mail | server IP | mail server address |
| MX | @ | mail.example.com | mail routing |
| TXT | @ | `v=spf1 mx ~all` | SPF anti-spam |
| TXT | default._domainkey | DKIM public key (auto-generated in the admin console) | DKIM signature verification |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC policy |
---
## 技术栈
## Tech Stack
| 组件 | 技术 |
|------|------|
| 语言 | Go 1.25+ |
| Web 框架 | Gin |
| 模板引擎 | html/template |
| Component | Technology |
|-----------|------------|
| Language | Go 1.25+ |
| Web framework | Gin |
| Template engine | html/template |
| ORM | GORM |
| 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML |
| Database | SQLite (default) / MySQL |
| Config format | TOML |
| SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap/v2 |
| POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.js (CDN) |
| POP3 | hand-implemented TCP protocol |
| Password hashing | golang.org/x/crypto/bcrypt |
| Rich text | Quill.js (embedded via go:embed, works offline) |
| OAuth2 | golang.org/x/oauth2 |
| LDAP | github.com/go-ldap/ldap/v3 |
| DKIM | RSA 2048 自动生成 |
| DKIM | RSA 2048 auto-generated |
---
## 默认账户
## Default Account
| 角色 | 邮箱 | 密码 | 默认配额 |
|------|------|------|---------|
| 管理员 | admin@example.com | admin | 5 GB |
| Role | Email | Initial password | Default quota |
|------|-------|------------------|---------------|
| Admin | admin@example.com | Randomly generated (printed once in the startup log) or set via `MAILGO_ADMIN_PASSWORD` | 5 GB |
> ⚠️ 部署到生产环境后请立即修改管理员密码。
> ⚠️ Password change is mandatory on first login (also triggered when an admin resets the password); change it immediately after production deployment.
## License
+561
View File
@@ -0,0 +1,561 @@
# MailGo
> [English](README.md) | 中文
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
Web 前端的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设计。
## 功能特性
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **多语言界面(i18n)**:中文 / 英文 / 日文,在「设置」页可选择界面语言(或自动跟随浏览器 Accept-Language,英语兜底),覆盖全部用户页与管理后台、12 小时制时间与错误提示
- **Web 邮箱**:文件夹数据驱动(Web 与 IMAP 共享 MailboxServiceIMAP LIST 返回什么界面就显示什么),系统文件夹(收件箱 / 已发送 / 草稿箱 / 垃圾箱)与自定义文件夹管理、删除邮件移入垃圾箱(支持恢复 / 彻底删除 / 清空)、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计、时间统一按 Web 时区显示 12 小时制(上午/下午)
- **协议调用日志**SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
- **IMAP 新邮件推送**:本地投递(SMTP/Web 写信)成功后实时推送,挂起 IDLE 的客户端即时收到新邮件通知(无需轮询);其他客户端造成的已读/星标/删除变化也实时同步(IMAP STORE/EXPUNGE、POP3 删除、Web 标已读/删除)
- **当前连接监控**:管理后台实时查看 SMTP/IMAP/POP3 活动连接(来源 IP、用户名、TLS、时长),每 5 秒自动刷新;支持「断开并封禁」一键封禁该 IP 全部在线连接(封禁 180 天,可随时解封)
- **外部认证**OAuth2Google / GitHub)、LDAP(可选,默认关闭)
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零;**枚举爆破无宽限**:用户名不存在的失败跳过宽限、首次触发即封禁)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
- **多数据库**:默认 SQLite,可切换 MySQL
- **跨平台**Linux 生产部署 + Windows 本地调试
## 界面预览
| 收件箱 | 邮件阅读 |
|--------|----------|
| ![收件箱](docs/screenshots/inbox.png) | ![邮件阅读](docs/screenshots/view.png) |
| 写信 | 设置 | 登录 |
|------|------|------|
| ![写信](docs/screenshots/compose.png) | ![设置](docs/screenshots/settings.png) | ![登录](docs/screenshots/login.png) |
> 截图使用演示数据渲染,实际界面以部署为准。
## 快速开始
### 编译
```bash
go build -o mailgo .
```
### 启动
```bash
./mailgo
```
首次启动会自动创建配置文件和数据库,默认管理员账户:
| 项目 | 值 |
|------|-----|
| 邮箱 | `admin@example.com` |
| 初始密码 | 随机生成(16 位,数字+大小写字母),仅在启动日志中打印一次;也可用环境变量 `MAILGO_ADMIN_PASSWORD` 预先指定 |
> ⚠️ 该账户已标记「首次登录必须修改密码」,登录后请在 设置 页面立即改密。
### 访问
| 页面 | 地址 |
|------|------|
| 用户邮箱 | `http://localhost:8080/` |
| 管理后台 | `http://localhost:8080/admin` |
---
## 配置文件
配置文件路径(TOML 格式):
| 系统 | 路径 |
|------|------|
| Linux | `/etc/mail_go/mail_go.toml` |
| Windows | `./win/etc/mail_go/mail_go.toml` |
首次启动自动生成,缺失字段自动补全。修改后需重启服务生效。
### 完整配置参考
```toml
[database]
driver = "sqlite" # sqlite | mysql
dsn = "/srv/mail_go/mail.db" # SQLite: 文件路径; MySQL: DSN 连接串
[storage]
base_dir = "/srv/mail_go" # 数据根目录
attach_dir = "/srv/mail_go/attachments" # 附件存储目录
[web]
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
# 随机密钥并写入本文件(请妥善备份,泄露/丢失
# 分别意味着会话可被伪造/所有登录态失效)
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
# 仅本地 HTTP 调试时才改为 false
protocol_log_keep_days = 30 # SMTP/IMAP/POP3 协议调用日志保留天数,
# 超出后由后台任务自动清理;0 表示不清理
[smtp]
addr = ":25" # SMTP 明文端口
tls_addr = ":465" # SMTPS 端口(需配置 TLS 证书)
domain = "example.com" # 邮件域名
tls_cert = "" # TLS 证书路径(留空则不启用 TLS)
tls_key = "" # TLS 私钥路径
max_message_bytes = 67108864 # 单封邮件最大 64MB
[imap]
addr = ":143" # IMAP 明文端口
tls_addr = ":993" # IMAPS 端口
tls_cert = ""
tls_key = ""
[pop3]
addr = ":110" # POP3 明文端口
tls_addr = ":995" # POP3S 端口
tls_cert = ""
tls_key = ""
[auth]
oauth2_enabled = false # 是否启用 OAuth2 登录
oauth2_provider = "" # google | github
oauth2_client_id = ""
oauth2_client_secret = ""
oauth2_redirect_url = ""
ldap_enabled = false # 是否启用 LDAP 登录
ldap_server = "" # 例: ldap://localhost:389
ldap_bind_dn = "" # 例: cn=admin,dc=example,dc=com
ldap_bind_password = ""
ldap_search_base = "" # 例: ou=users,dc=example,dc=com
ldap_search_filter = "" # 例: (uid=%s)
ldap_use_tls = false
[ban]
max_fail_attempts = 5 # 登录失败次数阈值
ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增:
# 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限)
# 前 3 次达到阈值只计数不封禁,成功登录后清零
[caddy]
data_dir = "" # Caddy 数据目录(含 certificates/ 的那个),
# 供后台一键导入证书;留空自动探测
# /var/lib/caddy/.local/share/caddy 等常见位置
[outbound]
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
poll_interval = 15 # 外发队列扫描间隔(秒)
workers = 4 # 并发投递 worker 数(多线程并行发送,
# 大量邮件时吞吐提升;0/1 为串行)
batch_size = 50 # 每次扫描最多取出的待投递邮件数
max_concurrent_per_domain = 2 # 同一收件域(或中继)的最大并发连接数,
# 防被判定为滥发;0 表示不限制
max_attempts = 12 # 单封邮件最大投递尝试次数
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
max_recipients = 50 # 单封邮件最大外部收件人数
max_per_min = 30 # 每用户每分钟最大外发数
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
connect_timeout = 30 # 连接远程 MX 超时(秒)
relay_host = "" # 智能主机(smarthost),留空则直投 MX
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
relay_user = "" # 中继认证用户名(AUTH PLAIN
relay_password = "" # 中继认证密码
relay_starttls = true # 非 465 端口是否使用 STARTTLS
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
# 默认验证证书(保护中继凭据),仅自签证书
# 内网中继且明确知晓风险时才改为 true
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
```
---
## 常见配置场景
### 1. 切换到 MySQL
```toml
[database]
driver = "mysql"
dsn = "mailgo:YourPassword@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=Local"
```
MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
> 数据库和用户需提前在 MySQL 中创建:
> ```sql
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
> GRANT ALL PRIVILEGES ON mailgo.* TO 'mailgo'@'localhost';
> FLUSH PRIVILEGES;
> ```
### 2. Web 使用 Unix Socket
```toml
[web]
addr = "/run/mail_go/web.sock"
```
`addr``/` 开头时,Gin 自动以 Unix socket 方式监听。
### 3. 指定会话密钥(容器/多实例部署)
会话签名密钥可通过环境变量 `MAILGO_SECRET_KEY` 覆盖(优先于配置文件,且不会写入磁盘):
```bash
MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go
```
要求:长度至少 16 字节;留空时由配置文件提供(首次启动自动生成)。更换密钥后所有已登录会话立即失效,用户需重新登录。
Nginx 反向代理配置:
```nginx
server {
listen 80;
server_name mail.example.com;
location / {
proxy_pass http://unix:/run/mail_go/web.sock;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
> 确保 socket 目录存在并有写权限:
> ```bash
> mkdir -p /run/mail_go
> chown mailgo:mailgo /run/mail_go
> ```
### 3. 启用 TLS 加密
为 SMTP/IMAP/POP3 启用 TLS(三个协议共享同一证书,或分别配置):
```toml
[smtp]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
[imap]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
[pop3]
tls_cert = "/etc/mail_go/certs/server.crt"
tls_key = "/etc/mail_go/certs/server.key"
```
配置 TLS 后,系统会自动启动对应的加密端口(465/993/995)。管理后台添加域名时勾选 TLS 会自动切换端口号。
> 可用 Let's Encrypt 获取免费证书:
> ```bash
> certbot certonly --standalone -d mail.example.com
> # 证书路径: /etc/letsencrypt/live/mail.example.com/fullchain.pem
> # 私钥路径: /etc/letsencrypt/live/mail.example.com/privkey.pem
> ```
#### 从 Caddy 一键导入证书
如果本机已用 [Caddy](https://caddyserver.com/) 托管该域名 HTTPS(Caddy 会自动签发并续期证书),
可在管理后台 **域名管理 → 编辑域名** 页面点击 **“从 Caddy 获取证书”** 按钮,
一键把 Caddy 存储中的证书与私钥导入邮件服务(自动启用该域名的 TLS),无需手动复制 PEM 文件。
支持通配符证书(如 `*.example.com` 可匹配 `mail.example.com`)。
证书支持**热加载**:导入(或手动上传)后立即生效,无需重启服务——SMTP/IMAP/POP3
每次 TLS 握手会自动检查并重载变化的证书文件。
由于 Caddy 的证书目录仅 `caddy` 用户可读,install.sh 会安装一个 root 权限的证书同步任务
`mailgo-caddy-sync.{path,timer}`),把 Caddy 证书树镜像到 `/srv/mail_go/tls/caddy`
证书续期后自动同步,mail_go 始终可读;另外还会授予 ACL 权限作为直接读取的兜底。
安装时自动配置,也可手动执行:
```bash
sudo ./install.sh setup-caddy-cert # 自动探测 Caddy 数据目录并配置同步 + ACL
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # 或手动指定数据目录
```
若 Caddy 数据目录不在常见位置,可在配置文件中显式指定:
```toml
[caddy]
data_dir = "/var/lib/caddy/.local/share/caddy"
```
### 4. 启用 OAuth2 登录(Google 示例)
```toml
[auth]
oauth2_enabled = true
oauth2_provider = "google"
oauth2_client_id = "your-client-id.apps.googleusercontent.com"
oauth2_client_secret = "your-client-secret"
oauth2_redirect_url = "https://mail.example.com/auth/oauth2/callback"
```
> 需要在 [Google Cloud Console](https://console.cloud.google.com/) 创建 OAuth 2.0 客户端,授权回调地址填 `https://你的域名/auth/oauth2/callback`。
### 5. 启用 LDAP 认证
```toml
[auth]
ldap_enabled = true
ldap_server = "ldap://ldap.example.com:389"
ldap_bind_dn = "cn=admin,dc=example,dc=com"
ldap_bind_password = "ldap_admin_password"
ldap_search_base = "ou=users,dc=example,dc=com"
ldap_search_filter = "(uid=%s)"
ldap_use_tls = true
```
### 6. 对外发送邮件(外部投递)
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
```toml
[smtp]
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
[outbound]
hostname = "mail.example.com" # 建议与邮件主机名一致
poll_interval = 15
max_attempts = 12
max_per_day = 500 # 设为 0 可完全禁用外部投递
```
需要满足的 DNS / 服务器条件(详见 `todo.md` 与后台 DNS 提示页):
| 项目 | 说明 |
|------|------|
| MX | `example.com MX 10 mail.example.com` |
| SPF | `example.com TXT "v=spf1 mx -all"` |
| DKIM | `default._domainkey.example.com TXT "v=DKIM1; k=rsa; p=<公钥>"`(后台自动生成) |
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
安全策略:外部收件人仅接受已认证用户;`MAIL FROM` 必须与登录用户一致;
每用户每分钟/每日外发数受限;失败邮件会退信到发件人收件箱;
管理员可在后台「外发队列」查看投递状态、手动重试或取消。
> **IPv4/IPv6**:默认仅使用 IPv4 出站(`ip_family = "ipv4"`),因为很多收件方
> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR。
> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`),
> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址
> (避免内核使用轮换的临时隐私地址)。
### 7. 通过智能主机(smarthost)中继外发
服务器 IP 属于家庭宽带/动态 IP 段时,常被 Spamhaus PBL 等策略列表收录,
MicrosoftOutlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
`[outbound]` 中配置即可,所有外部投递自动改走中继:
```toml
[outbound]
relay_host = "smtp.example-relay.com"
relay_port = 587 # 465 为隐式 TLS
relay_user = "your-api-user"
relay_password = "your-api-key"
relay_starttls = true
```
中继使用 AUTH PLAIN 认证;本地收件人仍走本地投递,不受影响。
### 8. 指定初始管理员密码
首次启动创建管理员账户时,初始密码默认随机生成并仅在启动日志中打印一次;
如需预先指定(例如部署脚本),可设置环境变量 `MAILGO_ADMIN_PASSWORD`
```bash
MAILGO_ADMIN_PASSWORD="$(openssl rand -base64 12)" ./mailgo
```
该变量仅在首次创建管理员账户时生效(账户已存在则忽略);无论哪种方式,
首次登录都会强制修改密码。
---
## 端口速查
| 协议 | 明文端口 | TLS 端口 | 说明 |
|------|---------|---------|------|
| SMTP | 25 | 465 | 邮件发送 |
| IMAP | 143 | 993 | 邮箱同步 |
| POP3 | 110 | 995 | 邮件收取 |
| Web | 8080 | — | Web 界面(可配 Unix socket |
---
## 目录结构
```
mailgo/
├── main.go # 程序入口
├── config/
│ ├── config.go # 配置加载与合并
│ └── defaults.go # 默认常量
├── internal/
│ ├── db/
│ │ ├── db.go # 数据库初始化(SQLite/MySQL
│ │ └── models.go # GORM 模型定义
│ ├── store/
│ │ ├── stores.go # Store 聚合器
│ │ ├── user_store.go # 用户数据操作
│ │ ├── mail_store.go # 邮件数据操作
│ │ ├── mailbox_store.go # 文件夹(IMAP mailbox)数据操作
│ │ ├── domain_store.go # 域名数据操作
│ │ ├── attachment_store.go # 附件数据操作
│ │ ├── outbound_store.go # 外发队列数据操作
│ │ ├── ban_store.go # 封禁数据操作
│ │ └── protocol_log_store.go # 协议调用日志数据操作
│ ├── smtp_server/server.go # SMTP 服务
│ ├── outbound/
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
│ │ ├── manager.go # 外发队列、重试、限速、退信
│ │ └── sign.go # DKIM 签名
│ ├── imap_server/
│ │ ├── server.go # IMAP 服务(监听器/能力/跨会话推送)
│ │ ├── service.go # MailboxServiceIMAP 会话与 Web 共享)
│ │ └── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE
│ ├── pop3_server/server.go # POP3 服务
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
│ ├── storage/attachment.go # 附件文件存储
│ ├── dkim/keys.go # DKIM 密钥生成
│ ├── auth/
│ │ ├── provider.go # 认证接口
│ │ ├── oauth2.go # OAuth2 认证
│ │ └── ldap.go # LDAP 认证
│ └── web/
│ ├── server.go # Web 路由与模板加载
│ ├── handlers/
│ │ ├── auth.go # 登录/登出/OAuth2/LDAP
│ │ ├── mail.go # 邮箱操作
│ │ └── admin.go # 管理后台
│ ├── middleware/
│ │ ├── auth.go # 会话认证
│ │ ├── admin.go # 管理员权限
│ │ └── ban.go # IP 封禁检查
│ └── templates/ # HTML 模板
│ ├── *.html # 用户页面
│ └── admin/*.html # 管理页面
└── .gitignore
```
### 运行时数据目录(Linux
| 路径 | 说明 |
|------|------|
| `/etc/mail_go/` | 配置文件 |
| `/srv/mail_go/` | 数据库 + 附件存储 |
### 运行时数据目录(Windows 调试)
| 路径 | 说明 |
|------|------|
| `./win/etc/mail_go/` | 配置文件 |
| `./win/srv/mail_go/` | 数据库 + 附件存储 |
---
## Linux 服务部署
### systemd 服务文件
创建 `/etc/systemd/system/mailgo.service`
```ini
[Unit]
Description=MailGo Mail Server
After=network.target mysql.service
[Service]
Type=simple
User=mailgo
Group=mailgo
WorkingDirectory=/opt/mailgo
ExecStart=/opt/mailgo/mailgo
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
# 安全加固
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/mail_go /run/mail_go /etc/mail_go
[Install]
WantedBy=multi-user.target
```
### 启动服务
```bash
# 创建系统用户
sudo useradd -r -s /sbin/nologin -d /srv/mail_go mailgo
# 设置目录权限
sudo chown -R mailgo:mailgo /srv/mail_go /etc/mail_go
# 启用并启动
sudo systemctl daemon-reload
sudo systemctl enable mailgo
sudo systemctl start mailgo
# 查看日志
sudo journalctl -u mailgo -f
```
---
## DNS 配置
邮件服务需要以下 DNS 记录(管理后台 → DNS 提示 页面可查看完整配置):
| 记录类型 | 名称 | 值 | 说明 |
|---------|------|-----|------|
| A | mail | 服务器 IP | 邮件服务器地址 |
| MX | @ | mail.example.com | 邮件路由 |
| TXT | @ | `v=spf1 mx ~all` | SPF 反垃圾 |
| TXT | default._domainkey | DKIM 公钥(后台自动生成) | DKIM 签名验证 |
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC 策略 |
---
## 技术栈
| 组件 | 技术 |
|------|------|
| 语言 | Go 1.25+ |
| Web 框架 | Gin |
| 模板引擎 | html/template |
| ORM | GORM |
| 数据库 | SQLite(默认)/ MySQL |
| 配置格式 | TOML |
| SMTP | github.com/emersion/go-smtp |
| IMAP | github.com/emersion/go-imap/v2 |
| POP3 | 手工实现 TCP 协议 |
| 密码哈希 | golang.org/x/crypto/bcrypt |
| 富文本 | Quill.jsgo:embed 内嵌,离线可用) |
| OAuth2 | golang.org/x/oauth2 |
| LDAP | github.com/go-ldap/ldap/v3 |
| DKIM | RSA 2048 自动生成 |
---
## 默认账户
| 角色 | 邮箱 | 初始密码 | 默认配额 |
|------|------|---------|---------|
| 管理员 | admin@example.com | 随机生成(启动日志打印一次)或 `MAILGO_ADMIN_PASSWORD` 指定 | 5 GB |
> ⚠️ 首次登录强制修改密码(管理员重置密码同样触发);部署到生产环境后请立即完成改密。
## License
MIT
+32 -29
View File
@@ -6,20 +6,23 @@ import (
// User represents a mail user in the system.
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DomainID uint `gorm:"index" json:"domain_id"`
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
ID uint `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;not null" json:"username"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
DomainID uint `gorm:"index" json:"domain_id"`
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
IsActive bool `gorm:"default:true" json:"is_active"`
IsAdmin bool `gorm:"default:false" json:"is_admin"`
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
// 用户)在首次登录后必须修改密码。
MustChangePassword bool `gorm:"default:false" json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
MustChangePassword bool `gorm:"default:false" json:"-"`
// Language 界面语言偏好:auto(跟随浏览器 Accept-Language| en | zh | ja。
// 默认 auto,浏览器语言无法识别时回退英语。
Language string `gorm:"size:8;default:auto" json:"language"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TableName specifies the table name for User.
@@ -51,20 +54,20 @@ func (Domain) TableName() string {
// Message represents an email message in the system.
type Message struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user"`
MessageID string `gorm:"size:255;index" json:"message_id"`
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
CcAddr string `gorm:"size:2048" json:"cc_addr"`
Subject string `gorm:"size:1024" json:"subject"`
TextBody string `gorm:"type:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index;not null" json:"user_id"`
User User `gorm:"foreignKey:UserID" json:"user"`
MessageID string `gorm:"size:255;index" json:"message_id"`
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
CcAddr string `gorm:"size:2048" json:"cc_addr"`
Subject string `gorm:"size:1024" json:"subject"`
TextBody string `gorm:"type:mediumtext" json:"text_body"`
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
RawData string `gorm:"type:mediumtext" json:"raw_data"`
IsRead bool `gorm:"default:false" json:"is_read"`
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
// IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入,
// EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话
// imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端
@@ -118,9 +121,9 @@ type BanEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
// 防止并发写入产生重复行导致计数与档位读写错位。
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
Reason string `gorm:"size:255" json:"reason"`
FailCount int `gorm:"default:0" json:"fail_count"`
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
Reason string `gorm:"size:255" json:"reason"`
FailCount int `gorm:"default:0" json:"fail_count"`
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
// 成功登录或管理员解封会删除记录,次数随之清零。
+736
View File
@@ -0,0 +1,736 @@
package i18n
// en 英文目录:key 为界面中的原始中文字符串。
// 英语是兜底语言:ja 缺译时回退这里,未知语言也回退这里。
var en = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "AM",
"下午": "PM",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "Inbox",
"已发送": "Sent",
"草稿箱": "Drafts",
"已删除": "Trash",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "Mail",
"搜索邮件(发件人 / 主题)": "Search mail (sender / subject)",
"管理后台": "Admin",
"设置": "Settings",
"退出登录": "Log out",
"写信": "Compose",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "Select all / deselect all",
"全选": "Select all",
"刷新": "Refresh",
"删除": "Delete",
"彻底删除": "Delete permanently",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "Empty the Trash folder? This cannot be undone.",
"清空文件夹": "Empty folder",
"共 %d 封": "%d messages",
"暂无邮件": "No messages",
"收件人:%s": "To: %s",
"致:": "To: ",
"(无主题)": "(no subject)",
"[HTML 邮件]": "[HTML email]",
"确定要恢复这封邮件吗?": "Restore this message?",
"恢复到收件箱": "Restore to inbox",
"确定要删除这封邮件吗?": "Delete this message?",
"第 %d / %d 页": "Page %d of %d",
"上一页": "Previous",
"下一页": "Next",
"确定要删除选中的 %s 封邮件吗?": "Delete the %s selected message(s)?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "View message",
"返回": "Back",
"回复": "Reply",
"恢复": "Restore",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "Permanently delete this message? This cannot be undone.",
"删除邮件": "Delete message",
"抄送:": "Cc: ",
// ---------- 写信(compose.html ----------
"发送": "Send",
"附件": "Attach",
"取消": "Cancel",
"撰写新邮件": "New message",
"收件人": "To",
"输入收件人邮箱地址": "Recipient email address",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "Separate multiple addresses with commas (optional)",
"主题": "Subject",
"输入邮件主题": "Email subject",
"请输入邮件内容...": "Enter your message...",
"附件配额": "Attachment quota",
"移除": "Remove",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ First login / password has been reset. Please change your password before using the mailbox.",
"账号信息": "Account info",
"已用 %s / 配额 %s": "Used %s / Quota %s",
"管理员": "Admin",
"修改密码": "Change password",
"当前密码": "Current password",
"请输入当前密码": "Enter current password",
"新密码": "New password",
"请输入新密码": "Enter new password",
"确认新密码": "Confirm new password",
"请再次输入新密码": "Re-enter new password",
"界面语言": "Interface language",
"自动(跟随浏览器)": "Auto (browser)",
"保存": "Save",
"语言偏好已保存": "Language preference saved",
"语言偏好保存失败": "Failed to save language preference",
"帮助": "Help",
"客户端收发信(IMAP / SMTP)配置:": "IMAP / SMTP client settings:",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP server: %s port %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP server: %s port %d (submission) / SSL %d",
// ---------- 登录(login.html ----------
"登录": "Log in",
"登录您的邮箱账户": "Log in to your mailbox",
"邮箱地址": "Email address",
"密码": "Password",
"请输入密码": "Enter password",
"登 录": "Log in",
"或": "or",
"LDAP 用户名": "LDAP username",
"LDAP 密码": "LDAP password",
"LDAP 登录": "LDAP sign in",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "Access denied",
"您的 IP 已被暂时禁止访问": "Your IP has been temporarily blocked",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "Your IP address has been temporarily banned due to too many failed login attempts.",
"IP 地址": "IP address",
"封禁档位": "Ban level",
"第 %d 次封禁": "Ban #%d",
"原因": "Reason",
"失败次数": "Failed attempts",
"%d 次": "%d times",
"解封时间": "Unban time",
"请在解封时间后重试,或联系管理员。": "Please retry after the unban time, or contact the administrator.",
"返回登录页": "Back to login",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "Back to mailbox",
"控制面板": "Dashboard",
"域名管理": "Domains",
"用户管理": "Users",
"所有邮件": "All mail",
"外发队列": "Outbound queue",
"协议日志": "Protocol logs",
"当前连接": "Connections",
"IP黑名单": "IP blacklist",
// ---------- 控制面板(dashboard.html ----------
"域名数": "Domains",
"用户数": "Users",
"邮件总数": "Total mail",
"被封IP": "Banned IPs",
"邮件分布": "Mail distribution",
"文件夹": "Folder",
"邮件数": "Messages",
"占用空间": "Size",
"合计": "Total",
"收发统计": "Send/receive stats",
"时间段": "Period",
"收件": "Received",
"发件": "Sent",
"今日": "Today",
"近 7 天": "Last 7 days",
"快捷操作": "Quick actions",
"新增域名": "Add domain",
"新增用户": "Add user",
"查看所有邮件": "View all mail",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "Users",
"暂无用户": "No users",
"用户名": "Username",
"域名ID": "Domain ID",
"配额": "Quota",
"已用": "Used",
"状态": "Status",
"操作": "Actions",
"编辑": "Edit",
"确定要删除用户 %s 吗?": "Delete user %s?",
"编辑用户": "Edit user",
"新密码(留空则不修改)": "New password (leave blank to keep)",
"域名": "Domain",
"请选择域名": "Select a domain",
"配额 (GB)": "Quota (GB)",
"启用账户": "Active account",
"保存更改": "Save changes",
"创建用户": "Create user",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "Domains",
"暂无域名": "No domains",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "Delete domain %s? Users of this domain will no longer be able to send or receive mail!",
"编辑域名": "Edit domain",
"SMTP 端口": "SMTP port",
"IMAP 端口": "IMAP port",
"POP3 端口": "POP3 port",
"启用 TLS": "Enable TLS",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "When checked, ports switch to standard SSL/TLS ports automatically",
"TLS 私钥 PEM": "TLS private key (PEM)",
"TLS 公钥证书 PEM": "TLS certificate (PEM)",
"私钥不会回显;留空则保留现有私钥。": "The private key is not shown; leave blank to keep the current key.",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ Certificate configured; uploading a new certificate hot-reloads automatically.",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ No certificate configured. To enable TLS you must provide both the private key and the certificate.",
"从 Caddy 获取证书": "Fetch certificate from Caddy",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "If this domain is served by local Caddy with HTTPS (automatic certificates), import its certificate and private key with one click; TLS is enabled and hot-reloaded, no restart needed.",
"🔒 从 Caddy 获取证书": "🔒 Fetch certificate from Caddy",
"获取中…": "Fetching...",
"获取失败": "Fetch failed",
"请求失败": "Request failed",
"重新生成 DKIM 密钥": "Regenerate DKIM key",
"✅ DKIM 密钥已配置": "✅ DKIM key configured",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM key not configured",
"创建域名": "Create domain",
"DNS配置": "DNS configuration",
"DNS 配置提示 — %s": "DNS configuration hint — %s",
"请在您的 DNS 服务商处添加以下记录:": "Add the following records at your DNS provider:",
"MX 记录": "MX record",
"SPF 记录 (TXT)": "SPF record (TXT)",
"DKIM 记录 (TXT)": "DKIM record (TXT)",
"DMARC 记录": "DMARC record",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM key not generated yet. Edit the domain to regenerate it.",
"返回域名列表": "Back to domains",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "From",
"所属用户": "Owner",
"日期": "Date",
"全部": "All",
"返回邮件列表": "Back to list",
"发件人:": "From:",
"收件人:": "To:",
"抄送:": "Cc:",
"所属用户:": "Owner:",
"文件夹:": "Folder:",
"时间:": "Time:",
"附件列表": "Attachments",
// ---------- 外发队列(outbound.html ----------
"队列为空": "Queue is empty",
"待发送": "Pending",
"发送中": "Sending",
"已送达": "Delivered",
"等待重试": "Waiting to retry",
"失败": "Failed",
"已取消": "Canceled",
"尝试次数": "Attempts",
"下次重试": "Next retry",
"最后响应 / 错误": "Last response / error",
"创建时间": "Created",
"重试": "Retry",
"确认取消该投递任务?": "Cancel this delivery task?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "Protocol logs (SMTP / IMAP / POP3)",
"清理旧日志": "Clean old logs",
"确认清理 %d 天前的协议日志?": "Delete protocol logs older than %d days?",
"今日 SMTP 失败": "SMTP failures today",
"今日 IMAP 失败": "IMAP failures today",
"今日 POP3 失败": "POP3 failures today",
"历史失败(全部)": "Historical failures (all)",
"全部协议": "All protocols",
"全部状态": "All statuses",
"成功": "Success",
"来源 IP(模糊)": "Source IP (fuzzy)",
"用户名(模糊)": "Username (fuzzy)",
"至": "to",
"筛选": "Filter",
"重置": "Reset",
"时间": "Time",
"端口": "Port",
"失败原因": "Failure reason",
"操作摘要": "Summary",
"消息数": "Messages",
"时长": "Duration",
"暂无记录": "No records",
"第 %d / %d 页(共 %d 条)": "Page %d of %d (%d entries)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "Active connections (SMTP / IMAP / POP3)",
"每 5 秒自动刷新": "Auto-refreshes every 5 seconds",
"当前连接总数": "Total active connections",
"协议": "Protocol",
"来源 IP": "Source IP",
"TLS": "TLS",
"明文": "Plain",
"连接时间": "Connected",
"最后活跃": "Last active",
"断开并封禁": "Disconnect & ban",
"当前没有活动连接": "No active connections",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "Disconnect all connections of IP %s and add to blacklist (180 days)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP blacklist",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "Staged bans: banning starts at the 4th trigger; 30 min → 3 h → 3 mo → 6 mo (max)",
"封禁次数": "Ban count",
"到期时间": "Expires",
"封禁中": "Banned",
"已过期": "Expired",
"仅计数": "Counting only",
"(已过期)": " (expired)",
"解封": "Unban",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "Unban IP %s? Its ban level will be reset.",
"暂无封禁记录": "No ban records",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "Please enter your email and password",
"用户名或密码错误,还剩 %d 次尝试机会": "Incorrect username or password. %d attempts remaining",
"会话保存失败,请重试": "Failed to save session, please retry",
"请输入LDAP用户名和密码": "Please enter your LDAP username and password",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP authentication failed. %d attempts remaining",
"LDAP 账号未接入本系统,请联系管理员": "LDAP account is not linked to this system. Contact the administrator",
"用户已被禁用": "User is disabled",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 state validation failed, please log in again",
"OAuth2 授权码缺失": "Missing OAuth2 authorization code",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 authentication failed. Please retry or contact the administrator",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 account is not linked to this system. Contact the administrator",
"OAuth2 未启用": "OAuth2 is not enabled",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 login is temporarily unavailable, please try again later",
"登录失败次数过多": "Too many failed login attempts",
"LDAP 登录失败次数过多": "Too many LDAP login failures",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "Please enter a recipient",
"配额检查失败,请稍后重试": "Quota check failed, please try again later",
"附件超出配额限制。已用 %s / 总配额 %s": "Attachments exceed the quota. Used %s / total %s",
"外部投递未启用": "Outbound delivery is not enabled",
"外部收件人过多:最多 %d 个": "Too many external recipients: maximum %d",
"外发邮件入队失败 (%s): %v": "Failed to enqueue outbound mail (%s): %v",
"投递邮件失败: %v": "Failed to deliver mail: %v",
"保存邮件失败: %v": "Failed to save mail: %v",
"当前密码不正确": "Current password is incorrect",
"新密码不能为空": "New password cannot be empty",
"两次输入的密码不一致": "The two passwords do not match",
"密码加密失败": "Failed to encrypt password",
"密码更新失败": "Failed to update password",
"密码修改成功": "Password changed successfully",
"邮箱不存在": "Mailbox does not exist",
"加载邮件列表失败: %v": "Failed to load messages: %v",
"无效的邮件ID": "Invalid message ID",
"邮件不存在": "Message does not exist",
"禁止访问": "Access denied",
"无效的附件ID": "Invalid attachment ID",
"附件不存在": "Attachment does not exist",
"读取附件失败": "Failed to read attachment",
"删除失败": "Delete failed",
"恢复失败": "Restore failed",
"清空文件夹失败: %v": "Failed to empty folder: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "Invalid connection ID",
"连接不存在或已断开": "Connection does not exist or is disconnected",
"管理员手动封禁(连接断开)": "Banned manually by admin (connection disconnected)",
"封禁失败: %v": "Ban failed: %v",
"加载域名列表失败: %v": "Failed to load domains: %v",
"请输入域名": "Please enter a domain name",
"创建域名失败: %v": "Failed to create domain: %v",
"无效的域名ID": "Invalid domain ID",
"域名不存在": "Domain does not exist",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Certificate fetched from Caddy and saved to the domain TLS directory; TLS enabled and hot-reloaded, no restart needed.",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS private key and certificate are required to enable TLS",
"TLS 私钥和公钥证书必须同时填写": "TLS private key and certificate must be provided together",
"TLS 证书或私钥无效: %v": "Invalid TLS certificate or private key: %v",
"创建 TLS 证书目录失败: %v": "Failed to create TLS directory: %v",
"保存 TLS 公钥证书失败: %v": "Failed to save TLS certificate: %v",
"保存 TLS 私钥失败: %v": "Failed to save TLS private key: %v",
"从 Caddy 获取证书失败: %v": "Failed to fetch certificate from Caddy: %v",
"更新域名记录失败: %v": "Failed to update domain record: %v",
"更新域名失败: %v": "Failed to update domain: %v",
"删除域名失败: %v": "Failed to delete domain: %v",
"加载用户列表失败: %v": "Failed to load users: %v",
"请填写所有必填字段": "Please fill in all required fields",
"创建用户失败: %v": "Failed to create user: %v",
"无效的用户ID": "Invalid user ID",
"不能删除自己的账户": "You cannot delete your own account",
"删除用户失败: %v": "Failed to delete user: %v",
"用户不存在": "User does not exist",
"更新用户失败: %v": "Failed to update user: %v",
"加载黑名单失败: %v": "Failed to load blacklist: %v",
"无效的记录ID": "Invalid record ID",
"解封失败: %v": "Unban failed: %v",
"加载协议日志失败: %v": "Failed to load protocol logs: %v",
"加载外发队列失败: %v": "Failed to load outbound queue: %v",
"外发服务不可用": "Outbound service is unavailable",
"重试失败: %v": "Retry failed: %v",
"取消失败: %v": "Cancel failed: %v",
"无效的队列ID": "Invalid queue ID",
"禁止访问:需要管理员权限": "Access denied: administrator privileges required",
}
// ja 日文目录:key 为界面中的原始中文字符串;缺译回退英文目录。
var ja = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "午前",
"下午": "午後",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "受信トレイ",
"已发送": "送信済み",
"草稿箱": "下書き",
"已删除": "ゴミ箱",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "メール",
"搜索邮件(发件人 / 主题)": "メールを検索(送信者 / 件名)",
"管理后台": "管理画面",
"设置": "設定",
"退出登录": "ログアウト",
"写信": "作成",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "すべて選択 / 選択解除",
"全选": "すべて選択",
"刷新": "更新",
"删除": "削除",
"彻底删除": "完全に削除",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "「ゴミ箱」フォルダを空にしますか?この操作は元に戻せません。",
"清空文件夹": "フォルダを空にする",
"共 %d 封": "%d 件",
"暂无邮件": "メールはありません",
"收件人:%s": "宛先:%s",
"致:": "宛先:",
"(无主题)": "(件名なし)",
"[HTML 邮件]": "[HTML メール]",
"确定要恢复这封邮件吗?": "このメールを復元しますか?",
"恢复到收件箱": "受信トレイへ復元",
"确定要删除这封邮件吗?": "このメールを削除しますか?",
"第 %d / %d 页": "%d / %d ページ",
"上一页": "前へ",
"下一页": "次へ",
"确定要删除选中的 %s 封邮件吗?": "選択した %s 件のメールを削除しますか?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "メールを表示",
"返回": "戻る",
"回复": "返信",
"恢复": "復元",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "このメールを完全に削除しますか?この操作は元に戻せません。",
"删除邮件": "メールを削除",
"抄送:": "Cc",
// ---------- 写信(compose.html ----------
"发送": "送信",
"附件": "添付",
"取消": "キャンセル",
"撰写新邮件": "新しいメールを作成",
"收件人": "宛先",
"输入收件人邮箱地址": "宛先メールアドレスを入力",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "複数のアドレスはカンマ区切り(任意)",
"主题": "件名",
"输入邮件主题": "メールの件名を入力",
"请输入邮件内容...": "メール本文を入力してください...",
"附件配额": "添付ファイル容量",
"移除": "削除",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ 初回ログインまたはパスワードリセット後です。メール機能を利用する前にパスワードを変更してください。",
"账号信息": "アカウント情報",
"已用 %s / 配额 %s": "使用 %s / 容量 %s",
"管理员": "管理者",
"修改密码": "パスワード変更",
"当前密码": "現在のパスワード",
"请输入当前密码": "現在のパスワードを入力",
"新密码": "新しいパスワード",
"请输入新密码": "新しいパスワードを入力",
"确认新密码": "新しいパスワード(確認)",
"请再次输入新密码": "新しいパスワードを再入力",
"界面语言": "表示言語",
"自动(跟随浏览器)": "自動(ブラウザに従う)",
"保存": "保存",
"语言偏好已保存": "言語設定を保存しました",
"语言偏好保存失败": "言語設定の保存に失敗しました",
"帮助": "ヘルプ",
"客户端收发信(IMAP / SMTP)配置:": "クライアント設定(IMAP / SMTP):",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP サーバー:%s ポート %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP サーバー:%s ポート %d(サブミッション)/ SSL %d",
// ---------- 登录(login.html ----------
"登录": "ログイン",
"登录您的邮箱账户": "メールアカウントにログイン",
"邮箱地址": "メールアドレス",
"密码": "パスワード",
"请输入密码": "パスワードを入力",
"登 录": "ログイン",
"或": "または",
"LDAP 用户名": "LDAP ユーザー名",
"LDAP 密码": "LDAP パスワード",
"LDAP 登录": "LDAP ログイン",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "アクセスが禁止されています",
"您的 IP 已被暂时禁止访问": "お使いの IP は一時的にアクセス禁止されています",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "ログイン失敗が多すぎるため、IP アドレスが一時的にブロックされています。",
"IP 地址": "IP アドレス",
"封禁档位": "ブロック段階",
"第 %d 次封禁": "%d 回目のブロック",
"原因": "理由",
"失败次数": "失敗回数",
"%d 次": "%d 回",
"解封时间": "ブロック解除時刻",
"请在解封时间后重试,或联系管理员。": "ブロック解除後にもう一度お試しいただくか、管理者に連絡してください。",
"返回登录页": "ログインページに戻る",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "メールボックスに戻る",
"控制面板": "ダッシュボード",
"域名管理": "ドメイン管理",
"用户管理": "ユーザー管理",
"所有邮件": "すべてのメール",
"外发队列": "送信キュー",
"协议日志": "プロトコルログ",
"当前连接": "現在の接続",
"IP黑名单": "IP ブラックリスト",
// ---------- 控制面板(dashboard.html ----------
"域名数": "ドメイン数",
"用户数": "ユーザー数",
"邮件总数": "メール総数",
"被封IP": "ブロック済み IP",
"邮件分布": "メール分布",
"文件夹": "フォルダ",
"邮件数": "メール数",
"占用空间": "使用容量",
"合计": "合計",
"收发统计": "送受信統計",
"时间段": "期間",
"收件": "受信",
"发件": "送信",
"今日": "今日",
"近 7 天": "過去 7 日間",
"快捷操作": "クイック操作",
"新增域名": "ドメインを追加",
"新增用户": "ユーザーを追加",
"查看所有邮件": "すべてのメールを表示",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "ユーザー一覧",
"暂无用户": "ユーザーはいません",
"用户名": "ユーザー名",
"域名ID": "ドメイン ID",
"配额": "容量",
"已用": "使用量",
"状态": "状態",
"操作": "操作",
"编辑": "編集",
"确定要删除用户 %s 吗?": "ユーザー %s を削除しますか?",
"编辑用户": "ユーザーを編集",
"新密码(留空则不修改)": "新しいパスワード(空欄なら変更なし)",
"域名": "ドメイン",
"请选择域名": "ドメインを選択",
"配额 (GB)": "容量 (GB)",
"启用账户": "アカウントを有効にする",
"保存更改": "変更を保存",
"创建用户": "ユーザーを作成",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "ドメイン一覧",
"暂无域名": "ドメインはありません",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "ドメイン %s を削除しますか?関連ユーザーはメールの送受信ができなくなります!",
"编辑域名": "ドメインを編集",
"SMTP 端口": "SMTP ポート",
"IMAP 端口": "IMAP ポート",
"POP3 端口": "POP3 ポート",
"启用 TLS": "TLS を有効にする",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "チェックするとポートは標準の SSL/TLS ポートに自動的に切り替わります",
"TLS 私钥 PEM": "TLS 秘密鍵 (PEM)",
"TLS 公钥证书 PEM": "TLS 公開鍵証明書 (PEM)",
"私钥不会回显;留空则保留现有私钥。": "秘密鍵は表示されません。空欄の場合は現在の鍵を保持します。",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ 証明書は設定済みです。新しい証明書をアップロードすると自動的にホットリロードされます。",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ 証明書が未設定です。TLS を有効にするには秘密鍵と証明書の両方が必要です。",
"从 Caddy 获取证书": "Caddy から証明書を取得",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "このドメインがローカル Caddy で HTTPS 運用されている場合(自動証明書)、証明書と秘密鍵をワンクリックでインポートし、TLS を自動で有効化します。ホットリロードされるため再起動は不要です。",
"🔒 从 Caddy 获取证书": "🔒 Caddy から証明書を取得",
"获取中…": "取得中...",
"获取失败": "取得失敗",
"请求失败": "リクエスト失敗",
"重新生成 DKIM 密钥": "DKIM キーを再生成",
"✅ DKIM 密钥已配置": "✅ DKIM キー設定済み",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM キー未設定",
"创建域名": "ドメインを作成",
"DNS配置": "DNS 設定",
"DNS 配置提示 — %s": "DNS 設定ヒント — %s",
"请在您的 DNS 服务商处添加以下记录:": "DNS プロバイダーで次のレコードを追加してください:",
"MX 记录": "MX レコード",
"SPF 记录 (TXT)": "SPF レコード (TXT)",
"DKIM 记录 (TXT)": "DKIM レコード (TXT)",
"DMARC 记录": "DMARC レコード",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM キーが未生成です。ドメインを編集して再生成してください。",
"返回域名列表": "ドメイン一覧に戻る",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "送信者",
"所属用户": "所有者",
"日期": "日付",
"全部": "すべて",
"返回邮件列表": "一覧に戻る",
"发件人:": "送信者:",
"收件人:": "宛先:",
"抄送:": "Cc",
"所属用户:": "所有者:",
"文件夹:": "フォルダ:",
"时间:": "時刻:",
"附件列表": "添付ファイル",
// ---------- 外发队列(outbound.html ----------
"队列为空": "キューは空です",
"待发送": "待機中",
"发送中": "送信中",
"已送达": "送信済み",
"等待重试": "再試行待ち",
"失败": "失敗",
"已取消": "キャンセル済み",
"尝试次数": "試行回数",
"下次重试": "次回再試行",
"最后响应 / 错误": "最終レスポンス / エラー",
"创建时间": "作成日時",
"重试": "再試行",
"确认取消该投递任务?": "この配信タスクをキャンセルしますか?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "プロトコルログ(SMTP / IMAP / POP3",
"清理旧日志": "古いログを削除",
"确认清理 %d 天前的协议日志?": "%d 日前より前のプロトコルログを削除しますか?",
"今日 SMTP 失败": "今日の SMTP 失敗",
"今日 IMAP 失败": "今日の IMAP 失敗",
"今日 POP3 失败": "今日の POP3 失敗",
"历史失败(全部)": "過去の失敗(すべて)",
"全部协议": "すべてのプロトコル",
"全部状态": "すべての状態",
"成功": "成功",
"来源 IP(模糊)": "送信元 IP(あいまい)",
"用户名(模糊)": "ユーザー名(あいまい)",
"至": "〜",
"筛选": "フィルター",
"重置": "リセット",
"时间": "時刻",
"端口": "ポート",
"失败原因": "失敗理由",
"操作摘要": "操作概要",
"消息数": "メッセージ数",
"时长": "所要時間",
"暂无记录": "記録はありません",
"第 %d / %d 页(共 %d 条)": "%d / %d ページ(全 %d 件)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "現在の接続(SMTP / IMAP / POP3",
"每 5 秒自动刷新": "5 秒ごとに自動更新",
"当前连接总数": "現在の接続数",
"协议": "プロトコル",
"来源 IP": "送信元 IP",
"TLS": "TLS",
"明文": "平文",
"连接时间": "接続時刻",
"最后活跃": "最終アクティブ",
"断开并封禁": "切断してブロック",
"当前没有活动连接": "アクティブな接続はありません",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "IP %s のすべての接続を切断してブラックリストに追加しますか(180 日)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP ブラックリスト",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "段階的ブロック:4 回目の発動からブロック開始、30分 → 3時間 → 3か月 → 半年(上限)",
"封禁次数": "ブロック回数",
"到期时间": "解除時刻",
"封禁中": "ブロック中",
"已过期": "期限切れ",
"仅计数": "カウントのみ",
"(已过期)": "(期限切れ)",
"解封": "解除",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "IP %s のブロックを解除しますか?ブロック段階はリセットされます。",
"暂无封禁记录": "ブロック記録はありません",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "メールアドレスとパスワードを入力してください",
"用户名或密码错误,还剩 %d 次尝试机会": "ユーザー名またはパスワードが違います。あと %d 回試行できます",
"会话保存失败,请重试": "セッションの保存に失敗しました。もう一度お試しください",
"请输入LDAP用户名和密码": "LDAP ユーザー名とパスワードを入力してください",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP 認証に失敗しました。あと %d 回試行できます",
"LDAP 账号未接入本系统,请联系管理员": "LDAP アカウントはこのシステムに接続されていません。管理者に連絡してください",
"用户已被禁用": "ユーザーは無効です",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 の state 検証に失敗しました。もう一度ログインしてください",
"OAuth2 授权码缺失": "OAuth2 認可コードがありません",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 認証に失敗しました。再試行するか管理者に連絡してください",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 アカウントはこのシステムに接続されていません。管理者に連絡してください",
"OAuth2 未启用": "OAuth2 は有効ではありません",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 ログインは一時的に利用できません。後でもう一度お試しください",
"登录失败次数过多": "ログイン失敗が多すぎます",
"LDAP 登录失败次数过多": "LDAP ログイン失敗が多すぎます",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "宛先を入力してください",
"配额检查失败,请稍后重试": "容量チェックに失敗しました。後でもう一度お試しください",
"附件超出配额限制。已用 %s / 总配额 %s": "添付ファイルが容量を超えています。使用 %s / 合計 %s",
"外部投递未启用": "外部送信は有効ではありません",
"外部收件人过多:最多 %d 个": "外部宛先が多すぎます:最大 %d 件",
"外发邮件入队失败 (%s): %v": "外部メールのキュー登録に失敗しました (%s): %v",
"投递邮件失败: %v": "メールの配信に失敗しました: %v",
"保存邮件失败: %v": "メールの保存に失敗しました: %v",
"当前密码不正确": "現在のパスワードが正しくありません",
"新密码不能为空": "新しいパスワードは必須です",
"两次输入的密码不一致": "パスワードが一致しません",
"密码加密失败": "パスワードの暗号化に失敗しました",
"密码更新失败": "パスワードの更新に失敗しました",
"密码修改成功": "パスワードを変更しました",
"邮箱不存在": "メールボックスが存在しません",
"加载邮件列表失败: %v": "メール一覧の読み込みに失敗しました: %v",
"无效的邮件ID": "無効なメール ID",
"邮件不存在": "メールが存在しません",
"禁止访问": "アクセスが拒否されました",
"无效的附件ID": "無効な添付ファイル ID",
"附件不存在": "添付ファイルが存在しません",
"读取附件失败": "添付ファイルの読み込みに失敗しました",
"删除失败": "削除に失敗しました",
"恢复失败": "復元に失敗しました",
"清空文件夹失败: %v": "フォルダを空にできませんでした: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "無効な接続 ID",
"连接不存在或已断开": "接続が存在しないか切断されています",
"管理员手动封禁(连接断开)": "管理者による手動ブロック(接続切断)",
"封禁失败: %v": "ブロックに失敗しました: %v",
"加载域名列表失败: %v": "ドメイン一覧の読み込みに失敗しました: %v",
"请输入域名": "ドメイン名を入力してください",
"创建域名失败: %v": "ドメインの作成に失敗しました: %v",
"无效的域名ID": "無効なドメイン ID",
"域名不存在": "ドメインが存在しません",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Caddy から証明書を取得し、ドメインの TLS ディレクトリに保存しました。TLS が有効になり、ホットリロード済みです。再起動は不要です。",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS を有効にするには秘密鍵と証明書が必要です",
"TLS 私钥和公钥证书必须同时填写": "秘密鍵と証明書は両方必要です",
"TLS 证书或私钥无效: %v": "TLS 証明書または秘密鍵が無効です: %v",
"创建 TLS 证书目录失败: %v": "TLS ディレクトリの作成に失敗しました: %v",
"保存 TLS 公钥证书失败: %v": "TLS 証明書の保存に失敗しました: %v",
"保存 TLS 私钥失败: %v": "TLS 秘密鍵の保存に失敗しました: %v",
"从 Caddy 获取证书失败: %v": "Caddy からの証明書取得に失敗しました: %v",
"更新域名记录失败: %v": "ドメイン情報の更新に失敗しました: %v",
"更新域名失败: %v": "ドメインの更新に失敗しました: %v",
"删除域名失败: %v": "ドメインの削除に失敗しました: %v",
"加载用户列表失败: %v": "ユーザー一覧の読み込みに失敗しました: %v",
"请填写所有必填字段": "必須項目をすべて入力してください",
"创建用户失败: %v": "ユーザーの作成に失敗しました: %v",
"无效的用户ID": "無効なユーザー ID",
"不能删除自己的账户": "自分のアカウントは削除できません",
"删除用户失败: %v": "ユーザーの削除に失敗しました: %v",
"用户不存在": "ユーザーが存在しません",
"更新用户失败: %v": "ユーザーの更新に失敗しました: %v",
"加载黑名单失败: %v": "ブラックリストの読み込みに失敗しました: %v",
"无效的记录ID": "無効なレコード ID",
"解封失败: %v": "ブロック解除に失敗しました: %v",
"加载协议日志失败: %v": "プロトコルログの読み込みに失敗しました: %v",
"加载外发队列失败: %v": "送信キューの読み込みに失敗しました: %v",
"外发服务不可用": "送信サービスを利用できません",
"重试失败: %v": "再試行に失敗しました: %v",
"取消失败: %v": "キャンセルに失敗しました: %v",
"无效的队列ID": "無効なキュー ID",
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
}
+139
View File
@@ -0,0 +1,139 @@
// Package i18n provides lightweight UI localization for the MailGo web
// frontend. Supported languages: zh (Chinese), en (English, fallback) and
// ja (Japanese). Users may also pick "auto" so the effective language is
// derived from their browser's Accept-Language header.
//
// Translation keys are the original Chinese UI strings themselves: the zh
// catalog is the identity map, and the en/ja catalogs map those keys to
// their translations. Missing translations fall back to English, then to
// the key itself.
package i18n
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Language preference values stored on the user record.
const (
// LangAuto 跟随浏览器语言(Accept-Language)。
LangAuto = "auto"
// LangEn 英语:兜底语言。
LangEn = "en"
// LangZh 中文。
LangZh = "zh"
// LangJa 日文。
LangJa = "ja"
)
// Supported 判断 lang 是否为可显式选择的界面语言(不含 auto)。
func Supported(lang string) bool {
switch lang {
case LangEn, LangZh, LangJa:
return true
default:
return false
}
}
// Normalize 把浏览器语言标签规整为受支持的语言代码:
// zh-CN / zh-TW / zh-Hans → zhja-JP → jaen-US / en-GB → en
// 其余返回空串(无法识别)。
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
if i := strings.IndexByte(tag, '_'); i >= 0 {
tag = tag[:i]
}
switch tag {
case LangZh, "zh-hans", "zh-hant":
return LangZh
case LangJa:
return LangJa
case LangEn:
return LangEn
default:
return ""
}
}
// FromAcceptLanguage 从 Accept-Language 请求头解析首选语言。
// 按 q 值(缺省 1.0)降序取第一个受支持的语言;都不支持时回退英语。
func FromAcceptLanguage(header string) string {
type entry struct {
lang string
q float64
}
var entries []entry
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tag := part
q := 1.0
if i := strings.IndexByte(part, ';'); i >= 0 {
tag = strings.TrimSpace(part[:i])
for _, param := range strings.Split(part[i+1:], ";") {
param = strings.TrimSpace(param)
if strings.HasPrefix(param, "q=") {
if v, err := strconv.ParseFloat(param[2:], 64); err == nil {
q = v
}
}
}
}
if lang := Normalize(tag); lang != "" {
entries = append(entries, entry{lang: lang, q: q})
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].q > entries[j].q })
for _, e := range entries {
if e.q > 0 {
return e.lang
}
}
return LangEn
}
// Resolve 计算有效界面语言:用户偏好为显式语言时直接采用;
// auto(或空/未知值)时按浏览器 Accept-Language 解析;最终兜底英语。
func Resolve(pref, acceptLanguage string) string {
if Supported(pref) {
return pref
}
return FromAcceptLanguage(acceptLanguage)
}
// T 返回 key 在 lang 下的译文。zh 直接返回 key;
// ja 优先查日文表,缺译回退英文表;其余语言(含未知)查英文表,
// 仍缺失时原样返回 key(保证界面永不出现空文本)。
func T(lang, key string) string {
switch Normalize(lang) {
case LangZh:
return key
case LangJa:
if s, ok := ja[key]; ok {
return s
}
if s, ok := en[key]; ok {
return s
}
return key
default:
if s, ok := en[key]; ok {
return s
}
return key
}
}
// TF 是 T 的格式化版本:对译文执行 fmt.Sprintf。
// 用于带占位符的消息(如「还剩 %d 次尝试机会」)。
func TF(lang, key string, args ...interface{}) string {
return fmt.Sprintf(T(lang, key), args...)
}
+114
View File
@@ -0,0 +1,114 @@
package i18n
import "testing"
func TestNormalize(t *testing.T) {
cases := map[string]string{
"zh": "zh",
"zh-CN": "zh",
"zh-TW": "zh",
"zh-Hans": "zh",
"ja": "ja",
"ja-JP": "ja",
"en": "en",
"en-US": "en",
"en_GB": "en",
"fr": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFromAcceptLanguage(t *testing.T) {
cases := []struct {
header string
want string
}{
{"zh-CN,zh;q=0.9,en;q=0.8", "zh"},
{"ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"en-US,en;q=0.9", "en"},
{"en;q=0.5,zh;q=0.9", "zh"}, // q 值优先
{"fr-FR,de-DE", "en"}, // 全部不支持 → 兜底英语
{"", "en"},
}
for _, tc := range cases {
if got := FromAcceptLanguage(tc.header); got != tc.want {
t.Errorf("FromAcceptLanguage(%q) = %q, want %q", tc.header, got, tc.want)
}
}
}
func TestResolve(t *testing.T) {
cases := []struct {
pref, header, want string
}{
{"auto", "ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"auto", "en-US,en;q=0.9", "en"},
{"auto", "fr-FR", "en"},
{"zh", "en-US,en;q=0.9", "zh"}, // 显式偏好优先于浏览器
{"ja", "en-US", "ja"},
{"en", "zh-CN,zh;q=0.9", "en"},
{"", "zh-CN", "zh"},
{"fr", "zh-CN", "zh"}, // 非法偏好按 auto 处理
}
for _, tc := range cases {
if got := Resolve(tc.pref, tc.header); got != tc.want {
t.Errorf("Resolve(%q, %q) = %q, want %q", tc.pref, tc.header, got, tc.want)
}
}
}
func TestT(t *testing.T) {
key := "收件箱"
// zh 恒等于 key 本身
if got := T("zh", key); got != key {
t.Errorf("T(zh) = %q, want %q", got, key)
}
// en
if got, want := T("en", key), "Inbox"; got != want {
t.Errorf("T(en) = %q, want %q", got, want)
}
// ja
if got, want := T("ja", key), "受信トレイ"; got != want {
t.Errorf("T(ja) = %q, want %q", got, want)
}
// 未知语言 → 英文兜底
if got := T("fr", key); got != "Inbox" {
t.Errorf("T(fr) = %q, want Inbox", got)
}
// 无此键 → 原样返回 key
if got := T("en", "不存在的键"); got != "不存在的键" {
t.Errorf("T(missing) = %q, want key itself", got)
}
}
func TestTF(t *testing.T) {
key := "用户名或密码错误,还剩 %d 次尝试机会"
if got, want := TF("zh", key, 3), "用户名或密码错误,还剩 3 次尝试机会"; got != want {
t.Errorf("TF(zh) = %q, want %q", got, want)
}
if got, want := TF("en", key, 2), "Incorrect username or password. 2 attempts remaining"; got != want {
t.Errorf("TF(en) = %q, want %q", got, want)
}
if got, want := TF("ja", key, 1), "ユーザー名またはパスワードが違います。あと 1 回試行できます"; got != want {
t.Errorf("TF(ja) = %q, want %q", got, want)
}
}
// TestCatalogCoverage 校验 en/ja 目录中不包含空译文。
func TestCatalogCoverage(t *testing.T) {
for k, v := range en {
if v == "" {
t.Errorf("en 目录空译文: %q", k)
}
}
for k, v := range ja {
if v == "" {
t.Errorf("ja 目录空译文: %q", k)
}
}
}
+14 -1
View File
@@ -30,6 +30,8 @@ type UserStore interface {
ListAll(page, size int) ([]db.User, int64, error)
UpdateUsedBytes(id uint, delta int64) error
UpdatePassword(userID uint, hashedPassword string) error
// UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。
UpdateLanguage(userID uint, lang string) error
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
TryReserveQuota(userID uint, delta int64) (bool, error)
@@ -204,11 +206,22 @@ func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error)
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
return s.db.Model(&db.User{}).Where("id = ?", userID).
Updates(map[string]interface{}{
"password_hash": hashedPassword,
"password_hash": hashedPassword,
"must_change_password": false,
}).Error
}
// UpdateLanguage 更新用户界面语言偏好。非法值(非 auto/en/zh/ja)回退 auto。
func (s *userStoreGorm) UpdateLanguage(userID uint, lang string) error {
switch lang {
case "auto", "en", "zh", "ja":
default:
lang = "auto"
}
return s.db.Model(&db.User{}).Where("id = ?", userID).
Update("language", lang).Error
}
// ListAll retrieves a paginated list of all users across all domains.
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
var users []db.User
+114 -106
View File
@@ -16,6 +16,7 @@ import (
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/dkim"
"mail_go/internal/i18n"
"mail_go/internal/outbound"
"mail_go/internal/storage"
"mail_go/internal/store"
@@ -69,13 +70,13 @@ const manualBanDuration = 180 * 24 * time.Hour
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的连接ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的连接ID"))
return
}
conn, ok := h.hub.Get(id)
if !ok {
c.String(http.StatusNotFound, "连接不存在或已断开")
c.String(http.StatusNotFound, i18n.T(langOf(c), "连接不存在或已断开"))
return
}
@@ -83,7 +84,7 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
// 避免与阶段性封禁的计数/档位记录错位。
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "封禁失败: %v", err))
return
}
@@ -105,7 +106,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_connections", gin.H{
c.HTML(200, "admin_connections", withLang(c, gin.H{
"currentUser": currentUser,
"conns": conns,
"total": total,
@@ -114,7 +115,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
"pop3Count": pop3Count,
"now": time.Now(),
"activeFolder": "connections",
})
}))
}
// Dashboard renders the admin dashboard with summary statistics.
@@ -153,7 +154,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dashboard", gin.H{
c.HTML(200, "admin_dashboard", withLang(c, gin.H{
"currentUser": currentUser,
"domainCount": domainCount,
"userCount": userCount,
@@ -171,7 +172,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
"weekSent": weekSent,
"banCount": banCount,
"activeFolder": "admin",
})
}))
}
// ListDomains renders the domain list page.
@@ -179,7 +180,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
page := getPageParam(c, "page", 1)
domains, total, err := h.stores.Domains.List(page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载域名列表失败: %v", err))
return
}
@@ -193,7 +194,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
totalPages = 0
}
c.HTML(200, "admin_domains", gin.H{
c.HTML(200, "admin_domains", withLang(c, gin.H{
"currentUser": currentUser,
"domains": domains,
"total": total,
@@ -201,20 +202,20 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "domains",
})
}))
}
// NewDomain renders the new domain form page.
func (h *AdminHandler) NewDomain(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_domain_form", gin.H{
c.HTML(200, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": "",
"isEdit": false,
"domain": &db.Domain{},
})
}))
}
// CreateDomain processes the new domain form submission.
@@ -227,10 +228,10 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
if name == "" {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": "请输入域名",
"error": i18n.T(langOf(c), "请输入域名"),
"isEdit": false,
"domain": &db.Domain{
Name: name,
@@ -239,7 +240,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
Pop3Port: pop3Port,
TlsEnabled: tlsEnabled,
},
})
}))
return
}
@@ -263,13 +264,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
if err := h.stores.Domains.Create(domain); err != nil {
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": fmt.Sprintf("创建域名失败: %v", err),
"error": i18n.TF(langOf(c), "创建域名失败: %v", err),
"isEdit": false,
"domain": domain,
})
}))
return
}
@@ -280,13 +281,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
func (h *AdminHandler) EditDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return
}
@@ -297,11 +298,11 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
caddyMsg = c.Query("caddy_err")
caddyMsgType = "error"
} else if c.Query("caddy_ok") == "1" {
caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。"
caddyMsg = i18n.T(langOf(c), "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。")
caddyMsgType = "success"
}
c.HTML(200, "admin_domain_form", gin.H{
c.HTML(200, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": "",
@@ -311,20 +312,20 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
"caddyMsg": caddyMsg,
"caddyMsgType": caddyMsgType,
})
}))
}
// UpdateDomain 处理编辑域名表单提交
func (h *AdminHandler) UpdateDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return
}
@@ -335,7 +336,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key"))
tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert"))
if err := h.handleDomainTLSUpdate(domain, tlsPublicCert, tlsPrivateKey); err != nil {
if err := h.handleDomainTLSUpdate(langOf(c), domain, tlsPublicCert, tlsPrivateKey); err != nil {
h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert)
return
}
@@ -351,7 +352,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
}
if err := h.stores.Domains.Update(domain); err != nil {
h.renderDomainFormError(c, domain, fmt.Sprintf("更新域名失败: %v", err), tlsPublicCert)
h.renderDomainFormError(c, domain, i18n.TF(langOf(c), "更新域名失败: %v", err), tlsPublicCert)
return
}
@@ -364,43 +365,45 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return
}
lang := langOf(c)
editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID)
fail := func(msg string) {
fail := func(key string, args ...interface{}) {
msg := i18n.TF(lang, key, args...)
log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg)
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
}
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
if err != nil {
fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err))
fail("从 Caddy 获取证书失败: %v", err)
return
}
// 保存到域名 TLS 目录(与手动上传证书的位置一致)
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err))
fail("创建 TLS 证书目录失败: %v", err)
return
}
certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err))
fail("保存 TLS 公钥证书失败: %v", err)
return
}
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err))
fail("保存 TLS 私钥失败: %v", err)
return
}
@@ -408,7 +411,7 @@ func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
domain.TlsKeyPath = keyPath
domain.TlsEnabled = true
if err := h.stores.Domains.Update(domain); err != nil {
fail(fmt.Sprintf("更新域名记录失败: %v", err))
fail("更新域名记录失败: %v", err)
return
}
@@ -451,7 +454,7 @@ func normalizePEM(s string) string {
return s
}
func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error {
func (h *AdminHandler) handleDomainTLSUpdate(lang string, domain *db.Domain, publicCert, privateKey string) error {
// 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改
publicCert = normalizePEM(strings.TrimSpace(publicCert))
privateKey = normalizePEM(strings.TrimSpace(privateKey))
@@ -465,31 +468,31 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv
if hasExistingCert {
return nil
}
return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书")
return fmt.Errorf("%s", i18n.T(lang, "启用 TLS 时必须填写 TLS 私钥和公钥证书"))
}
if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert {
return nil
}
if publicCert == "" || privateKey == "" {
return fmt.Errorf("TLS 私钥和公钥证书必须同时填写")
return fmt.Errorf("%s", i18n.T(lang, "TLS 私钥和公钥证书必须同时填写"))
}
if _, err := tls.X509KeyPair([]byte(publicCert), []byte(privateKey)); err != nil {
return fmt.Errorf("TLS 证书或私钥无效: %v", err)
return fmt.Errorf("%s", i18n.TF(lang, "TLS 证书或私钥无效: %v", err))
}
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
return fmt.Errorf("创建 TLS 证书目录失败: %v", err)
return fmt.Errorf("%s", i18n.TF(lang, "创建 TLS 证书目录失败: %v", err))
}
certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil {
return fmt.Errorf("保存 TLS 公钥证书失败: %v", err)
return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 公钥证书失败: %v", err))
}
if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil {
return fmt.Errorf("保存 TLS 私钥失败: %v", err)
return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 私钥失败: %v", err))
}
domain.TlsCertPath = certPath
@@ -503,7 +506,7 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
tlsPublicCert = readTLSCert(domain.TlsCertPath)
}
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"error": message,
@@ -511,19 +514,19 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
"domain": domain,
"tlsPublicCert": tlsPublicCert,
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
})
}))
}
// DeleteDomain removes a domain by ID.
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return
}
if err := h.stores.Domains.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "删除域名失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除域名失败: %v", err))
return
}
@@ -534,24 +537,24 @@ func (h *AdminHandler) DeleteDomain(c *gin.Context) {
func (h *AdminHandler) DNSHint(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return
}
domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "域名不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dns_hint", gin.H{
c.HTML(200, "admin_dns_hint", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "domains",
"domain": domain,
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
})
}))
}
// ListUsers renders the user list page.
@@ -560,7 +563,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
users, total, err := h.stores.Users.ListAll(page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载用户列表失败: %v", err))
return
}
@@ -577,7 +580,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
totalPages = 0
}
c.HTML(200, "admin_users", gin.H{
c.HTML(200, "admin_users", withLang(c, gin.H{
"currentUser": currentUser,
"users": users,
"domains": domains,
@@ -586,7 +589,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "users",
})
}))
}
// NewUser renders the new user form page.
@@ -595,14 +598,14 @@ func (h *AdminHandler) NewUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_user_form", gin.H{
c.HTML(200, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": "",
"isEdit": false,
"domains": domains,
"user": &db.User{},
})
}))
}
// CreateUser processes the new user form submission.
@@ -616,10 +619,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
if username == "" || password == "" || domainID == 0 {
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": "请填写所有必填字段",
"error": i18n.T(langOf(c), "请填写所有必填字段"),
"isEdit": false,
"domains": domains,
"user": &db.User{
@@ -627,7 +630,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
DomainID: domainID,
IsAdmin: isAdmin,
},
})
}))
return
}
@@ -636,10 +639,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
if err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": "密码加密失败",
"error": i18n.T(langOf(c), "密码加密失败"),
"isEdit": false,
"domains": domains,
"user": &db.User{
@@ -647,7 +650,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
DomainID: domainID,
IsAdmin: isAdmin,
},
})
}))
return
}
@@ -661,19 +664,20 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
UsedBytes: 0,
IsActive: true,
IsAdmin: isAdmin,
Language: normLang(c.PostForm("language")),
}
if err := h.stores.Users.Create(user); err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": fmt.Sprintf("创建用户失败: %v", err),
"error": i18n.TF(langOf(c), "创建用户失败: %v", err),
"isEdit": false,
"domains": domains,
"user": user,
})
}))
return
}
@@ -684,18 +688,18 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
func (h *AdminHandler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return
}
currentUser, _ := c.Get("currentUser")
if currentUser.(*db.User).ID == uint(id) {
c.String(http.StatusBadRequest, "不能删除自己的账户")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "不能删除自己的账户"))
return
}
if err := h.stores.Users.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "删除用户失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除用户失败: %v", err))
return
}
@@ -706,40 +710,40 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
func (h *AdminHandler) EditUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return
}
user, err := h.stores.Users.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "用户不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
return
}
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_user_form", gin.H{
c.HTML(200, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": "",
"isEdit": true,
"domains": domains,
"user": user,
})
}))
}
// UpdateUser processes the edit user form submission.
func (h *AdminHandler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return
}
user, err := h.stores.Users.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "用户不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
return
}
@@ -757,6 +761,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
user.IsActive = isActive
user.IsAdmin = isAdmin
// 界面语言偏好(表单未提交时保持原值)
if lang := c.PostForm("language"); lang != "" {
user.Language = normLang(lang)
}
// Update password only if a new one is provided
if password != "" {
@@ -764,14 +772,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
if err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": "密码加密失败",
"error": i18n.T(langOf(c), "密码加密失败"),
"isEdit": true,
"domains": domains,
"user": user,
})
}))
return
}
user.PasswordHash = string(hashedPassword)
@@ -782,14 +790,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
if err := h.stores.Users.Update(user); err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "users",
"error": fmt.Sprintf("更新用户失败: %v", err),
"error": i18n.TF(langOf(c), "更新用户失败: %v", err),
"isEdit": true,
"domains": domains,
"user": user,
})
}))
return
}
@@ -802,7 +810,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
bans, total, err := h.stores.Bans.List(page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载黑名单失败: %v", err))
return
}
@@ -823,7 +831,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
totalPages = 0
}
c.HTML(200, "admin_bans", gin.H{
c.HTML(200, "admin_bans", withLang(c, gin.H{
"currentUser": currentUser,
"rows": rows,
"total": total,
@@ -831,7 +839,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
"pageSize": 20,
"totalPages": totalPages,
"activeFolder": "bans",
})
}))
}
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
@@ -844,12 +852,12 @@ type banRow struct {
func (h *AdminHandler) UnbanIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的记录ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的记录ID"))
return
}
if err := h.stores.Bans.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "解封失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "解封失败: %v", err))
return
}
@@ -892,7 +900,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter)
if err != nil {
c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载协议日志失败: %v", err))
return
}
@@ -928,7 +936,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
"to": url.QueryEscape(c.Query("to")),
}
c.HTML(200, "admin_protocol_logs", gin.H{
c.HTML(200, "admin_protocol_logs", withLang(c, gin.H{
"currentUser": currentUser,
"logs": logs,
"total": total,
@@ -940,7 +948,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
"allStats": normStats(allStats),
"keepDays": h.protocolLogKeepDays,
"activeFolder": "protocol-logs",
})
}))
}
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。
@@ -978,7 +986,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
}
if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
return
}
@@ -992,7 +1000,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
totalPages = 0
}
c.HTML(200, "admin_mails", gin.H{
c.HTML(200, "admin_mails", withLang(c, gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
@@ -1001,20 +1009,20 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
"totalPages": totalPages,
"folder": folder,
"activeFolder": "mails",
})
}))
}
// AdminViewMail renders the detail view of a specific mail for admin.
func (h *AdminHandler) AdminViewMail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "邮件不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
return
}
@@ -1023,31 +1031,31 @@ func (h *AdminHandler) AdminViewMail(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_mail_view", gin.H{
c.HTML(200, "admin_mail_view", withLang(c, gin.H{
"currentUser": currentUser,
"message": msg,
"attachments": attachments,
"activeFolder": "mails",
})
}))
}
// AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check).
func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的附件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
return
}
att, err := h.stores.Attachments.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "附件不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
return
}
data, err := h.storage.Read(att.FilePath)
if err != nil {
c.String(http.StatusInternalServerError, "读取附件失败")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
return
}
@@ -1062,7 +1070,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
items, total, err := h.stores.Outbound.List(page, 20, status)
if err != nil {
c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载外发队列失败: %v", err))
return
}
@@ -1088,7 +1096,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_outbound", gin.H{
c.HTML(200, "admin_outbound", withLang(c, gin.H{
"currentUser": currentUser,
"items": items,
"total": total,
@@ -1099,23 +1107,23 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
"statCounts": statCounts,
"statusText": outbound.StatusText,
"activeFolder": "outbound",
})
}))
}
// RetryOutbound resets an outbound queue item for immediate redelivery.
func (h *AdminHandler) RetryOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的队列ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
return
}
if h.outbound == nil {
c.String(http.StatusInternalServerError, "外发服务不可用")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
return
}
if err := h.outbound.Retry(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "重试失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "重试失败: %v", err))
return
}
@@ -1126,16 +1134,16 @@ func (h *AdminHandler) RetryOutbound(c *gin.Context) {
func (h *AdminHandler) CancelOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的队列ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
return
}
if h.outbound == nil {
c.String(http.StatusInternalServerError, "外发服务不可用")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
return
}
if err := h.outbound.Cancel(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "取消失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "取消失败: %v", err))
return
}
+3 -3
View File
@@ -83,7 +83,7 @@ func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) {
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交
if err := h.handleDomainTLSUpdate(domain, submittedCert, ""); err != nil {
if err := h.handleDomainTLSUpdate("zh", domain, submittedCert, ""); err != nil {
t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err)
}
}
@@ -95,7 +95,7 @@ func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) {
h := &AdminHandler{tlsDir: t.TempDir()}
domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true}
if err := h.handleDomainTLSUpdate(domain, certCRLF, keyCRLF); err != nil {
if err := h.handleDomainTLSUpdate("zh", domain, certCRLF, keyCRLF); err != nil {
t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err)
}
@@ -119,7 +119,7 @@ func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) {
h := &AdminHandler{tlsDir: t.TempDir()}
domain := writeExistingCert(t, h.tlsDir, certA, keyA)
err := h.handleDomainTLSUpdate(domain, certB, "")
err := h.handleDomainTLSUpdate("zh", domain, certB, "")
if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
}
+52 -51
View File
@@ -11,6 +11,7 @@ import (
"mail_go/config"
"mail_go/internal/auth"
"mail_go/internal/i18n"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
@@ -37,12 +38,12 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
c.Redirect(302, "/inbox")
return
}
c.HTML(200, "login", gin.H{
c.HTML(200, "login", withLang(c, gin.H{
"error": "",
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
}
// DoLogin processes the login form submission.
@@ -54,7 +55,7 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
@@ -62,12 +63,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
password := c.PostForm("password")
if email == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入邮箱和密码",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "请输入邮箱和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -79,17 +80,17 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
if banned {
entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -105,12 +106,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -124,7 +125,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
// Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip)
if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
@@ -132,12 +133,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
password := c.PostForm("password")
if username == "" || password == "" {
c.HTML(200, "login", gin.H{
"error": "请输入LDAP用户名和密码",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "请输入LDAP用户名和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -153,39 +154,39 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
if banned {
entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return
}
remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining),
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// Look up or auto-create user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": "LDAP 账号未接入本系统,请联系管理员",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -201,12 +202,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -246,7 +247,7 @@ func (h *AuthHandler) oauth2LoginVars() gin.H {
// OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
return
}
@@ -254,7 +255,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
state, err := randomOAuth2State()
if err != nil {
log.Printf("生成 OAuth2 state 失败: %v", err)
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试"))
return
}
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
@@ -264,7 +265,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
return
}
@@ -274,11 +275,11 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
reqState := c.Query("state")
if cookieErr != nil || reqState == "" ||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
c.HTML(http.StatusForbidden, "login", func() gin.H {
c.HTML(http.StatusForbidden, "login", withLang(c, func() gin.H {
v := h.oauth2LoginVars()
v["error"] = "OAuth2 state 校验失败,请重新发起登录"
v["error"] = i18n.T(langOf(c), "OAuth2 state 校验失败,请重新发起登录")
return v
}())
}()))
return
}
// state 一次性使用:无论后续成败都立即失效
@@ -286,12 +287,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
code := c.Query("code")
if code == "" {
c.HTML(200, "login", gin.H{
"error": "OAuth2 授权码缺失",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 授权码缺失"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -299,34 +300,34 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
email, err := provider.HandleCallback(code)
if err != nil {
log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{
"error": "OAuth2 认证失败,请重试或联系管理员",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
// Look up user by email
user, err := h.stores.Users.GetByEmail(email)
if err != nil {
c.HTML(200, "login", gin.H{
"error": "OAuth2 账号未接入本系统,请联系管理员",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
if !user.IsActive {
c.HTML(200, "login", gin.H{
"error": "用户已被禁用",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
@@ -339,12 +340,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{
"error": "会话保存失败,请重试",
c.HTML(200, "login", withLang(c, gin.H{
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider,
})
}))
return
}
+39
View File
@@ -0,0 +1,39 @@
package handlers
import (
"mail_go/internal/i18n"
"github.com/gin-gonic/gin"
)
// langOf 返回当前请求的界面语言:优先取中间件解析好的 context 值
// (登录用户偏好 / 浏览器语言),未设置时按浏览器 Accept-Language 兜底。
func langOf(c *gin.Context) string {
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
return s
}
}
return i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
}
// withLang 把当前请求的界面语言注入模板数据(key "lang"),
// 所有 c.HTML 渲染入口都应经过它,保证模板可调用 {{t .Lang "..."}}。
func withLang(c *gin.Context, data gin.H) gin.H {
if data == nil {
data = gin.H{}
}
data["Lang"] = langOf(c)
return data
}
// normLang 把表单提交的语言偏好规整为合法值(auto | en | zh | ja),
// 非法值回退 auto(跟随浏览器)。
func normLang(v string) string {
switch v {
case i18n.LangAuto, i18n.LangEn, i18n.LangZh, i18n.LangJa:
return v
default:
return i18n.LangAuto
}
}
+64 -52
View File
@@ -13,6 +13,7 @@ import (
"time"
"mail_go/internal/db"
"mail_go/internal/i18n"
"mail_go/internal/imap_server"
"mail_go/internal/outbound"
"mail_go/internal/storage"
@@ -123,14 +124,14 @@ func (h *MailHandler) Folder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
return
}
page := getPageParam(c, "page", 1)
messages, total, err := h.svc.Messages(userID, name, page, 20)
if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
return
}
@@ -143,7 +144,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
}
currentUser, _ := c.Get("currentUser")
c.HTML(200, "folder", gin.H{
c.HTML(200, "folder", withLang(c, gin.H{
"currentUser": currentUser,
"messages": messages,
"total": total,
@@ -154,7 +155,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
"activeFolder": name,
"isTrash": name == "Trash",
"folders": h.foldersFor(userID),
})
}))
}
// View renders the email detail page for a specific message.
@@ -162,19 +163,19 @@ func (h *MailHandler) View(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "邮件不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
return
}
// Verify the message belongs to the current user
if msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
@@ -191,14 +192,14 @@ func (h *MailHandler) View(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
c.HTML(200, "view", gin.H{
c.HTML(200, "view", withLang(c, gin.H{
"currentUser": currentUser,
"message": msg,
"attachments": attachments,
"activeFolder": msg.Folder,
"inTrash": msg.Folder == "Trash",
"folders": h.foldersFor(userID),
})
}))
}
// Compose renders the email composition page.
@@ -215,7 +216,7 @@ func (h *MailHandler) Compose(c *gin.Context) {
quotaBytes = user.QuotaBytes
}
c.HTML(200, "compose", gin.H{
c.HTML(200, "compose", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": "",
@@ -225,12 +226,12 @@ func (h *MailHandler) Compose(c *gin.Context) {
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
})
}))
}
// composeData builds the shared template context for the compose page.
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return gin.H{
func (h *MailHandler) composeData(c *gin.Context, userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return withLang(c, gin.H{
"currentUser": user,
"activeFolder": "compose",
"error": errMsg,
@@ -241,7 +242,7 @@ func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subjec
"usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes,
"folders": h.foldersFor(userID),
}
})
}
// DoSend processes the email composition form, sends the email via SMTP,
@@ -258,7 +259,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
cc := c.PostForm("cc")
if to == "" {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "请输入收件人"), to, subject, cc, htmlBody))
return
}
@@ -276,7 +277,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
}
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
if err != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "配额检查失败,请稍后重试"), to, subject, cc, htmlBody))
return
}
if !reserved {
@@ -285,10 +286,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if user != nil {
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
}
c.HTML(http.StatusBadRequest, "compose", gin.H{
c.HTML(http.StatusBadRequest, "compose", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "compose",
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
"error": i18n.TF(langOf(c), "附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
"to": to,
"subject": subject,
"cc": cc,
@@ -296,7 +297,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
"usedBytes": usedBytes,
"quotaBytes": quotaBytes,
"folders": h.foldersFor(userID),
})
}))
return
}
@@ -355,16 +356,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if len(externalRecipients) > 0 {
ob := h.outbound
if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody))
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "外部投递未启用"), to, subject, cc, htmlBody))
return
}
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
return
}
for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
return
}
}
@@ -386,7 +387,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
IsRead: false,
}
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
return
}
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
@@ -412,7 +413,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
}
if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
return
}
@@ -579,13 +580,13 @@ func (h *MailHandler) Delete(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
userEmail := userEmailOf(c)
@@ -602,7 +603,7 @@ func (h *MailHandler) Delete(c *gin.Context) {
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "删除失败")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "删除失败"))
return
}
if h.pusher != nil && userEmail != "" {
@@ -624,13 +625,13 @@ func (h *MailHandler) Restore(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
if msg.Folder != "Trash" {
@@ -641,7 +642,7 @@ func (h *MailHandler) Restore(c *gin.Context) {
seq := h.seqOfFolder(userID, "Trash", msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "恢复失败")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "恢复失败"))
return
}
if h.pusher != nil {
@@ -658,13 +659,13 @@ func (h *MailHandler) Purge(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
@@ -688,13 +689,13 @@ func (h *MailHandler) EmptyFolder(c *gin.Context) {
userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok {
c.String(http.StatusNotFound, "邮箱不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
return
}
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil {
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err)
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "清空文件夹失败: %v", err))
return
}
seqs := make([]uint32, 0, len(msgs))
@@ -715,13 +716,13 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return
}
msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
@@ -752,26 +753,26 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "无效的附件ID")
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
return
}
att, err := h.stores.Attachments.GetByID(uint(id))
if err != nil {
c.String(http.StatusNotFound, "附件不存在")
c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
return
}
// Verify the message belongs to the current user
msg, err := h.stores.Mails.GetByID(att.MessageID)
if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return
}
data, err := h.storage.Read(att.FilePath)
if err != nil {
c.String(http.StatusInternalServerError, "读取附件失败")
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
return
}
@@ -797,65 +798,76 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
func (h *MailHandler) Settings(c *gin.Context) {
currentUser, _ := c.Get("currentUser")
userID := c.GetUint("userID")
c.HTML(200, "settings", gin.H{
c.HTML(200, "settings", withLang(c, gin.H{
"currentUser": currentUser,
"activeFolder": "settings",
"error": "",
"success": "",
"mustChange": c.Query("force") == "1",
"folders": h.foldersFor(userID),
})
}))
}
// settingsData builds the shared template context for the settings page.
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H {
return gin.H{
func (h *MailHandler) settingsData(c *gin.Context, userID uint, user *db.User, errMsg, success string) gin.H {
return withLang(c, gin.H{
"currentUser": user,
"activeFolder": "settings",
"error": errMsg,
"success": success,
"folders": h.foldersFor(userID),
}
})
}
// UpdateSettings handles the password change form.
// UpdateSettings 处理设置页表单:密码修改(old_password + new_password +
// confirm_password)与界面语言偏好(仅 language 字段)二选一提交。
func (h *MailHandler) UpdateSettings(c *gin.Context) {
userID := c.GetUint("userID")
currentUserVal, _ := c.Get("currentUser")
currentUser := currentUserVal.(*db.User)
// 仅提交 language 字段 → 语言偏好更新(设置页独立表单)
if lang := c.PostForm("language"); lang != "" && c.PostForm("old_password") == "" {
if err := h.stores.Users.UpdateLanguage(userID, lang); err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "语言偏好保存失败"), ""))
return
}
c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "语言偏好已保存")))
return
}
oldPassword := c.PostForm("old_password")
newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password")
// Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", ""))
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "当前密码不正确"), ""))
return
}
if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "新密码不能为空"), ""))
return
}
if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", ""))
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "两次输入的密码不一致"), ""))
return
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", ""))
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码加密失败"), ""))
return
}
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码更新失败"), ""))
return
}
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "密码修改成功")))
}
// formatBytes converts a file size in bytes to a human-readable string.
+22 -12
View File
@@ -5,6 +5,7 @@ package handlers
import (
"encoding/json"
"fmt"
"html/template"
"math"
"net/http"
@@ -25,13 +26,19 @@ import (
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
func testTemplateFuncs() template.FuncMap {
return template.FuncMap{
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r },
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"mul": func(a, b int) int { return a * b },
"div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int {
r := make([]int, n)
for i := range r {
r[i] = i + 1
}
return r
},
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
"jsonify": func(v interface{}) template.JS {
b, _ := json.Marshal(v)
@@ -45,13 +52,16 @@ func testTemplateFuncs() template.FuncMap {
"mailEmail": func(s string) string { return s },
"initial": func(s string) string { return "?" },
"truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") },
"t": func(lang, key string) string { return key },
"tf": func(lang, key string, args ...interface{}) string { return fmt.Sprintf(key, args...) },
"htmlLang": func(lang string) string { return "zh-CN" },
"shortDate": func(lang string, t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t },
"time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
"time12": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04") },
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
"urlPath": func(s string) string { return url.PathEscape(s) },
"folderLabel": func(s string) string { return s },
"folderLabel": func(lang, s string) string { return s },
}
}
@@ -66,7 +76,7 @@ func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.R
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
authCfg := config.AuthConfig{
OAuth2Enabled: true,
OAuth2Enabled: true,
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
// 测试不依赖外部网络。
OAuth2Provider: "127.0.0.1:1",
+9 -2
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"mail_go/internal/db"
"mail_go/internal/i18n"
"github.com/gin-gonic/gin"
)
@@ -12,16 +13,22 @@ import (
// Must be used after AuthMiddleware so that "currentUser" is available.
func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
lang = s
}
}
userVal, exists := c.Get("currentUser")
if !exists {
c.String(http.StatusForbidden, "禁止访问")
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问"))
c.Abort()
return
}
user, ok := userVal.(*db.User)
if !ok || !user.IsAdmin {
c.String(http.StatusForbidden, "禁止访问:需要管理员权限")
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问:需要管理员权限"))
c.Abort()
return
}
+5
View File
@@ -3,6 +3,7 @@ package middleware
import (
"time"
"mail_go/internal/i18n"
"mail_go/internal/store"
"github.com/gin-contrib/sessions"
@@ -100,6 +101,10 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
return
}
// 解析界面语言:用户偏好 auto 时按浏览器 Accept-Language 选择,
// 最终兜底英语。模板数据与错误提示统一使用该值。
c.Set("lang", i18n.Resolve(user.Language, c.GetHeader("Accept-Language")))
c.Set("currentUser", user)
c.Set("userID", id)
c.Next()
+5
View File
@@ -3,6 +3,7 @@ package middleware
import (
"net/http"
"mail_go/internal/i18n"
"mail_go/internal/store"
"github.com/gin-gonic/gin"
@@ -15,8 +16,12 @@ func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
ip := c.ClientIP()
banned, entry := stores.Bans.IsBanned(ip)
if banned {
// 封禁页无登录用户,按浏览器语言渲染
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
c.Set("lang", lang)
c.HTML(http.StatusForbidden, "banned", gin.H{
"entry": entry,
"Lang": lang,
})
c.Abort()
return
+11 -11
View File
@@ -59,24 +59,24 @@ func TestRenderAllPages(t *testing.T) {
name string
data ginH
}{
{"login", ginH{"error": ""}},
{"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"view", ginH{
{"login", ginH{"Lang": "zh", "error": ""}},
{"banned", ginH{"Lang": "zh", "entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"view", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "INBOX",
"message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false},
"attachments": attachments, "inTrash": false, "folders": folders,
}},
{"compose", ginH{
{"compose", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "compose", "error": "",
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
"folders": folders,
}},
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
{"admin_bans", ginH{
{"settings", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
{"admin_bans", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "bans",
"rows": []struct {
db.BanEntry
@@ -88,7 +88,7 @@ func TestRenderAllPages(t *testing.T) {
},
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
}},
{"admin_protocol_logs", ginH{
{"admin_protocol_logs", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "protocol-logs",
"logs": []db.ProtocolLog{
{ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM:<spam@evil.example> RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now},
@@ -109,7 +109,7 @@ func TestRenderAllPages(t *testing.T) {
},
"keepDays": 30,
}},
{"admin_connections", ginH{
{"admin_connections", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "connections",
"conns": []struct {
ID uint64
+49 -27
View File
@@ -18,6 +18,7 @@ import (
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/i18n"
"mail_go/internal/imap_server"
"mail_go/internal/mailutil"
"mail_go/internal/outbound"
@@ -84,6 +85,21 @@ func templateFuncs() template.FuncMap {
"domainName": func(domainID uint, domains []interface{}) string {
return fmt.Sprintf("Domain #%d", domainID)
},
// t / tf 多语言翻译(见 internal/i18n):t 返回译文,
// tf 对译文做 fmt.Sprintf(带占位符的消息)。
"t": i18n.T,
"tf": i18n.TF,
// htmlLang 返回 <html lang="..."> 属性值(zh → zh-CN)。
"htmlLang": func(lang string) string {
switch i18n.Normalize(lang) {
case i18n.LangZh:
return "zh-CN"
case i18n.LangJa:
return "ja"
default:
return "en"
}
},
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
// < > &\u003c 等),无法逃出 </script>,杜绝 script 注入。
@@ -108,12 +124,11 @@ func templateFuncs() template.FuncMap {
"initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate,
// shortDate 邮件列表时间:统一 12 小时制(上午/下午)
// shortDate 邮件列表时间:按界面语言输出 12 小时制
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
// 更早显示「2026-08-20 下午 2:35」。
"shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区
// 「2026-08-20 下午 2:35:05」。
// time12 完整时间(含秒),先转换为 Web 时区
"time12": time12,
// time12m 同上但不含秒。
"time12m": time12m,
@@ -123,24 +138,30 @@ func templateFuncs() template.FuncMap {
"avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
"folderLabel": func(name string) string {
switch name {
case "INBOX":
return "收件箱"
case "Sent":
return "已发送"
case "Drafts":
return "草稿箱"
case "Trash":
return "已删除"
default:
return name
}
// folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。
"folderLabel": func(lang, name string) string {
return folderLabel(lang, name)
},
}
}
// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译,
// 自定义文件夹原样返回。
func folderLabel(lang, name string) string {
switch name {
case "INBOX":
return i18n.T(lang, "收件箱")
case "Sent":
return i18n.T(lang, "已发送")
case "Drafts":
return i18n.T(lang, "草稿箱")
case "Trash":
return i18n.T(lang, "已删除")
default:
return name
}
}
// mailName extracts the display name from an RFC 5322 address.
func mailName(s string) string {
s = strings.TrimSpace(s)
@@ -186,11 +207,12 @@ func truncate(s string, n int) string {
return string(r[:n]) + "…"
}
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
func periodOf(t time.Time) (string, int) {
period := "上午"
// periodOf 返回 12 小时制的时间段与小时:按界面语言输出
// 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。
func periodOf(lang string, t time.Time) (string, int) {
period := i18n.T(lang, "上午")
if t.Hour() >= 12 {
period = "下午"
period = i18n.T(lang, "下午")
}
h := t.Hour() % 12
if h == 0 {
@@ -202,10 +224,10 @@ func periodOf(t time.Time) (string, int) {
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
// 更早 → 「2026-08-20 下午 2:35」。
func shortDate(t time.Time) string {
func shortDate(lang string, t time.Time) string {
t = inWebTZ(t)
now := time.Now().In(t.Location())
period, h := periodOf(t)
period, h := periodOf(lang, t)
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return clock
@@ -218,16 +240,16 @@ func shortDate(t time.Time) string {
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
// 「2026-08-20 下午 2:35:05」。
func time12(t time.Time) string {
func time12(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
}
// time12m 完整时间(12 小时制,不含秒)。
func time12m(t time.Time) string {
func time12m(lang string, t time.Time) string {
t = inWebTZ(t)
period, h := periodOf(t)
period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
}
+31 -31
View File
@@ -1,10 +1,10 @@
{{define "admin_bans"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>IP黑名单 - MailGo</title>
<title>{{t .Lang "IP黑名单"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,32 +12,32 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>IP 黑名单</h2>
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span>
<h2>{{t .Lang "IP 黑名单"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)"}}</span>
</div>
<table>
<thead>
<tr>
<th>IP 地址</th>
<th>状态</th>
<th>封禁次数</th>
<th>失败次数</th>
<th>原因</th>
<th>到期时间</th>
<th>操作</th>
<th>{{t .Lang "IP 地址"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "封禁次数"}}</th>
<th>{{t .Lang "失败次数"}}</th>
<th>{{t .Lang "原因"}}</th>
<th>{{t .Lang "到期时间"}}</th>
<th>{{t .Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -45,21 +45,21 @@
<tr>
<td>{{.IPAddress}}</td>
<td>
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}}
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">{{t $.Lang "封禁中"}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "已过期"}}</span>{{end}}
</td>
<td>
{{if .BanCount}}
{{if gt .BanCount 3}}{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}}
{{if gt .BanCount 3}}{{tf $.Lang "第 %d 次封禁" (sub .BanCount 3)}}{{else}}{{t $.Lang "仅计数"}}{{end}}
{{else}}—{{end}}
</td>
<td>{{.FailCount}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
<td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td>
<td>{{if .Reason}}{{t $.Lang .Reason}}{{else}}—{{end}}</td>
<td>{{time12 $.Lang .ExpiresAt}}{{if not .Active}}{{t $.Lang "(已过期)"}}{{end}}</td>
<td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
<button type="submit" class="btn btn-primary btn-sm">解封</button>
onsubmit="return confirm('{{tf $.Lang "确定要解封 IP %s 解封后该 IP 的封禁档位将清零" .IPAddress}}');">
<button type="submit" class="btn btn-primary btn-sm">{{t $.Lang "解封"}}</button>
</form>
</td>
</tr>
@@ -67,17 +67,17 @@
</tbody>
</table>
{{if not .rows}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无封禁记录</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无封禁记录"}}</p>
{{end}}
</div>
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
<a href="/admin/bans?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
<span>{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
{{if lt .page .totalPages}}
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
<a href="/admin/bans?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+28 -28
View File
@@ -1,10 +1,10 @@
{{define "admin_connections"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>当前连接 - MailGo</title>
<title>{{t .Lang "当前连接"}} - MailGo</title>
<meta http-equiv="refresh" content="5">
{{template "styles" .}}
</head>
@@ -13,26 +13,26 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>当前连接(SMTP / IMAP / POP3</h2>
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span>
<h2>{{t .Lang "当前连接(SMTP / IMAP / POP3"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "每 5 秒自动刷新"}}</span>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{.total}}</h3>
<p>当前连接总数</p>
<p>{{t .Lang "当前连接总数"}}</p>
</div>
<div class="stat-card">
<h3>{{.smtpCount}}</h3>
@@ -53,15 +53,15 @@
<thead>
<tr>
<th>ID</th>
<th>协议</th>
<th>来源 IP</th>
<th>端口</th>
<th>用户名</th>
<th>{{t .Lang "协议"}}</th>
<th>{{t .Lang "来源 IP"}}</th>
<th>{{t .Lang "端口"}}</th>
<th>{{t .Lang "用户名"}}</th>
<th>TLS</th>
<th>连接时间</th>
<th>时长</th>
<th>最后活跃</th>
<th>操作</th>
<th>{{t .Lang "连接时间"}}</th>
<th>{{t .Lang "时长"}}</th>
<th>{{t .Lang "最后活跃"}}</th>
<th>{{t .Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -76,19 +76,19 @@
<td>{{.IP}}</td>
<td>{{.Port}}</td>
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td>
<td>{{time12 .Connected}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "明文"}}</span>{{end}}</td>
<td>{{time12 $.Lang .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{time12 .LastActive}}</td>
<td>{{time12 $.Lang .LastActive}}</td>
<td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button>
onsubmit="return confirm('{{tf $.Lang "确定要断开 IP %s 的所有连接并加入黑名单180 " .IP}}');">
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "断开并封禁"}}</button>
</form>
</td>
</tr>
{{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr>
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">{{t $.Lang "当前没有活动连接"}}</td></tr>
{{end}}
</tbody>
</table>
+34 -34
View File
@@ -1,10 +1,10 @@
{{define "admin_dashboard"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>管理后台 - MailGo</title>
<title>{{t .Lang "管理后台"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,74 +12,74 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<h2 style="margin-bottom:24px;">管理后台</h2>
<h2 style="margin-bottom:24px;">{{t .Lang "管理后台"}}</h2>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{.domainCount}}</h3>
<p>域名数</p>
<p>{{t .Lang "域名数"}}</p>
</div>
<div class="stat-card">
<h3>{{.userCount}}</h3>
<p>用户数</p>
<p>{{t .Lang "用户数"}}</p>
</div>
<div class="stat-card">
<h3>{{.totalMails}}</h3>
<p>邮件总数</p>
<p>{{t .Lang "邮件总数"}}</p>
</div>
<div class="stat-card">
<h3>{{.banCount}}</h3>
<p>被封IP</p>
<p>{{t .Lang "被封IP"}}</p>
</div>
</div>
<div class="card">
<h3>邮件分布</h3>
<h3>{{t .Lang "邮件分布"}}</h3>
<table style="margin-top:12px;">
<thead>
<tr>
<th>文件夹</th>
<th>邮件数</th>
<th>占用空间</th>
<th>{{t .Lang "文件夹"}}</th>
<th>{{t .Lang "邮件数"}}</th>
<th>{{t .Lang "占用空间"}}</th>
</tr>
</thead>
<tbody>
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr>
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td></td></tr>
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
<tr><td>{{folderLabel .Lang "INBOX"}} (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
<tr><td>{{folderLabel .Lang "Sent"}} (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
<tr><td>{{folderLabel .Lang "Drafts"}} (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr>
<tr><td>{{folderLabel .Lang "Trash"}} (Trash)</td><td>{{.trashCount}}</td><td></td></tr>
<tr style="font-weight:bold;"><td>{{t .Lang "合计"}}</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>收发统计</h3>
<h3>{{t .Lang "收发统计"}}</h3>
<table style="margin-top:12px;">
<thead>
<tr><th>时间段</th><th>收件</th><th>发件</th></tr>
<tr><th>{{t .Lang "时间段"}}</th><th>{{t .Lang "收件"}}</th><th>{{t .Lang "发件"}}</th></tr>
</thead>
<tbody>
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
<tr><td>{{t .Lang "今日"}}</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
<tr><td>{{t .Lang "近 7 天"}}</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
</tbody>
</table>
</div>
<div class="card">
<h3>快捷操作</h3>
<h3>{{t .Lang "快捷操作"}}</h3>
<p style="margin-top:12px;">
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a>
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">查看所有邮件</a>
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">IP黑名单</a>
<a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "新增用户"}}</a>
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "查看所有邮件"}}</a>
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "IP黑名单"}}</a>
</p>
</div>
</div>
+19 -19
View File
@@ -1,10 +1,10 @@
{{define "admin_dns_hint"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>DNS配置 - MailGo</title>
<title>{{t .Lang "DNS配置"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,39 +12,39 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
<h2 style="margin-bottom:16px;">{{tf .Lang "DNS 配置提示 — %s" .domain.Name}}</h2>
<p style="margin-bottom:16px;color:#7f8c8d;">{{t .Lang "请在您的 DNS 服务商处添加以下记录:"}}</p>
<h4 style="margin-bottom:8px;">MX 记录</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "MX 记录"}}</h4>
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "SPF 记录 (TXT)"}}</h4>
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "DKIM 记录 (TXT)"}}</h4>
{{if .domain.DkimPublicKey}}
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
{{else}}
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div>
<div class="dns-record" style="color:#e67e22;">{{t .Lang "⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。"}}</div>
{{end}}
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "DMARC 记录"}}</h4>
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
<div style="margin-top:24px;">
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a>
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回域名列表"}}</a>
</div>
</div>
</div>
+34 -34
View File
@@ -1,10 +1,10 @@
{{define "admin_domain_form"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title>
<title>{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,24 +12,24 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2>
<h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .caddyMsg}}<div class="alert {{if eq .caddyMsgType "success"}}alert-success{{else}}alert-error{{end}}">{{.caddyMsg}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
<div class="form-group">
<label>域名</label>
<label>{{t .Lang "域名"}}</label>
{{if .isEdit}}
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
{{else}}
@@ -37,45 +37,45 @@
{{end}}
</div>
<div class="form-group">
<label>SMTP 端口</label>
<label>{{t .Lang "SMTP 端口"}}</label>
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
</div>
<div class="form-group">
<label>IMAP 端口</label>
<label>{{t .Lang "IMAP 端口"}}</label>
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
</div>
<div class="form-group">
<label>POP3 端口</label>
<label>{{t .Lang "POP3 端口"}}</label>
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
启用 TLS
{{t .Lang "启用 TLS"}}
</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "勾选后端口将自动切换为 SSL/TLS 标准端口"}}</p>
</div>
{{if .isEdit}}
<div id="tls_cert_fields" style="display:none;">
<div class="form-group">
<label>TLS 私钥 PEM</label>
<label>{{t .Lang "TLS 私钥 PEM"}}</label>
<textarea name="tls_private_key" rows="8" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" style="font-family:monospace;"></textarea>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">私钥不会回显;留空则保留现有私钥。</p>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "私钥不会回显;留空则保留现有私钥。"}}</p>
</div>
<div class="form-group">
<label>TLS 公钥证书 PEM</label>
<label>{{t .Lang "TLS 公钥证书 PEM"}}</label>
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
{{if .tlsCertConfigured}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后自动热加载生效。</p>
<p style="color:#27ae60;font-size:12px;margin-top:4px;">{{t .Lang "✅ TLS 证书已配置;上传新证书后自动热加载生效。"}}</p>
{{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。</p>
<p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。"}}</p>
{{end}}
</div>
</div>
<div class="form-group" style="margin-top:4px;">
<label>从 Caddy 获取证书</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。</p>
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">🔒 从 Caddy 获取证书</button>
<label>{{t .Lang "从 Caddy 获取证书"}}</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">{{t .Lang "若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。"}}</p>
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">{{t .Lang "🔒 从 Caddy 获取证书"}}</button>
<span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span>
<script>
async function fetchCaddyCert() {
@@ -83,7 +83,7 @@
var msg = document.getElementById('caddy_fetch_msg');
btn.disabled = true;
var oldText = btn.textContent;
btn.textContent = '获取中…';
btn.textContent = '{{t .Lang "获取中…"}}';
msg.textContent = '';
try {
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
@@ -91,9 +91,9 @@
window.location.href = resp.url;
return;
}
msg.textContent = '获取失败: ' + await resp.text();
msg.textContent = '{{t .Lang "获取失败"}}: ' + await resp.text();
} catch (e) {
msg.textContent = '请求失败: ' + e;
msg.textContent = '{{t .Lang "请求失败"}}: ' + e;
}
btn.disabled = false;
btn.textContent = oldText;
@@ -118,17 +118,17 @@
<div class="form-group">
<label>
<input type="checkbox" name="regenerate_dkim">
重新生成 DKIM 密钥
{{t .Lang "重新生成 DKIM 密钥"}}
</label>
{{if .domain.DkimPublicKey}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ DKIM 密钥已配置</p>
<p style="color:#27ae60;font-size:12px;margin-top:4px;">{{t .Lang "✅ DKIM 密钥已配置"}}</p>
{{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ DKIM 密钥未配置</p>
<p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ DKIM 密钥未配置"}}</p>
{{end}}
</div>
{{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
<button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建域名"}}{{end}}</button>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form>
</div>
</div>
+22 -22
View File
@@ -1,10 +1,10 @@
{{define "admin_domains"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>域名管理 - MailGo</title>
<title>{{t .Lang "域名管理"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,35 +12,35 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>域名列表</h2>
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
<h2>{{t .Lang "域名列表"}}</h2>
<a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
</div>
{{if not .domains}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无域名"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>域名</th>
<th>{{t $.Lang "域名"}}</th>
<th>SMTP</th>
<th>IMAP</th>
<th>POP3</th>
<th>TLS</th>
<th>操作</th>
<th>{{t $.Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -53,11 +53,11 @@
<td>{{.Pop3Port}}</td>
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
<td>
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
onsubmit="return confirm('{{tf $.Lang "确定要删除域名 %s 删除域名将导致关联用户无法收发邮件" .Name}}');">
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form>
</td>
</tr>
@@ -69,11 +69,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/domains?page={{sub .page 1}}">上一页</a>
<a href="/admin/domains?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/domains?page={{add .page 1}}">下一页</a>
<a href="/admin/domains?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+20 -20
View File
@@ -1,10 +1,10 @@
{{define "admin_mail_view"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>查看邮件 - MailGo 管理后台</title>
<title>{{t .Lang "查看邮件"}} - MailGo {{t .Lang "管理后台"}}</title>
{{template "styles" .}}
<style>
.mail-body-iframe {
@@ -21,29 +21,29 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" class="active">所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" class="active">{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="margin-bottom:16px;">
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">返回邮件列表</a>
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回邮件列表"}}</a>
</div>
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2>
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}{{t .Lang "(无主题)"}}{{end}}</h2>
<div class="mail-meta" style="margin-top:12px;">
<p><strong>发件人:</strong> {{decodeHeader .message.FromAddr}}</p>
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>文件夹:</strong> {{.message.Folder}}</p>
<p><strong>时间:</strong> {{time12 .message.Date}}</p>
<p><strong>{{t .Lang "发件人:"}}</strong> {{decodeHeader .message.FromAddr}}</p>
<p><strong>{{t .Lang "收件人:"}}</strong> {{.message.ToAddr}}</p>
{{if .message.CcAddr}}<p><strong>{{t .Lang "抄送:"}}</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>{{t .Lang "所属用户:"}}</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>{{t .Lang "文件夹:"}}</strong> {{.message.Folder}}</p>
<p><strong>{{t .Lang "时间:"}}</strong> {{time12 .Lang .message.Date}}</p>
</div>
<div class="mail-body">
{{if .message.HtmlBody}}
@@ -54,7 +54,7 @@
</div>
{{if .attachments}}
<div class="attachment-list">
<h4 style="margin-bottom:8px;">附件</h4>
<h4 style="margin-bottom:8px;">{{t .Lang "附件列表"}}</h4>
{{range .attachments}}
<div class="attachment-item">
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
+24 -24
View File
@@ -1,10 +1,10 @@
{{define "admin_mails"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>所有邮件 - MailGo</title>
<title>{{t .Lang "所有邮件"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,38 +12,38 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">所有邮件</h2>
<h2 style="margin-bottom:16px;">{{t .Lang "所有邮件"}}</h2>
<div style="margin-bottom:16px;">
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>全部</a>
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>{{t .Lang "全部"}}</a>
<a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a>
<a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a>
<a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a>
<a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a>
</div>
{{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无邮件"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>发件人</th>
<th>收件人</th>
<th>主题</th>
<th>所属用户</th>
<th>文件夹</th>
<th>日期</th>
<th>{{t $.Lang "发件人"}}</th>
<th>{{t $.Lang "收件人"}}</th>
<th>{{t $.Lang "主题"}}</th>
<th>{{t $.Lang "所属用户"}}</th>
<th>{{t $.Lang "文件夹"}}</th>
<th>{{t $.Lang "日期"}}</th>
</tr>
</thead>
<tbody>
@@ -54,7 +54,7 @@
<td>{{.Subject}}</td>
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
<td>{{.Folder}}</td>
<td>{{time12m .Date}}</td>
<td>{{time12m $.Lang .Date}}</td>
</tr>
{{end}}
</tbody>
@@ -64,11 +64,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">上一页</a>
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">下一页</a>
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+44 -44
View File
@@ -1,10 +1,10 @@
{{define "admin_outbound"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>外发队列 - MailGo</title>
<title>{{t .Lang "外发队列"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,60 +12,60 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<h2 style="margin-bottom:24px;">外发队列</h2>
<h2 style="margin-bottom:24px;">{{t .Lang "外发队列"}}</h2>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .statCounts "pending"}}</h3>
<p>待发送</p>
<p>{{t .Lang "待发送"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "deferred"}}</h3>
<p>等待重试</p>
<p>{{t .Lang "等待重试"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "sent"}}</h3>
<p>已送达</p>
<p>{{t .Lang "已送达"}}</p>
</div>
<div class="stat-card">
<h3>{{index .statCounts "failed"}}</h3>
<p>失败</p>
<p>{{t .Lang "失败"}}</p>
</div>
</div>
<div class="card">
<div style="margin-bottom:12px;">
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">{{t .Lang "全部"}}</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">{{t .Lang "待发送"}}</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">{{t .Lang "等待重试"}}</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">{{t .Lang "已送达"}}</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">{{t .Lang "失败"}}</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">{{t .Lang "已取消"}}</a>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>发件人</th>
<th>收件人</th>
<th>状态</th>
<th>尝试次数</th>
<th>下次重试</th>
<th>最后响应 / 错误</th>
<th>创建时间</th>
<th>操作</th>
<th>{{t .Lang "发件人"}}</th>
<th>{{t .Lang "收件人"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "尝试次数"}}</th>
<th>{{t .Lang "下次重试"}}</th>
<th>{{t .Lang "最后响应 / 错误"}}</th>
<th>{{t .Lang "创建时间"}}</th>
<th>{{t .Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -75,40 +75,40 @@
<td>{{.FromAddr}}</td>
<td>{{.ToAddr}}</td>
<td>
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}}
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{t $.Lang (call $.statusText .Status)}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>{{end}}
</td>
<td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m $.Lang .NextAttemptAt}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{time12m .CreatedAt}}</td>
<td>{{time12m $.Lang .CreatedAt}}</td>
<td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
<button type="submit" class="btn btn-sm btn-primary">重试</button>
<button type="submit" class="btn btn-sm btn-primary">{{t $.Lang "重试"}}</button>
</form>
{{end}}
{{if or (eq .Status "pending") (eq .Status "deferred")}}
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
<button type="submit" class="btn btn-sm btn-danger">取消</button>
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('{{t $.Lang "确认取消该投递任务"}}');">
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "取消"}}</button>
</form>
{{end}}
</td>
</tr>
{{else}}
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr>
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">{{t $.Lang "队列为空"}}</td></tr>
{{end}}
</tbody>
</table>
{{if gt .totalPages 1}}
<div class="pagination">
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}}</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">{{t $.Lang "下一页"}}</a>{{end}}
</div>
{{end}}
</div>
+44 -44
View File
@@ -1,10 +1,10 @@
{{define "admin_protocol_logs"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>协议日志 - MailGo</title>
<title>{{t .Lang "协议日志"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,41 +12,41 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>协议日志(SMTP / IMAP / POP3</h2>
<h2>{{t .Lang "协议日志(SMTP / IMAP / POP3"}}</h2>
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
onsubmit="return confirm('确认清理 {{.keepDays}} 天前的协议日志?');">
<button type="submit" class="btn btn-primary">清理旧日志</button>
onsubmit="return confirm('{{tf .Lang "确认清理 %d 天前的协议日志" .keepDays}}');">
<button type="submit" class="btn btn-primary">{{t .Lang "清理旧日志"}}</button>
</form>
</div>
<div style="margin-bottom:24px;">
<div class="stat-card">
<h3>{{index .todayStats "smtp" "fail"}}</h3>
<p>今日 SMTP 失败</p>
<p>{{t .Lang "今日 SMTP 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "imap" "fail"}}</h3>
<p>今日 IMAP 失败</p>
<p>{{t .Lang "今日 IMAP 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{index .todayStats "pop3" "fail"}}</h3>
<p>今日 POP3 失败</p>
<p>{{t .Lang "今日 POP3 失败"}}</p>
</div>
<div class="stat-card">
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
<p>历史失败(全部)</p>
<p>{{t .Lang "历史失败(全部)"}}</p>
</div>
</div>
@@ -54,45 +54,45 @@
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
<select name="protocol" style="padding:6px 10px;">
<option value="">全部协议</option>
<option value="">{{t .Lang "全部协议"}}</option>
<option value="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option>
<option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</option>
<option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option>
</select>
<select name="success" style="padding:6px 10px;">
<option value="">全部状态</option>
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>成功</option>
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>失败</option>
<option value="">{{t .Lang "全部状态"}}</option>
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>{{t .Lang "成功"}}</option>
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>{{t .Lang "失败"}}</option>
</select>
<input type="text" name="ip" placeholder="来源 IP(模糊)" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
<input type="text" name="username" placeholder="用户名(模糊)" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
<input type="text" name="ip" placeholder="{{t .Lang "来源 IP模糊"}}" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
<input type="text" name="username" placeholder="{{t .Lang "用户名模糊"}}" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
<input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;">
<span></span>
<span>{{t .Lang "至"}}</span>
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
<button type="submit" class="btn btn-sm btn-primary">筛选</button>
<a href="/admin/protocol-logs" class="btn btn-sm">重置</a>
<button type="submit" class="btn btn-sm btn-primary">{{t .Lang "筛选"}}</button>
<a href="/admin/protocol-logs" class="btn btn-sm">{{t .Lang "重置"}}</a>
</div>
</form>
<table>
<thead>
<tr>
<th>时间</th>
<th>协议</th>
<th>端口</th>
<th>来源 IP</th>
<th>用户名</th>
<th>状态</th>
<th>失败原因</th>
<th>操作摘要</th>
<th>消息数</th>
<th>时长</th>
<th>{{t .Lang "时间"}}</th>
<th>{{t .Lang "协议"}}</th>
<th>{{t .Lang "端口"}}</th>
<th>{{t .Lang "来源 IP"}}</th>
<th>{{t .Lang "用户名"}}</th>
<th>{{t .Lang "状态"}}</th>
<th>{{t .Lang "失败原因"}}</th>
<th>{{t .Lang "操作摘要"}}</th>
<th>{{t .Lang "消息数"}}</th>
<th>{{t .Lang "时长"}}</th>
</tr>
</thead>
<tbody>
{{range .logs}}
<tr>
<td style="white-space:nowrap;">{{time12 .CreatedAt}}</td>
<td style="white-space:nowrap;">{{time12 $.Lang .CreatedAt}}</td>
<td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
@@ -102,8 +102,8 @@
<td>{{.ClientIP}}</td>
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
<td>
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">成功</span>
{{else}}<span class="badge badge-unread">失败</span>{{end}}
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang "成功"}}</span>
{{else}}<span class="badge badge-unread">{{t $.Lang "失败"}}</span>{{end}}
</td>
<td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{end}}</td>
<td style="max-width:320px;word-break:break-all;font-size:13px;color:#555;">{{.Detail}}</td>
@@ -111,16 +111,16 @@
<td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td>
</tr>
{{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">暂无记录</td></tr>
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">{{t $.Lang "暂无记录"}}</td></tr>
{{end}}
</tbody>
</table>
{{if gt .totalPages 1}}
<div class="pagination">
{{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">上一页</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
{{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">下一页</a>{{end}}
{{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
{{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">{{t $.Lang "下一页"}}</a>{{end}}
</div>
{{end}}
</div>
+31 -22
View File
@@ -1,10 +1,10 @@
{{define "admin_user_form"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title>
<title>{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,58 +12,67 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2>
<h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
<div class="form-group">
<label>用户名</label>
<label>{{t .Lang "用户名"}}</label>
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
</div>
<div class="form-group">
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
<label>{{if .isEdit}}{{t .Lang "新密码(留空则不修改)"}}{{else}}{{t .Lang "密码"}}{{end}}</label>
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="{{t .Lang "请输入密码"}}">
</div>
<div class="form-group">
<label>域名</label>
<label>{{t .Lang "界面语言"}}</label>
<select name="language">
<option value="auto" {{if eq .user.Language "auto"}}selected{{end}}>{{t .Lang "自动(跟随浏览器)"}}</option>
<option value="en" {{if eq .user.Language "en"}}selected{{end}}>English</option>
<option value="zh" {{if eq .user.Language "zh"}}selected{{end}}>中文</option>
<option value="ja" {{if eq .user.Language "ja"}}selected{{end}}>日本語</option>
</select>
</div>
<div class="form-group">
<label>{{t .Lang "域名"}}</label>
<select name="domain_id" required>
<option value="">请选择域名</option>
<option value="">{{t .Lang "请选择域名"}}</option>
{{range .domains}}
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="form-group">
<label>配额 (GB)</label>
<label>{{t .Lang "配额 (GB)"}}</label>
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
</div>
<div class="form-group">
<label>
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
管理员
{{t .Lang "管理员"}}
</label>
</div>
{{if .isEdit}}
<div class="form-group">
<label>
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
启用账户
{{t .Lang "启用账户"}}
</label>
</div>
{{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
<button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建用户"}}{{end}}</button>
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form>
</div>
</div>
+27 -27
View File
@@ -1,10 +1,10 @@
{{define "admin_users"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>用户管理 - MailGo</title>
<title>{{t .Lang "用户管理"}} - MailGo</title>
{{template "styles" .}}
</head>
<body>
@@ -12,36 +12,36 @@
<div class="container">
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div>
<div class="content">
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>用户列表</h2>
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
<h2>{{t .Lang "用户列表"}}</h2>
<a href="/admin/users/new" class="btn btn-primary">{{t .Lang "新增用户"}}</a>
</div>
{{if not .users}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p>
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无用户"}}</p>
{{else}}
<table>
<thead>
<tr>
<th>ID</th>
<th>用户名</th>
<th>域名ID</th>
<th>配额</th>
<th>已用</th>
<th>状态</th>
<th>管理员</th>
<th>操作</th>
<th>{{t $.Lang "用户名"}}</th>
<th>{{t $.Lang "域名ID"}}</th>
<th>{{t $.Lang "配额"}}</th>
<th>{{t $.Lang "已用"}}</th>
<th>{{t $.Lang "状态"}}</th>
<th>{{t $.Lang "管理员"}}</th>
<th>{{t $.Lang "操作"}}</th>
</tr>
</thead>
<tbody>
@@ -55,10 +55,10 @@
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
<td>
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a>
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
<button type="submit" class="btn btn-danger btn-sm">删除</button>
onsubmit="return confirm('{{tf $.Lang "确定要删除用户 %s " .Username}}');">
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form>
</td>
</tr>
@@ -70,11 +70,11 @@
{{if .totalPages}}
<div class="pagination">
{{if gt .page 1}}
<a href="/admin/users?page={{sub .page 1}}">上一页</a>
<a href="/admin/users?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}}
<span>第 {{.page}} / {{.totalPages}}</span>
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}
<a href="/admin/users?page={{add .page 1}}">下一页</a>
<a href="/admin/users?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}}
</div>
{{end}}
+15 -15
View File
@@ -1,10 +1,10 @@
{{define "banned"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>访问被禁止 - MailGo</title>
<title>{{t .Lang "访问被禁止"}} - MailGo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
@@ -43,34 +43,34 @@
<body>
<div class="banned-card">
<div class="banned-icon">🚫</div>
<h1>您的 IP 已被暂时禁止访问</h1>
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p>
<h1>{{t .Lang "您的 IP 已被暂时禁止访问"}}</h1>
<p>{{t .Lang "由于登录失败次数过多,您的 IP 地址已被暂时封禁。"}}</p>
{{if .entry}}
<div class="detail">
<div class="detail-row">
<span class="detail-label">IP 地址</span>
<span class="detail-label">{{t .Lang "IP 地址"}}</span>
<span class="detail-value">{{.entry.IPAddress}}</span>
</div>
<div class="detail-row">
<span class="detail-label">封禁档位</span>
<span class="detail-value">{{if gt .entry.BanCount 3}}{{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span>
<span class="detail-label">{{t .Lang "封禁档位"}}</span>
<span class="detail-value">{{if gt .entry.BanCount 3}}{{tf .Lang "第 %d 次封禁" (sub .entry.BanCount 3)}}{{else}}—{{end}}</span>
</div>
<div class="detail-row">
<span class="detail-label">原因</span>
<span class="detail-value">{{.entry.Reason}}</span>
<span class="detail-label">{{t .Lang "原因"}}</span>
<span class="detail-value">{{t .Lang .entry.Reason}}</span>
</div>
<div class="detail-row">
<span class="detail-label">失败次数</span>
<span class="detail-value">{{.entry.FailCount}}</span>
<span class="detail-label">{{t .Lang "失败次数"}}</span>
<span class="detail-value">{{tf .Lang "%d 次" .entry.FailCount}}</span>
</div>
<div class="detail-row">
<span class="detail-label">解封时间</span>
<span class="detail-value">{{time12 .entry.ExpiresAt}}</span>
<span class="detail-label">{{t .Lang "解封时间"}}</span>
<span class="detail-value">{{time12 .Lang .entry.ExpiresAt}}</span>
</div>
</div>
{{end}}
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p>
<a href="/login" class="back-link">返回登录页</a>
<p style="font-size:13px;">{{t .Lang "请在解封时间后重试,或联系管理员。"}}</p>
<a href="/login" class="back-link">{{t .Lang "返回登录页"}}</a>
</div>
</body>
</html>
+9 -9
View File
@@ -433,22 +433,22 @@
<div class="topbar-left">
<a class="logo" href="/inbox">
<span class="logo-icon"></span>
<span class="logo-text">MailGo<em>邮箱</em></span>
<span class="logo-text">MailGo<em>{{t .Lang "邮箱"}}</em></span>
</a>
</div>
<div class="topbar-search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="mail-search" type="text" placeholder="搜索邮件(发件人 / 主题)" autocomplete="off">
<input id="mail-search" type="text" placeholder="{{t .Lang "搜索邮件发件人 / 主题"}}" autocomplete="off">
</div>
<div class="topbar-right">
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">管理后台</a>{{end}}
<a class="topbar-link" href="/settings">设置</a>
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">{{t .Lang "管理后台"}}</a>{{end}}
<a class="topbar-link" href="/settings">{{t .Lang "设置"}}</a>
<span class="user-chip">
<span class="avatar avatar-sm" style="{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<span class="user-email" title="{{.currentUser.Username}}@{{.currentUser.Domain.Name}}">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
</span>
<form method="POST" action="/logout" class="logout-form">
<button type="submit" class="icon-btn" title="退出登录">
<button type="submit" class="icon-btn" title="{{t .Lang "退出登录"}}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button>
</form>
@@ -461,7 +461,7 @@
<aside class="mail-sidebar">
<a href="/compose" class="compose-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
写信
{{t .Lang "写信"}}
</a>
<nav class="folder-nav">
{{range .folders}}
@@ -477,7 +477,7 @@
{{else}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{{end}}
{{folderLabel .Name}}
{{folderLabel $.Lang .Name}}
{{if eq .Name "INBOX"}}
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
@@ -488,7 +488,7 @@
{{if .currentUser.IsAdmin}}
<a class="folder" href="/admin">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
管理后台
{{t .Lang "管理后台"}}
</a>
{{end}}
</div>
@@ -541,7 +541,7 @@
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; });
if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
if (!confirm('{{t .Lang "确定要删除选中的 %s 封邮件吗?"}}'.replace('%s', ids.length))) return;
var done = 0;
ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
+16 -16
View File
@@ -1,10 +1,10 @@
{{define "compose"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>写信 - MailGo</title>
<title>{{t .Lang "写信"}} - MailGo</title>
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
{{template "styles" .}}
</head>
@@ -16,44 +16,44 @@
<div class="compose-toolbar">
<button type="submit" form="compose-form" class="btn btn-primary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
发送
{{t .Lang "发送"}}
</button>
<label class="tb-btn" style="cursor:pointer;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
附件
{{t .Lang "附件"}}
<input type="file" name="attachments" id="attach-input" multiple style="display:none;">
</label>
<a href="/inbox" class="tb-btn">取消</a>
<a href="/inbox" class="tb-btn">{{t .Lang "取消"}}</a>
<div class="toolbar-spacer"></div>
<span class="page-info">撰写新邮件</span>
<span class="page-info">{{t .Lang "撰写新邮件"}}</span>
</div>
{{if .error}}<div class="alert alert-error" style="margin:12px 18px 0;">{{.error}}</div>{{end}}
<form id="compose-form" class="compose-form" method="POST" action="/compose" enctype="multipart/form-data">
<div class="compose-field">
<label>收件人</label>
<input type="email" name="to" required value="{{.to}}" placeholder="输入收件人邮箱地址">
<label>{{t .Lang "收件人"}}</label>
<input type="email" name="to" required value="{{.to}}" placeholder="{{t .Lang "输入收件人邮箱地址"}}">
</div>
<div class="compose-field">
<label>抄送</label>
<input type="text" name="cc" value="{{.cc}}" placeholder="多个地址用逗号分隔(可选)">
<label>{{t .Lang "抄送"}}</label>
<input type="text" name="cc" value="{{.cc}}" placeholder="{{t .Lang "多个地址用逗号分隔可选"}}">
</div>
<div class="compose-field">
<label>主题</label>
<input type="text" name="subject" value="{{.subject}}" placeholder="输入邮件主题">
<label>{{t .Lang "主题"}}</label>
<input type="text" name="subject" value="{{.subject}}" placeholder="{{t .Lang "输入邮件主题"}}">
</div>
<div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div>
<div class="editor-wrap">
<div id="editor" data-placeholder="请输入邮件内容..."></div>
<div id="editor" data-placeholder="{{t .Lang "请输入邮件内容..."}}"></div>
<input type="hidden" name="body" id="body-hidden">
<input type="hidden" name="html_body" id="html-body-hidden">
</div>
<div class="compose-footer">
<span>附件配额</span>
<span>{{t .Lang "附件配额"}}</span>
<span class="quota-bar" id="quota-bar" data-used="{{.usedBytes}}" data-quota="{{.quotaBytes}}"><i></i></span>
<span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span>
</div>
@@ -64,7 +64,7 @@
<script>
var quill = new Quill('#editor', {
theme: 'snow',
placeholder: document.getElementById('editor').dataset.placeholder || '请输入邮件内容...',
placeholder: document.getElementById('editor').dataset.placeholder || '{{t .Lang "请输入邮件内容..."}}',
modules: {
toolbar: [
[{ 'header': [1, 2, 3, false] }],
@@ -96,7 +96,7 @@
var chip = document.createElement('span');
chip.className = 'attach-chip';
chip.innerHTML = '📎 ' + f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)' +
'<button type="button" class="chip-del" data-i="' + i + '" title="移除">✕</button>';
'<button type="button" class="chip-del" data-i="' + i + '" title="{{t .Lang "移除"}}">✕</button>';
chipsBox.appendChild(chip);
});
}
+25 -25
View File
@@ -1,10 +1,10 @@
{{define "folder"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{folderLabel .folder}} - MailGo</title>
<title>{{folderLabel .Lang .folder}} - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-list">
@@ -13,31 +13,31 @@
{{template "sidebar" .}}
<main class="mail-main">
<div class="list-toolbar">
<label class="check-all" title="全选/取消全选">
<label class="check-all" title="{{t .Lang "全选/取消全选"}}">
<input type="checkbox" id="select-all">
全选
{{t .Lang "全选"}}
</label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
<button type="button" class="tb-btn" id="btn-refresh" title="{{t .Lang "刷新"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新
{{t .Lang "刷新"}}
</button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{if .isTrash}}彻底删除{{else}}删除{{end}}
{{if .isTrash}}{{t $.Lang "彻底删除"}}{{else}}{{t $.Lang "删除"}}{{end}}
</button>
{{if .isTrash}}
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
<button type="submit" class="tb-btn danger">清空文件夹</button>
onsubmit="return confirm('{{t $.Lang "确定要清空已删除文件夹吗此操作不可恢复"}}');">
<button type="submit" class="tb-btn danger">{{t $.Lang "清空文件夹"}}</button>
</form>
{{end}}
<div class="toolbar-spacer"></div>
<span class="page-info">{{.total}}</span>
<span class="page-info">{{tf .Lang "共 %d 封" .total}}</span>
</div>
{{if not .messages}}
<div class="mail-list">
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel $.Lang $.folder}} · {{t $.Lang "暂无邮件"}}</div>
</div>
{{else}}
<ul class="mail-list">
@@ -53,9 +53,9 @@
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
{{end}}
</span>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="{{tf $.Lang "收件人%s" .ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
{{if eq $.folder "Drafts"}}{{t $.Lang "致:"}}{{end}}{{mailName .ToAddr}}
{{else}}
{{mailName (decodeHeader .FromAddr)}}
{{end}}
@@ -63,24 +63,24 @@
<span class="cell-subject-wrap">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
{{if .Subject}}{{.Subject}}{{else}}{{t $.Lang "(无主题)"}}{{end}}
</a>
</span>
<span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}{{t $.Lang "[HTML 邮件]"}}{{end}}
</span>
<span class="cell-date">{{shortDate .Date}}</span>
<span class="cell-date">{{shortDate $.Lang .Date}}</span>
{{if $.isTrash}}
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
<button type="submit" class="icon-btn" title="恢复到收件箱">
onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要恢复这封邮件吗"}}');">
<button type="submit" class="icon-btn" title="{{t $.Lang "恢复到收件箱"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
<button type="submit" class="icon-btn" title="删除">
onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="icon-btn" title="{{t $.Lang "删除"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button>
</form>
@@ -91,23 +91,23 @@
{{end}}
<div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
<span class="page-num">{{tf .Lang "第 %d / %d 页" .page (or .totalPages 1)}}</span>
<div class="pager">
{{if gt .page 1}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页
{{t $.Lang "上一页"}}
</a>
{{else}}
<span class="page-btn disabled">上一页</span>
<span class="page-btn disabled">{{t $.Lang "上一页"}}</span>
{{end}}
{{if lt .page .totalPages}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
下一页
{{t $.Lang "下一页"}}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a>
{{else}}
<span class="page-btn disabled">下一页</span>
<span class="page-btn disabled">{{t $.Lang "下一页"}}</span>
{{end}}
</div>
</div>
+15 -15
View File
@@ -1,55 +1,55 @@
{{define "login"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>登录 - MailGo 邮箱</title>
<title>{{t .Lang "登录"}} - MailGo {{t .Lang "邮箱"}}</title>
{{template "styles" .}}
</head>
<body class="login-page">
<div class="login-card">
<div class="login-logo">
<span class="logo-icon" style="width:44px;height:44px;font-size:22px;border-radius:12px;"></span>
<span style="font-size:26px;font-weight:700;color:var(--primary);">MailGo<em style="font-style:normal;font-weight:400;font-size:15px;color:var(--text-2);margin-left:4px;">邮箱</em></span>
<span style="font-size:26px;font-weight:700;color:var(--primary);">MailGo<em style="font-style:normal;font-weight:400;font-size:15px;color:var(--text-2);margin-left:4px;">{{t .Lang "邮箱"}}</em></span>
</div>
<div class="login-sub">登录您的邮箱账户</div>
<div class="login-sub">{{t .Lang "登录您的邮箱账户"}}</div>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label>邮箱地址</label>
<label>{{t .Lang "邮箱地址"}}</label>
<input type="email" name="email" required autofocus placeholder="user@example.com" style="padding:11px 14px;border-radius:8px;">
</div>
<div class="form-group">
<label>密码</label>
<input type="password" name="password" required placeholder="请输入密码" style="padding:11px 14px;border-radius:8px;">
<label>{{t .Lang "密码"}}</label>
<input type="password" name="password" required placeholder="{{t .Lang "请输入密码"}}" style="padding:11px 14px;border-radius:8px;">
</div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:11px 16px;font-size:15px;border-radius:8px;">登 录</button>
<button type="submit" class="btn btn-primary" style="width:100%;padding:11px 16px;font-size:15px;border-radius:8px;">{{t .Lang "登 录"}}</button>
</form>
{{if or .oauth2Enabled .ldapEnabled}}
<div class="divider"></div>
<div class="divider">{{t .Lang "或"}}</div>
{{end}}
{{if .ldapEnabled}}
<form method="POST" action="/login/ldap" style="margin-bottom:10px;">
<div class="form-group">
<label>LDAP 用户名</label>
<input type="text" name="username" placeholder="LDAP 用户名">
<label>{{t .Lang "LDAP 用户名"}}</label>
<input type="text" name="username" placeholder="{{t .Lang "LDAP 用户名"}}">
</div>
<div class="form-group">
<label>LDAP 密码</label>
<input type="password" name="password" placeholder="LDAP 密码">
<label>{{t .Lang "LDAP 密码"}}</label>
<input type="password" name="password" placeholder="{{t .Lang "LDAP 密码"}}">
</div>
<button type="submit" class="btn" style="width:100%;background:#7b5cd6;color:#fff;">LDAP 登录</button>
<button type="submit" class="btn" style="width:100%;background:#7b5cd6;color:#fff;">{{t .Lang "LDAP 登录"}}</button>
</form>
{{end}}
{{if .oauth2Enabled}}
<a href="/auth/oauth2" class="btn btn-primary" style="width:100%;text-align:center;display:block;margin-top:4px;">
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} {{t .Lang "登录"}}
</a>
{{end}}
</div>
+38 -22
View File
@@ -1,10 +1,10 @@
{{define "settings"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>设置 - MailGo</title>
<title>{{t .Lang "设置"}} - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-settings">
@@ -15,21 +15,21 @@
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
{{if .mustChange}}<div class="alert" style="border:1px solid #ffa940;background:#fff7e6;color:#d46b08;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13.5px;">
⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。
{{t .Lang "⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。"}}
</div>{{end}}
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
账号信息
{{t .Lang "账号信息"}}
</h2>
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
<span class="avatar" style="width:52px;height:52px;font-size:22px;{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<div>
<div style="font-size:16px;font-weight:600;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</div>
<div style="color:var(--text-3);font-size:12.5px;margin-top:3px;">
已用 {{formatBytes .currentUser.UsedBytes}} / 配额 {{formatBytes .currentUser.QuotaBytes}}
{{if .currentUser.IsAdmin}} · 管理员{{end}}
{{tf .Lang "已用 %s / 配额 %s" (formatBytes .currentUser.UsedBytes) (formatBytes .currentUser.QuotaBytes)}}
{{if .currentUser.IsAdmin}} · {{t .Lang "管理员"}}{{end}}
</div>
</div>
</div>
@@ -37,30 +37,46 @@
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;">修改密码</h2>
<h2 style="font-size:17px;margin-bottom:18px;">{{t .Lang "界面语言"}}</h2>
<form method="POST" action="/settings" style="max-width:420px;">
<div class="form-group">
<label>当前密码</label>
<input type="password" name="old_password" required placeholder="请输入当前密码" autocomplete="current-password">
<label>{{t .Lang "界面语言"}}</label>
<select name="language">
<option value="auto" {{if eq .currentUser.Language "auto"}}selected{{end}}>{{t .Lang "自动(跟随浏览器)"}}</option>
<option value="en" {{if eq .currentUser.Language "en"}}selected{{end}}>English</option>
<option value="zh" {{if eq .currentUser.Language "zh"}}selected{{end}}>中文</option>
<option value="ja" {{if eq .currentUser.Language "ja"}}selected{{end}}>日本語</option>
</select>
</div>
<div class="form-group">
<label>新密码</label>
<input type="password" name="new_password" required placeholder="请输入新密码" autocomplete="new-password">
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" required placeholder="请再次输入新密码" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">修改密码</button>
<button type="submit" class="btn btn-primary">{{t .Lang "保存"}}</button>
</form>
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:10px;">帮助</h2>
<h2 style="font-size:17px;margin-bottom:18px;">{{t .Lang "修改密码"}}</h2>
<form method="POST" action="/settings" style="max-width:420px;">
<div class="form-group">
<label>{{t .Lang "当前密码"}}</label>
<input type="password" name="old_password" required placeholder="{{t .Lang "请输入当前密码"}}" autocomplete="current-password">
</div>
<div class="form-group">
<label>{{t .Lang "新密码"}}</label>
<input type="password" name="new_password" required placeholder="{{t .Lang "请输入新密码"}}" autocomplete="new-password">
</div>
<div class="form-group">
<label>{{t .Lang "确认新密码"}}</label>
<input type="password" name="confirm_password" required placeholder="{{t .Lang "请再次输入新密码"}}" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">{{t .Lang "修改密码"}}</button>
</form>
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:10px;">{{t .Lang "帮助"}}</h2>
<p style="color:var(--text-2);font-size:13.5px;line-height:1.9;">
客户端收发信(IMAP / SMTP)配置:<br>
IMAP 服务器:{{.currentUser.Domain.Name}} 端口 143 / SSL 993<br>
SMTP 服务器:{{.currentUser.Domain.Name}} 端口 587(提交)/ SSL 465
{{t .Lang "客户端收发信(IMAP / SMTP)配置:"}}<br>
{{tf .Lang "IMAP 服务器:%s 端口 %d / SSL %d" .currentUser.Domain.Name 143 993}}<br>
{{tf .Lang "SMTP 服务器:%s 端口 %d(提交)/ SSL %d" .currentUser.Domain.Name 587 465}}
</p>
</div>
</main>
+18 -18
View File
@@ -1,10 +1,10 @@
{{define "view"}}
<!DOCTYPE html>
<html lang="zh-CN">
<html lang="{{htmlLang .Lang}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>查看邮件 - MailGo</title>
<title>{{t .Lang "查看邮件"}} - MailGo</title>
{{template "styles" .}}
</head>
<body class="page-view">
@@ -15,50 +15,50 @@
<div class="view-toolbar">
<a href="javascript:history.back()" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回
{{t .Lang "返回"}}
</a>
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复
{{t .Lang "回复"}}
</a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复
{{t .Lang "恢复"}}
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗此操作不可恢复。');">
onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗此操作不可恢复"}}');">
<button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
{{t .Lang "彻底删除"}}
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除
{{t .Lang "删除"}}
</button>
</form>
{{end}}
</div>
<div class="mail-head">
<h1 class="mail-title">{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h1>
<h1 class="mail-title">{{if .message.Subject}}{{.message.Subject}}{{else}}{{t .Lang "(无主题)"}}{{end}}</h1>
<div class="mail-from-row">
<span class="avatar" style="{{avatarStyle .message.FromAddr}}">{{initial (mailName (decodeHeader .message.FromAddr))}}</span>
<div>
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div>
<div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
</div>
<span class="mail-date">{{time12 .message.Date}}</span>
<span class="mail-date">{{time12 .Lang .message.Date}}</span>
</div>
{{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
抄送:{{.message.CcAddr}}
{{t .Lang "抄送:"}}{{.message.CcAddr}}
</div>
{{end}}
</div>
@@ -88,28 +88,28 @@
<div class="view-actions">
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复
{{t .Lang "回复"}}
</a>
{{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复到收件箱
{{t .Lang "恢复到收件箱"}}
</button>
</form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗此操作不可恢复。');">
onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗此操作不可恢复"}}');">
<button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除
{{t .Lang "彻底删除"}}
</button>
</form>
{{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');">
onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除邮件
{{t .Lang "删除邮件"}}
</button>
</form>
{{end}}
+5 -5
View File
@@ -33,7 +33,7 @@ func TestTime12Format(t *testing.T) {
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
}
for _, tc := range cases {
if got := time12(tc.in); got != tc.want {
if got := time12("zh", tc.in); got != tc.want {
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
}
}
@@ -42,7 +42,7 @@ func TestTime12Format(t *testing.T) {
func TestTime12mNoSeconds(t *testing.T) {
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC)
if got, want := time12m(in), "2026-08-20 下午 6:30"; got != want {
if got, want := time12m("zh", in), "2026-08-20 下午 6:30"; got != want {
t.Fatalf("time12m = %q, want %q", got, want)
}
}
@@ -53,7 +53,7 @@ func TestShortDate12Hour(t *testing.T) {
// 今天 → 「下午 2:35」(无日期)
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
if got, want := shortDate(today), "下午 2:35"; got != want {
if got, want := shortDate("zh", today), "下午 2:35"; got != want {
t.Fatalf("shortDate(today) = %q, want %q", got, want)
}
@@ -62,13 +62,13 @@ func TestShortDate12Hour(t *testing.T) {
if day.YearDay() == now.YearDay() {
day = day.AddDate(0, 0, 1)
}
if got, want := shortDate(day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
if got, want := shortDate("zh", day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
t.Fatalf("shortDate(thisYear) = %q, want %q", got, want)
}
// 往年 → 「YYYY-MM-DD 上午 9:05」
older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC()
if got, want := shortDate(older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
if got, want := shortDate("zh", older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
t.Fatalf("shortDate(older) = %q, want %q", got, want)
}
}