Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c881146a40 | ||
|
|
d1ffd10408 | ||
|
|
975fd34f61 | ||
|
|
5abe5c4528 | ||
|
|
0576e6e348 | ||
|
|
034901a467 | ||
|
|
6ee2dd8184 | ||
|
|
37b4816573 | ||
|
|
87a85ef7ca | ||
|
|
2f7f322b62 | ||
|
|
dc164fdf66 | ||
|
|
962c5f454c | ||
|
|
f533fc8feb | ||
|
|
8be5df57c6 | ||
|
|
7a394bbd9e | ||
|
|
9607c4f023 | ||
|
|
4367f7fb92 | ||
|
|
af839a1378 | ||
|
|
07e81fc328 | ||
|
|
f8fbe1ebdb |
+10
@@ -13,6 +13,12 @@
|
||||
# Go 工具缓存
|
||||
vendor/
|
||||
|
||||
# 例外:Web 端本地化的第三方前端库(Quill,CSP 要求同源加载)
|
||||
!internal/web/static/vendor/
|
||||
!internal/web/static/vendor/quill/
|
||||
!internal/web/static/vendor/quill/quill.min.js
|
||||
!internal/web/static/vendor/quill/quill.snow.css
|
||||
|
||||
# 日志文件
|
||||
*.log
|
||||
|
||||
@@ -40,3 +46,7 @@ win/srv/
|
||||
|
||||
# 临时测试文件
|
||||
login_test.html
|
||||
migrate
|
||||
|
||||
# 本地构建产物
|
||||
bin/
|
||||
@@ -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 天,可随时解封)
|
||||
- **外部认证**:OAuth2(Google / 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
|
||||
|
||||
| 收件箱 | 邮件阅读 |
|
||||
|--------|----------|
|
||||
|  |  |
|
||||
## Screenshots
|
||||
|
||||
| 写信 | 设置 | 登录 |
|
||||
|------|------|------|
|
||||
|  |  |  |
|
||||
| Inbox | Mail view |
|
||||
|-------|-----------|
|
||||
|  |  |
|
||||
|
||||
> 截图使用演示数据渲染,实际界面以部署为准。
|
||||
| Compose | Settings | Login |
|
||||
|---------|----------|-------|
|
||||
|  |  |  |
|
||||
|
||||
## 快速开始
|
||||
> 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 等策略列表收录,
|
||||
Microsoft(Outlook/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 服务
|
||||
│ │ └── backend.go # IMAP 后端
|
||||
│ ├── 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 v1 |
|
||||
| POP3 | 手工实现 TCP 协议 |
|
||||
| 密码哈希 | golang.org/x/crypto/bcrypt |
|
||||
| 富文本 | Quill.js (CDN) |
|
||||
| IMAP | github.com/emersion/go-imap/v2 |
|
||||
| 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
@@ -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 共享 MailboxService,IMAP 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 天,可随时解封)
|
||||
- **外部认证**:OAuth2(Google / GitHub)、LDAP(可选,默认关闭)
|
||||
- **安全机制**:BCrypt 密码哈希、登录失败自动封禁 IP(**阶段性封禁**:前 3 次达到失败阈值只计数,第 4 次起封禁并按档位递增 30分钟 → 3小时 → 3个月 → 半年,成功登录清零;**枚举爆破无宽限**:用户名不存在的失败跳过宽限、首次触发即封禁)、外发频率限制(防滥用)、非认证禁止中继(防开放中继)、管理员可解封
|
||||
- **多数据库**:默认 SQLite,可切换 MySQL
|
||||
- **跨平台**:Linux 生产部署 + Windows 本地调试
|
||||
|
||||
## 界面预览
|
||||
|
||||
| 收件箱 | 邮件阅读 |
|
||||
|--------|----------|
|
||||
|  |  |
|
||||
|
||||
| 写信 | 设置 | 登录 |
|
||||
|------|------|------|
|
||||
|  |  |  |
|
||||
|
||||
> 截图使用演示数据渲染,实际界面以部署为准。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 编译
|
||||
|
||||
```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 等策略列表收录,
|
||||
Microsoft(Outlook/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 # MailboxService(IMAP 会话与 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.js(go: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
|
||||
@@ -0,0 +1,260 @@
|
||||
// migrate 一次性工具:把 SQLite 数据迁移到 MySQL(mailgo 库)。
|
||||
// 用法:go run ./cmd/migrate -from /srv/mail_go/mail.db -dsn "mailgo:密码@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=UTC"
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
fromDSN = flag.String("from", "/srv/mail_go/mail.db", "SQLite 数据库路径")
|
||||
mysqlDSN = flag.String("dsn", "", "MySQL DSN(目标库,需已创建 mailgo 库与用户)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
if *mysqlDSN == "" {
|
||||
log.Fatal("缺少 -dsn")
|
||||
}
|
||||
|
||||
// 目标:MySQL(InitDB 内含 AutoMigrate,按当前模型建表)
|
||||
mdb, err := db.InitDB(config.DatabaseConfig{Driver: "mysql", DSN: *mysqlDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 MySQL 失败: %v", err)
|
||||
}
|
||||
log.Println("MySQL 建表完成(AutoMigrate)")
|
||||
|
||||
// 源:SQLite(只读)
|
||||
sdb, err := db.InitDB(config.DatabaseConfig{Driver: "sqlite", DSN: *fromDSN}, config.StorageConfig{BaseDir: "/srv/mail_go/"})
|
||||
if err != nil {
|
||||
log.Fatalf("连接 SQLite 失败: %v", err)
|
||||
}
|
||||
sdb.Logger = logger.Default.LogMode(logger.Silent)
|
||||
|
||||
// 关闭 GORM 自动时间戳(保留原始 CreatedAt/UpdatedAt)
|
||||
mw := mdb.Session(&gorm.Session{SkipHooks: true})
|
||||
|
||||
stateWant := int64(-1) // mailbox_states 期望行数;-1 = 源库无此表不校验
|
||||
|
||||
// 按外键依赖顺序复制:domains → users → messages → attachments → 其余
|
||||
// 所有时间统一 UTC(MySQL DATETIME 无时区)。
|
||||
utc := func(t time.Time) time.Time {
|
||||
if t.IsZero() {
|
||||
// MySQL DATETIME 最小年份 1000;零值由调用方转 NULL
|
||||
return t
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
_ = utc
|
||||
|
||||
// ---- domains ----
|
||||
var domains []db.Domain
|
||||
if err := sdb.Order("id").Find(&domains).Error; err != nil {
|
||||
log.Fatalf("读 domains: %v", err)
|
||||
}
|
||||
for i := range domains {
|
||||
domains[i].CreatedAt = domains[i].CreatedAt.UTC()
|
||||
domains[i].UpdatedAt = domains[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&domains).Error; err != nil {
|
||||
log.Fatalf("写 domains: %v", err)
|
||||
}
|
||||
log.Printf("domains: %d", len(domains))
|
||||
|
||||
// ---- users ----
|
||||
var users []db.User
|
||||
if err := sdb.Order("id").Find(&users).Error; err != nil {
|
||||
log.Fatalf("读 users: %v", err)
|
||||
}
|
||||
for i := range users {
|
||||
users[i].CreatedAt = users[i].CreatedAt.UTC()
|
||||
users[i].UpdatedAt = users[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&users).Error; err != nil {
|
||||
log.Fatalf("写 users: %v", err)
|
||||
}
|
||||
log.Printf("users: %d", len(users))
|
||||
|
||||
// ---- messages ----
|
||||
var msgs []db.Message
|
||||
if err := sdb.Order("id").Find(&msgs).Error; err != nil {
|
||||
log.Fatalf("读 messages: %v", err)
|
||||
}
|
||||
for i := range msgs {
|
||||
msgs[i].Date = msgs[i].Date.UTC()
|
||||
msgs[i].CreatedAt = msgs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&msgs).Error; err != nil {
|
||||
log.Fatalf("写 messages: %v", err)
|
||||
}
|
||||
log.Printf("messages: %d", len(msgs))
|
||||
|
||||
// ---- attachments ----
|
||||
var atts []db.Attachment
|
||||
if err := sdb.Order("id").Find(&atts).Error; err != nil {
|
||||
log.Fatalf("读 attachments: %v", err)
|
||||
}
|
||||
for i := range atts {
|
||||
atts[i].CreatedAt = atts[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&atts).Error; err != nil {
|
||||
log.Fatalf("写 attachments: %v", err)
|
||||
}
|
||||
log.Printf("attachments: %d", len(atts))
|
||||
|
||||
// ---- outbound_messages(原样,含时间转 UTC)----
|
||||
var outs []db.OutboundMessage
|
||||
if err := sdb.Order("id").Find(&outs).Error; err != nil {
|
||||
log.Fatalf("读 outbound_messages: %v", err)
|
||||
}
|
||||
for i := range outs {
|
||||
outs[i].NextAttemptAt = outs[i].NextAttemptAt.UTC()
|
||||
if outs[i].CompletedAt != nil && !outs[i].CompletedAt.IsZero() {
|
||||
u := outs[i].CompletedAt.UTC()
|
||||
outs[i].CompletedAt = &u
|
||||
}
|
||||
outs[i].CreatedAt = outs[i].CreatedAt.UTC()
|
||||
outs[i].UpdatedAt = outs[i].UpdatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&outs).Error; err != nil {
|
||||
log.Fatalf("写 outbound_messages: %v", err)
|
||||
}
|
||||
log.Printf("outbound_messages: %d", len(outs))
|
||||
|
||||
// ---- ban_entries(expires_at 零值 → NULL)----
|
||||
rows, err := sdb.Raw("SELECT id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at FROM ban_entries ORDER BY id").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 ban_entries: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
bans := 0
|
||||
for rows.Next() {
|
||||
var (
|
||||
id uint
|
||||
ip string
|
||||
reason *string
|
||||
failCount int
|
||||
banCount int
|
||||
expires *time.Time
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := rows.Scan(&id, &ip, &reason, &failCount, &banCount, &expires, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 ban_entries: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO ban_entries (id, ip_address, reason, fail_count, ban_count, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)",
|
||||
id, ip, reason, failCount, banCount, norm(expires), norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 ban_entries id=%d: %v", id, err)
|
||||
}
|
||||
bans++
|
||||
}
|
||||
log.Printf("ban_entries: %d", bans)
|
||||
|
||||
// ---- protocol_logs ----
|
||||
var logs []db.ProtocolLog
|
||||
if err := sdb.Order("id").Find(&logs).Error; err != nil {
|
||||
log.Fatalf("读 protocol_logs: %v", err)
|
||||
}
|
||||
for i := range logs {
|
||||
logs[i].CreatedAt = logs[i].CreatedAt.UTC()
|
||||
}
|
||||
if err := mw.Create(&logs).Error; err != nil {
|
||||
log.Fatalf("写 protocol_logs: %v", err)
|
||||
}
|
||||
log.Printf("protocol_logs: %d", len(logs))
|
||||
|
||||
// ---- mailbox_states(原生 SQL:该表随 UIDVALIDITY 特性存在,旧版本源库可能没有)----
|
||||
var stateCount int64
|
||||
sdb.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='mailbox_states'").Scan(&stateCount)
|
||||
if stateCount > 0 {
|
||||
// 目标库若没有该表(上游模型未含 MailboxState 时 AutoMigrate 不会建),先建表
|
||||
var tcnt int64
|
||||
mdb.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'mailbox_states'").Scan(&tcnt)
|
||||
if tcnt == 0 {
|
||||
if err := mdb.Exec(`CREATE TABLE mailbox_states (
|
||||
user_id bigint unsigned NOT NULL,
|
||||
folder varchar(64) NOT NULL,
|
||||
uid_validity bigint unsigned NOT NULL,
|
||||
created_at datetime(3) NULL,
|
||||
updated_at datetime(3) NULL,
|
||||
PRIMARY KEY (user_id, folder))`).Error; err != nil {
|
||||
log.Fatalf("建 mailbox_states 表: %v", err)
|
||||
}
|
||||
log.Println("mailbox_states: 目标库已建表")
|
||||
}
|
||||
srows, err := sdb.Raw("SELECT user_id, folder, uid_validity, created_at, updated_at FROM mailbox_states ORDER BY user_id, folder").Rows()
|
||||
if err != nil {
|
||||
log.Fatalf("读 mailbox_states: %v", err)
|
||||
}
|
||||
defer srows.Close()
|
||||
states := 0
|
||||
for srows.Next() {
|
||||
var (
|
||||
userID uint
|
||||
folder string
|
||||
validity uint32
|
||||
created *time.Time
|
||||
updated *time.Time
|
||||
)
|
||||
if err := srows.Scan(&userID, &folder, &validity, &created, &updated); err != nil {
|
||||
log.Fatalf("扫 mailbox_states: %v", err)
|
||||
}
|
||||
norm := func(t *time.Time) *time.Time {
|
||||
if t == nil || t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
if err := mdb.Exec("INSERT INTO mailbox_states (user_id, folder, uid_validity, created_at, updated_at) VALUES (?,?,?,?,?)",
|
||||
userID, folder, validity, norm(created), norm(updated)).Error; err != nil {
|
||||
log.Fatalf("写 mailbox_states: %v", err)
|
||||
}
|
||||
states++
|
||||
}
|
||||
log.Printf("mailbox_states: %d", states)
|
||||
stateWant = int64(states)
|
||||
} else {
|
||||
log.Println("mailbox_states: 源库无此表,跳过")
|
||||
}
|
||||
|
||||
// ---- 校验 ----
|
||||
check := func(table string, want int64) {
|
||||
var got int64
|
||||
if err := mdb.Table(table).Count(&got).Error; err != nil {
|
||||
log.Fatalf("校验 %s: %v", table, err)
|
||||
}
|
||||
if got != want {
|
||||
log.Fatalf("校验 %s 失败: got %d want %d", table, got, want)
|
||||
}
|
||||
fmt.Printf("校验 %s: %d/%d ✓\n", table, got, want)
|
||||
}
|
||||
check("domains", int64(len(domains)))
|
||||
check("users", int64(len(users)))
|
||||
check("messages", int64(len(msgs)))
|
||||
check("attachments", int64(len(atts)))
|
||||
check("outbound_messages", int64(len(outs)))
|
||||
check("ban_entries", int64(bans))
|
||||
check("protocol_logs", int64(len(logs)))
|
||||
if stateWant >= 0 {
|
||||
check("mailbox_states", stateWant)
|
||||
}
|
||||
|
||||
log.Println("迁移完成 ✅")
|
||||
}
|
||||
+17
-15
@@ -83,8 +83,8 @@ mail_go/
|
||||
│ ├── smtp_server/
|
||||
│ │ └── server.go # SMTP 服务端(go-smtp Backend 实现)
|
||||
│ ├── imap_server/
|
||||
│ │ ├── server.go # IMAP 服务端启动
|
||||
│ │ └── backend.go # go-imap Backend/User/Mailbox/Message 实现
|
||||
│ │ ├── server.go # IMAP 服务端启动(监听器/能力/跨会话推送)
|
||||
│ │ └── session.go # go-imap/v2 imapserver.Session 实现
|
||||
│ ├── pop3_server/
|
||||
│ │ └── server.go # POP3 服务端(TCP 监听 + 文本协议)
|
||||
│ ├── web/
|
||||
@@ -373,11 +373,11 @@ func (s *smtpSession) Logout() error
|
||||
#### IMAP Backend 接口(go-imap/v2 要求)
|
||||
|
||||
```go
|
||||
// internal/imap_server/backend.go
|
||||
// internal/imap_server/session.go
|
||||
|
||||
// 实现 go-imap/v2 的 backend.Backend 接口
|
||||
type imapBackend struct {
|
||||
userStore store.UserStore
|
||||
// 实现 go-imap/v2 的 imapserver.Session 接口
|
||||
type imapSession struct {
|
||||
stores *store.Stores
|
||||
mailStore store.MailStore
|
||||
domainStore store.DomainStore
|
||||
attStore store.AttachmentStore
|
||||
@@ -413,14 +413,16 @@ type imapMailbox struct {
|
||||
| GET | /login | auth.ShowLogin | — | 登录页 |
|
||||
| POST | /login | auth.DoLogin | — | 登录提交 |
|
||||
| POST | /logout | auth.DoLogout | Auth | 登出 |
|
||||
| GET | / | mail.Inbox | Auth | 收件箱(重定向到 /inbox) |
|
||||
| GET | /inbox | mail.Inbox | Auth | 收件箱列表 |
|
||||
| GET | /inbox/:id | mail.View | Auth | 查看邮件 |
|
||||
| GET | / | — | Auth | 收件箱(重定向到 /inbox) |
|
||||
| GET | /folder/:name | mail.Folder | Auth | 通用文件夹页(目录与 IMAP LIST 同源) |
|
||||
| GET | /folder/:name/:id | mail.View | Auth | 查看文件夹内邮件 |
|
||||
| POST | /folder/:name/empty | mail.EmptyFolder | Auth | 清空文件夹 |
|
||||
| GET | /inbox /sent /drafts(及 /:id) | — | Auth | 旧路径兼容重定向到 /folder/<name> |
|
||||
| GET | /compose | mail.Compose | Auth | 撰写页面 |
|
||||
| POST | /compose | mail.DoSend | Auth | 发送邮件 |
|
||||
| GET | /sent | mail.Sent | Auth | 发件箱 |
|
||||
| GET | /sent/:id | mail.View | Auth | 查看已发送邮件 |
|
||||
| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件 |
|
||||
| POST | /mail/delete/:id | mail.Delete | Auth | 删除邮件(移入 Trash;Trash 内为彻底删除) |
|
||||
| POST | /mail/restore/:id | mail.Restore | Auth | 恢复邮件到收件箱 |
|
||||
| POST | /mail/purge/:id | mail.Purge | Auth | 彻底删除邮件 |
|
||||
| POST | /mail/read/:id | mail.MarkRead | Auth | 标记已读 |
|
||||
| GET | /attachment/:id | mail.DownloadAttachment | Auth | 下载附件 |
|
||||
| GET | /admin | admin.Dashboard | Auth + Admin | 管理后台首页 |
|
||||
@@ -557,7 +559,7 @@ sequenceDiagram
|
||||
| Task ID | 任务名称 | 依赖 | 涉及文件 | 优先级 |
|
||||
|---------|---------|------|---------|--------|
|
||||
| T01 | 项目基础设施:go.mod + 入口 + 配置系统 + 数据库层 | — | go.mod, main.go, config/config.go, config/defaults.go, internal/db/db.go, internal/db/models.go, internal/store/*.go | P0 |
|
||||
| T02 | 邮件协议服务端(SMTP + IMAP + POP3) | T01 | internal/smtp_server/server.go, internal/imap_server/server.go, internal/imap_server/backend.go, internal/pop3_server/server.go | P0 |
|
||||
| T02 | 邮件协议服务端(SMTP + IMAP + POP3) | T01 | internal/smtp_server/server.go, internal/imap_server/server.go, internal/imap_server/session.go, internal/pop3_server/server.go | P0 |
|
||||
| T03 | Web 服务核心:路由 + 中间件 + 认证 + 邮件页面 | T01 | internal/web/server.go, internal/web/middleware/auth.go, internal/web/middleware/admin.go, internal/web/handlers/auth.go, internal/web/handlers/mail.go | P0 |
|
||||
| T04 | 管理后台 + 附件存储 + 模板 | T01, T03 | internal/web/handlers/admin.go, internal/storage/attachment.go, internal/web/templates/*.html | P0 |
|
||||
| T05 | 集成调试 + 安装脚本 | T02, T03, T04 | main.go(更新), scripts/install.sh | P1 |
|
||||
@@ -594,7 +596,7 @@ sequenceDiagram
|
||||
|
||||
- 管理后台 handler:域名 CRUD、用户 CRUD、DNS 提示
|
||||
- AttachmentStorage:附件文件写入/读取/删除磁盘操作
|
||||
- 所有 HTML 模板:base 布局 + login/inbox/compose/sent/view/admin 系列页面
|
||||
- 所有 HTML 模板:base 布局 + login/folder/compose/view/admin 系列页面
|
||||
- 附件上传(compose 页面多文件上传)+ 下载 handler
|
||||
|
||||
**T05: 集成调试 + 安装脚本**
|
||||
@@ -614,7 +616,7 @@ go 1.22
|
||||
|
||||
require (
|
||||
github.com/emersion/go-smtp v0.21.0
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.5
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/gin-contrib/sessions v0.0.5
|
||||
|
||||
@@ -4,7 +4,7 @@ go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.4.0
|
||||
github.com/emersion/go-imap v1.2.1
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8
|
||||
github.com/emersion/go-message v0.18.2
|
||||
github.com/emersion/go-msgauth v0.7.0
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
|
||||
@@ -17,19 +17,16 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||
github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
|
||||
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8 h1:5IXZK1E33DyeP526320J3RS7eFlCYGFgtbrfapqDPug=
|
||||
github.com/emersion/go-imap/v2 v2.0.0-beta.8/go.mod h1:dhoFe2Q0PwLrMD7oZw8ODuaD0vLYPe5uj2wcOMnvh48=
|
||||
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
|
||||
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
|
||||
github.com/emersion/go-msgauth v0.7.0 h1:vj2hMn6KhFtW41kshIBTXvp6KgYSqpA/ZN9Pv4g1INc=
|
||||
github.com/emersion/go-msgauth v0.7.0/go.mod h1:mmS9I6HkSovrNgq0HNXTeu8l3sRAAuQ9RMvbM4KU7Ck=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
@@ -163,7 +160,6 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
|
||||
+63
-1
@@ -2,6 +2,7 @@ package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -55,10 +56,71 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
||||
return nil, fmt.Errorf("连接数据库失败: %w", err)
|
||||
}
|
||||
|
||||
// AutoMigrate 前清理 ban_entries 的历史重复行(保留每 IP 最大 id):
|
||||
// ip_address 将升级为唯一索引,重复行会使索引创建失败。
|
||||
// 首次安装表不存在时忽略错误(AutoMigrate 会建新表)。
|
||||
dedupeBanEntries(db)
|
||||
// 再清理旧模型遗留的同名非唯一索引(见 dropLegacyBanIndex)。
|
||||
dropLegacyBanIndex(db)
|
||||
|
||||
// Auto-migrate all models
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}); err != nil {
|
||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}, &OutboundMessage{}, &ProtocolLog{}, &MailboxState{}, &Mailbox{}); err != nil {
|
||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// dedupeBanEntries 删除 ban_entries 中同一 ip_address 的重复行(保留
|
||||
// 每组 id 最大的一条),为 ip_address 唯一索引的 AutoMigrate 扫清障碍。
|
||||
// 表不存在(首次安装)时静默跳过;清理失败仅告警,不阻断启动
|
||||
// (索引创建失败会在 AutoMigrate 中显式报错)。
|
||||
func dedupeBanEntries(db *gorm.DB) {
|
||||
// SQLite 与 MySQL 均支持;MySQL 不允许 DELETE 子查询直接引用同表
|
||||
// (1093),因此用派生表包一层。
|
||||
sql := "DELETE FROM ban_entries WHERE id NOT IN (" +
|
||||
"SELECT mid FROM (SELECT MAX(id) AS mid FROM ban_entries GROUP BY ip_address) AS t)"
|
||||
if err := db.Exec(sql).Error; err != nil {
|
||||
// 表不存在(首次安装)为预期情况
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "no such table") || strings.Contains(msg, "doesn't exist") {
|
||||
return
|
||||
}
|
||||
log.Printf("清理 ban_entries 重复行失败(唯一索引可能无法创建): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// dropLegacyBanIndex 删除 ban_entries 上历史遗留的同名非唯一索引
|
||||
// (旧模型 IPAddress 为普通 `index` 时由 AutoMigrate 创建)。
|
||||
// IPAddress 升级为 uniqueIndex 后,MySQL 的 MigrateColumnUnique 会对
|
||||
// 非唯一列无条件执行 CREATE UNIQUE INDEX(该路径不检查 HasIndex),
|
||||
// 与同名旧索引冲突时报 Error 1061 Duplicate key name,导致启动即失败
|
||||
// (AutoMigrate 末尾按名查 HasIndex 的保护根本执行不到)。
|
||||
// 仅 MySQL 存在此问题;SQLite 迁移路径不受影响。
|
||||
// 表不存在(首次安装)时静默跳过;失败仅告警,不阻断启动
|
||||
// (索引创建失败会在 AutoMigrate 中显式报错)。
|
||||
func dropLegacyBanIndex(db *gorm.DB) {
|
||||
if db.Dialector.Name() != "mysql" {
|
||||
return
|
||||
}
|
||||
var cnt int64
|
||||
err := db.Raw(
|
||||
"SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'ban_entries' AND index_name = 'idx_ban_entries_ip_address' AND non_unique = 1",
|
||||
).Scan(&cnt).Error
|
||||
if err != nil {
|
||||
// 表不存在(首次安装)为预期情况
|
||||
if strings.Contains(err.Error(), "doesn't exist") {
|
||||
return
|
||||
}
|
||||
log.Printf("检查 ban_entries 遗留索引失败(唯一索引可能无法创建): %v", err)
|
||||
return
|
||||
}
|
||||
if cnt == 0 {
|
||||
return
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE `ban_entries` DROP INDEX `idx_ban_entries_ip_address`").Error; err != nil {
|
||||
log.Printf("删除 ban_entries 遗留非唯一索引失败(唯一索引可能无法创建): %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("已删除 ban_entries 遗留非唯一索引 idx_ban_entries_ip_address(AutoMigrate 将重建为唯一索引)")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package db
|
||||
|
||||
// P4 #17 回归:dedupeBanEntries 在 AutoMigrate 前清理历史重复行,
|
||||
// 为 ip_address 唯一索引扫清障碍;表不存在时静默跳过。
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// createLegacyBanTable 按旧版结构建表(ip_address 无唯一索引)。
|
||||
func createLegacyBanTable(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.Exec(`CREATE TABLE ban_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ip_address TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
fail_count INTEGER DEFAULT 0,
|
||||
ban_count INTEGER DEFAULT 0,
|
||||
expires_at DATETIME,
|
||||
created_at DATETIME,
|
||||
updated_at DATETIME
|
||||
)`).Error; err != nil {
|
||||
t.Fatalf("create legacy table: %v", err)
|
||||
}
|
||||
return gdb
|
||||
}
|
||||
|
||||
func TestDedupeBanEntriesRemovesDuplicates(t *testing.T) {
|
||||
gdb := createLegacyBanTable(t)
|
||||
|
||||
// 同一 IP 三条旧记录(id 1,2,3),另一 IP 一条
|
||||
for _, rec := range []struct {
|
||||
ip string
|
||||
fc int
|
||||
}{
|
||||
{"1.2.3.4", 1},
|
||||
{"1.2.3.4", 2},
|
||||
{"1.2.3.4", 3},
|
||||
{"5.6.7.8", 4},
|
||||
} {
|
||||
if err := gdb.Exec(
|
||||
"INSERT INTO ban_entries (ip_address, fail_count) VALUES (?, ?)", rec.ip, rec.fc,
|
||||
).Error; err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
dedupeBanEntries(gdb)
|
||||
|
||||
// 每 IP 仅剩 id 最大的一条
|
||||
var rows []struct {
|
||||
IP string `gorm:"column:ip_address"`
|
||||
FC int `gorm:"column:fail_count"`
|
||||
}
|
||||
if err := gdb.Raw("SELECT ip_address, fail_count FROM ban_entries ORDER BY ip_address").Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows after dedupe = %d, want 2", len(rows))
|
||||
}
|
||||
if rows[0].IP != "1.2.3.4" || rows[0].FC != 3 {
|
||||
t.Fatalf("1.2.3.4 row = %+v, want the max-id row (fail_count=3)", rows[0])
|
||||
}
|
||||
if rows[1].IP != "5.6.7.8" || rows[1].FC != 4 {
|
||||
t.Fatalf("5.6.7.8 row = %+v, want fail_count=4", rows[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupeBanEntriesMissingTableSilent(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
// 表不存在时不应 panic/报错(首次安装场景)
|
||||
dedupeBanEntries(gdb)
|
||||
}
|
||||
+75
-31
@@ -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,25 @@ 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:text" json:"text_body"`
|
||||
HtmlBody string `gorm:"type:text" 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),重选文件夹/重连即丢失,导致客户端
|
||||
// COPY→Trash 后 EXPUNGE 删不掉原邮件(垃圾桶只有副本)。
|
||||
IsDeleted bool `gorm:"default:false;index" json:"is_deleted"`
|
||||
Date time.Time `json:"date"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
@@ -110,10 +118,12 @@ func (OutboundMessage) TableName() string {
|
||||
|
||||
// BanEntry represents an IP address that has been banned due to excessive login failures.
|
||||
type BanEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"`
|
||||
Reason string `gorm:"size:255" json:"reason"`
|
||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||
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"`
|
||||
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
|
||||
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
|
||||
// 成功登录或管理员解封会删除记录,次数随之清零。
|
||||
@@ -155,10 +165,11 @@ func (ProtocolLog) TableName() string {
|
||||
}
|
||||
|
||||
// Attachment represents a file attached to an email message.
|
||||
// 注意:不声明 Message 关联(避免 GORM 外键名 MessageID 与
|
||||
// Message.MessageID 字符串字段冲突,导致 AutoMigrate 生成错误外键)。
|
||||
type Attachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
MessageID uint `gorm:"index;not null" json:"message_id"`
|
||||
Message Message `gorm:"foreignKey:MessageID" json:"message"`
|
||||
FileName string `gorm:"size:255;not null" json:"file_name"`
|
||||
FilePath string `gorm:"size:512;not null" json:"file_path"`
|
||||
ContentType string `gorm:"size:128" json:"content_type"`
|
||||
@@ -171,6 +182,39 @@ func (Attachment) TableName() string {
|
||||
return "attachments"
|
||||
}
|
||||
|
||||
// SystemMailbox 定义一个系统预置文件夹(RFC 6154 SPECIAL-USE 角色)。
|
||||
type SystemMailbox struct {
|
||||
Name string // 规范名(INBOX 大小写不敏感)
|
||||
SpecialUse string // Sent / Drafts / Trash;INBOX 为 ""
|
||||
}
|
||||
|
||||
// SystemMailboxes 是系统预置文件夹清单:IMAP LIST 与 Web 侧边栏
|
||||
// 共用此定义,保证「IMAP 返回什么,Web 就显示什么」。
|
||||
var SystemMailboxes = []SystemMailbox{
|
||||
{Name: "INBOX", SpecialUse: ""},
|
||||
{Name: "Sent", SpecialUse: "Sent"},
|
||||
{Name: "Drafts", SpecialUse: "Drafts"},
|
||||
{Name: "Trash", SpecialUse: "Trash"},
|
||||
}
|
||||
|
||||
// Mailbox 表示一个用户文件夹(IMAP mailbox / Web 侧边栏条目)。
|
||||
// 系统文件夹在首次访问时由 MailboxStore.EnsureSystem 幂等创建;
|
||||
// 自定义文件夹经 IMAP CREATE 创建。
|
||||
type Mailbox struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_mailbox_user_name;not null" json:"user_id"`
|
||||
Name string `gorm:"size:64;uniqueIndex:idx_mailbox_user_name;not null" json:"name"`
|
||||
SpecialUse string `gorm:"size:16" json:"special_use"` // 自定义文件夹为空
|
||||
IsSubscribed bool `gorm:"default:true" json:"is_subscribed"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName specifies the table name for Mailbox.
|
||||
func (Mailbox) TableName() string {
|
||||
return "mailboxes"
|
||||
}
|
||||
|
||||
// MailboxState 记录每个邮箱(用户+文件夹)的持久化 IMAP 状态。
|
||||
// UidValidity 在首次访问时随机生成并持久化:数据库重建(消息 ID 空间
|
||||
// 变化)后该值随之改变,客户端(Thunderbird 等)会据此丢弃本地缓存
|
||||
|
||||
@@ -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",
|
||||
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
|
||||
}
|
||||
@@ -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 → zh;ja-JP → ja;en-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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,6 @@
|
||||
//go:build !race
|
||||
|
||||
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap client)。
|
||||
// 注意:仅在非 -race 构建下运行——go-imap v1.2.1 存在库内数据竞争
|
||||
// (cmd_selected.go STORE 写 *conn.silent() vs listenUpdates 读),
|
||||
// 启用 backend 推送(Updates != nil)时必然触发,-race 下会误报。
|
||||
// 推送逻辑的竞态覆盖由单元测试(notify_test.go)承担。
|
||||
// 集成测试:启动真实 IMAP 监听 + 脚本客户端(go-imap v2 imapclient)。
|
||||
// v2 库为 race-clean(此前 v1.2.1 库内数据竞争导致本文件必须带
|
||||
// !race 构建标签,升级后已移除)。推送逻辑的单元覆盖由 notify_test.go 承担。
|
||||
package imap_server
|
||||
|
||||
import (
|
||||
@@ -18,8 +14,8 @@ import (
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/client"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"github.com/emersion/go-imap/v2/imapclient"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
@@ -32,7 +28,7 @@ func startIntegrationServer(t *testing.T) (*store.Stores, string) {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}); err != nil {
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.ProtocolLog{}, &db.BanEntry{}, &db.MailboxState{}, &db.Mailbox{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
@@ -83,23 +79,22 @@ func seedMailbox(t *testing.T, stores *store.Stores, userID uint, n int) []uint
|
||||
return ids
|
||||
}
|
||||
|
||||
func loginAndSelect(t *testing.T, addr string) *client.Client {
|
||||
func loginAndSelect(t *testing.T, addr string) *imapclient.Client {
|
||||
t.Helper()
|
||||
c, err := client.Dial(addr)
|
||||
c, err := imapclient.DialInsecure(addr, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Logout() })
|
||||
if err := c.Login("alice@example.com", "secret123"); err != nil {
|
||||
t.Cleanup(func() { c.Logout().Wait() })
|
||||
if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
if _, err := c.Select("INBOX", false); err != nil {
|
||||
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// uidOf returns the message id of the newest message by date.
|
||||
func assertReadState(t *testing.T, stores *store.Stores, msgID uint, want bool) {
|
||||
t.Helper()
|
||||
msg, err := stores.Mails.GetByID(msgID)
|
||||
@@ -117,13 +112,13 @@ func TestUidStorePersists(t *testing.T) {
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(uint32(ids[1])) // UID = 第二条消息
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.UidStore(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
cmd := c.Store(imap.UIDSetNum(imap.UID(ids[1])), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil)
|
||||
if _, err := cmd.Collect(); err != nil {
|
||||
t.Fatalf("uid store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
assertReadState(t, stores, ids[1], true)
|
||||
assertReadState(t, stores, ids[0], false)
|
||||
@@ -139,15 +134,13 @@ func TestSeqStoreServerIssued(t *testing.T) {
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 拉取全部消息,找到 ids[2](最新一封)的服务器序号
|
||||
seqsetAll := new(imap.SeqSet)
|
||||
seqsetAll.AddRange(1, 3)
|
||||
messages := make(chan *imap.Message, 3)
|
||||
if err := c.Fetch(seqsetAll, []imap.FetchItem{imap.FetchFlags, imap.FetchUid}, messages); err != nil {
|
||||
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
var targetSeq uint32
|
||||
for m := range messages {
|
||||
if m.Uid == uint32(ids[2]) {
|
||||
for _, m := range msgs {
|
||||
if m.UID == imap.UID(ids[2]) {
|
||||
targetSeq = m.SeqNum
|
||||
}
|
||||
}
|
||||
@@ -155,13 +148,13 @@ func TestSeqStoreServerIssued(t *testing.T) {
|
||||
t.Fatal("target message not found in fetch")
|
||||
}
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(targetSeq)
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
cmd := c.Store(imap.SeqSetNum(targetSeq), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil)
|
||||
if _, err := cmd.Collect(); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
}
|
||||
@@ -177,13 +170,13 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 客户端按日期倒序视图:最新一封 = seq 1
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddNum(1)
|
||||
ch := make(chan *imap.Message, 1)
|
||||
if err := c.Store(seqset, imap.AddFlags, []interface{}{imap.SeenFlag}, ch); err != nil {
|
||||
cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Flags: []imap.Flag{imap.FlagSeen},
|
||||
}, nil)
|
||||
if _, err := cmd.Collect(); err != nil {
|
||||
t.Fatalf("store: %v", err)
|
||||
}
|
||||
<-ch
|
||||
|
||||
// 客户端意图是标记最新一封(ids[2])为已读
|
||||
assertReadState(t, stores, ids[2], true)
|
||||
@@ -191,14 +184,12 @@ func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
||||
|
||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||
// message/rfc822 附件 / 截断的 multipart)时,FETCH BODY/BODYSTRUCTURE
|
||||
// 不得因 nil BodyStructure 触发服务器 panic(否则连接中断,客户端只收到
|
||||
// 部分邮件或一直卡在同步)。修复前 go-imap send() 协程会 nil 指针崩溃。
|
||||
// 不得 panic(v2 的 ExtractBodyStructure 对畸形 MIME 返回降级结构,
|
||||
// WriteBodyStructure 要求 Extended 非 nil,两者都需满足)。
|
||||
func TestFetchBodyMalformedMIME(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
|
||||
// 1) base64 编码的 message/rfc822 附件(转发邮件场景):
|
||||
// backendutil.FetchBodyStructure 不解码 base64,直接把编码文本
|
||||
// 当嵌套消息头解析 → "malformed MIME header line" 错误。
|
||||
// 1) base64 编码的 message/rfc822 附件(转发邮件场景)
|
||||
rfc822Body := "UmVjZWl2ZWQ6IGZyb20gb3V0Ym91bmQuY2kuaWNsb3VkLmNvbSAodW5rbm93biBbMTI3LjAuMC4yKVxuXHQgYnkgcDAwLWljbG91ZG10YS1hc210cC11cy1jZW50cmFsLTFrLTEwMC1wZXJjZW50LTggKFBvc3RmaXgpIHdpdGggRVNNVFBTIGlkIDIxRTlBMThDQURDRjM4MlxuXHQgZm9yIDxkc2hAbG12ZS5uZXQ+OyBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwIChVVEMpXG5YLUlDTC1SZXBJZDogRURWY1BlQ3RlWG4tZ0Z1T0xxUWhfSjZvcE9fN1B2OEtsOW1mMDg2VUFxZ29zXG5EYXRlOiBTdW4sIDE2IEF1ZyAyMDI2IDEzOjU4OjIxICswMDAwXG5Gcm9tOiBkYXZpZEB5YW5kZXguY29tXG5UbzogZHNoQGxtdmUubmV0XG5NZXNzYWdlLUlEOiA8QTIxNzBEMTEtMkI1MC00MTQwLTlEQTMtMkI3M0U2RUIwQTc4QHlhbmRleC5jb20+XG5TdWJqZWN0OiB0ZXN0XG5cbmhlbGxvXG4="
|
||||
msgWithRFC822 := &db.Message{
|
||||
UserID: 1,
|
||||
@@ -224,7 +215,7 @@ func TestFetchBodyMalformedMIME(t *testing.T) {
|
||||
rfc822Body + "\r\n" +
|
||||
"--==fwd==--\r\n",
|
||||
}
|
||||
// 2) 截断的 multipart(缺少结束边界):BODYSTRUCTURE(extended) 解析报错
|
||||
// 2) 截断的 multipart(缺少结束边界)
|
||||
msgTruncated := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
@@ -251,32 +242,260 @@ func TestFetchBodyMalformedMIME(t *testing.T) {
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
seqset := new(imap.SeqSet)
|
||||
seqset.AddRange(1, 2)
|
||||
seqSet := imap.SeqSetNum(1, 2)
|
||||
|
||||
// BODY:历史上 message/rfc822 消息解析失败 → nil BodyStructure → panic
|
||||
msgs := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBody}, msgs); err != nil {
|
||||
// BODY(非扩展)
|
||||
msgs, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{}}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch body: %v", err)
|
||||
}
|
||||
got := 0
|
||||
for range msgs {
|
||||
got++
|
||||
}
|
||||
if got != 2 {
|
||||
t.Fatalf("FETCH BODY 返回 %d/2 封", got)
|
||||
if len(msgs) != 2 {
|
||||
t.Fatalf("FETCH BODY 返回 %d/2 封", len(msgs))
|
||||
}
|
||||
|
||||
// BODYSTRUCTURE:截断 multipart 在 extended 解析时报错 → nil → panic
|
||||
msgs2 := make(chan *imap.Message, 10)
|
||||
if err := c.Fetch(seqset, []imap.FetchItem{imap.FetchBodyStructure}, msgs2); err != nil {
|
||||
// BODYSTRUCTURE(扩展)
|
||||
msgs2, err := c.Fetch(seqSet, &imap.FetchOptions{BodyStructure: &imap.FetchItemBodyStructure{Extended: true}}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("fetch bodystructure: %v", err)
|
||||
}
|
||||
got2 := 0
|
||||
for range msgs2 {
|
||||
got2++
|
||||
}
|
||||
if got2 != 2 {
|
||||
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", got2)
|
||||
if len(msgs2) != 2 {
|
||||
t.Fatalf("FETCH BODYSTRUCTURE 返回 %d/2 封", len(msgs2))
|
||||
}
|
||||
}
|
||||
|
||||
// TestUidExpungeDeleteFlow 核心回归:模拟 Apple Mail / iOS Mail 的删除流程
|
||||
// (UID COPY → Trash + UID STORE \Deleted + UID EXPUNGE)。修复前:
|
||||
// \Deleted 只存内存会话、UID EXPUNGE 不被 go-imap v1 支持 → 原邮件留在
|
||||
// INBOX,垃圾桶只有副本。修复后:原邮件被真正删除,Trash 留有一份副本。
|
||||
func TestUidExpungeDeleteFlow(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
uid := imap.UID(ids[1])
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
|
||||
// 1) UID COPY → Trash
|
||||
if _, err := c.Copy(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
|
||||
t.Fatalf("uid copy: %v", err)
|
||||
}
|
||||
|
||||
// 2) UID STORE +FLAGS.SILENT (\Deleted)
|
||||
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagDeleted},
|
||||
}, nil).Collect(); err != nil {
|
||||
t.Fatalf("uid store deleted: %v", err)
|
||||
}
|
||||
|
||||
// 3) UID EXPUNGE
|
||||
seqs, err := c.UIDExpunge(imap.UIDSetNum(uid)).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("uid expunge: %v", err)
|
||||
}
|
||||
if len(seqs) != 1 {
|
||||
t.Fatalf("uid expunge seqs = %v, want 1 条", seqs)
|
||||
}
|
||||
|
||||
// 原邮件必须被真正删除
|
||||
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
|
||||
t.Fatal("原邮件仍在数据库,UID EXPUNGE 未生效")
|
||||
}
|
||||
// 其余邮件保留
|
||||
assertReadState(t, stores, ids[0], false)
|
||||
assertReadState(t, stores, ids[2], false)
|
||||
// Trash 中有一份副本
|
||||
trashCount, err := stores.Mails.CountByUserAndFolder(1, "Trash")
|
||||
if err != nil || trashCount != 1 {
|
||||
t.Fatalf("Trash count = %d, want 1", trashCount)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeletedPersistsAcrossSessions 验证 \Deleted 落库:标记后断开重连
|
||||
// (模拟客户端切换文件夹/重连),普通 EXPUNGE 仍能删除。修复前标记只存
|
||||
// 内存会话对象,重连后 EXPUNGE 什么都不删。
|
||||
func TestDeletedPersistsAcrossSessions(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 3)
|
||||
uid := imap.UID(ids[1])
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
if _, err := c.Store(imap.UIDSetNum(uid), &imap.StoreFlags{
|
||||
Op: imap.StoreFlagsAdd,
|
||||
Silent: true,
|
||||
Flags: []imap.Flag{imap.FlagDeleted},
|
||||
}, nil).Collect(); err != nil {
|
||||
t.Fatalf("uid store deleted: %v", err)
|
||||
}
|
||||
if err := c.Logout().Wait(); err != nil {
|
||||
t.Fatalf("logout: %v", err)
|
||||
}
|
||||
|
||||
// 重新连接(新的会话对象,此前标记必须仍在库中)
|
||||
c2, err := imapclient.DialInsecure(addr, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial2: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c2.Logout().Wait() })
|
||||
if err := c2.Login("alice@example.com", "secret123").Wait(); err != nil {
|
||||
t.Fatalf("login2: %v", err)
|
||||
}
|
||||
if _, err := c2.Select("INBOX", nil).Wait(); err != nil {
|
||||
t.Fatalf("select2: %v", err)
|
||||
}
|
||||
seqs, err := c2.Expunge().Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("expunge: %v", err)
|
||||
}
|
||||
if len(seqs) != 1 {
|
||||
t.Fatalf("expunge seqs = %v, want 1 条", seqs)
|
||||
}
|
||||
if _, err := stores.Mails.GetByID(ids[1]); err == nil {
|
||||
t.Fatal("标记 \\Deleted 的邮件在重连后未被 EXPUNGE 删除")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUidMoveFlow 验证 UID MOVE:目标文件夹出现该邮件且源文件夹删除。
|
||||
func TestUidMoveFlow(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
ids := seedMailbox(t, stores, 1, 2)
|
||||
uid := imap.UID(ids[1])
|
||||
|
||||
c := loginAndSelect(t, addr)
|
||||
if _, err := c.Move(imap.UIDSetNum(uid), "Trash").Wait(); err != nil {
|
||||
t.Fatalf("uid move: %v", err)
|
||||
}
|
||||
|
||||
if _, err := stores.Mails.GetByID(ids[1]); err != nil {
|
||||
t.Fatalf("moved message missing: %v", err)
|
||||
}
|
||||
msg, err := stores.Mails.GetByID(ids[1])
|
||||
if err != nil {
|
||||
t.Fatalf("get moved msg: %v", err)
|
||||
}
|
||||
if msg.Folder != "Trash" {
|
||||
t.Fatalf("moved msg folder = %q, want Trash", msg.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListDataDriven 验证文件夹目录由 mailboxes 表驱动:LIST 返回
|
||||
// 4 个系统文件夹 + CREATE 的自定义文件夹,且带正确的 SPECIAL-USE 属性;
|
||||
// DELETE 非空/系统文件夹被拒绝,LSUB 按订阅过滤。
|
||||
func TestListDataDriven(t *testing.T) {
|
||||
stores, addr := startIntegrationServer(t)
|
||||
|
||||
c, err := imapclient.DialInsecure(addr, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { c.Logout().Wait() })
|
||||
if err := c.Login("alice@example.com", "secret123").Wait(); err != nil {
|
||||
t.Fatalf("login: %v", err)
|
||||
}
|
||||
|
||||
// LIST:系统文件夹齐全且属性正确
|
||||
datas, err := c.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
byName := map[string]*imap.ListData{}
|
||||
for _, d := range datas {
|
||||
byName[d.Mailbox] = d
|
||||
}
|
||||
for _, want := range []string{"INBOX", "Sent", "Drafts", "Trash"} {
|
||||
if _, ok := byName[want]; !ok {
|
||||
t.Fatalf("LIST 缺少 %s", want)
|
||||
}
|
||||
}
|
||||
if !containsAttr(byName["Trash"].Attrs, imap.MailboxAttrTrash) {
|
||||
t.Fatalf("Trash attrs = %v, want \\Trash", byName["Trash"].Attrs)
|
||||
}
|
||||
if !containsAttr(byName["Sent"].Attrs, imap.MailboxAttrSent) {
|
||||
t.Fatalf("Sent attrs = %v, want \\Sent", byName["Sent"].Attrs)
|
||||
}
|
||||
|
||||
// CREATE 自定义文件夹 → LIST 立即可见
|
||||
if err := c.Create("工作", nil).Wait(); err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
datas2, err := c.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("list after create: %v", err)
|
||||
}
|
||||
found := false
|
||||
for _, d := range datas2 {
|
||||
if d.Mailbox == "工作" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("CREATE 后的自定义文件夹未出现在 LIST 中")
|
||||
}
|
||||
|
||||
// 重名创建 → NO
|
||||
if err := c.Create("工作", nil).Wait(); err == nil {
|
||||
t.Fatal("重复 CREATE 应返回 NO")
|
||||
}
|
||||
|
||||
// 系统文件夹不可删除
|
||||
if err := c.Delete("Trash").Wait(); err == nil {
|
||||
t.Fatal("删除系统文件夹应返回 NO")
|
||||
}
|
||||
|
||||
// 非空自定义文件夹不可删除
|
||||
ids := seedMailbox(t, stores, 1, 1)
|
||||
if _, err := c.Select("INBOX", nil).Wait(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.Copy(imap.UIDSetNum(imap.UID(ids[0])), "工作").Wait(); err != nil {
|
||||
t.Fatalf("copy to custom: %v", err)
|
||||
}
|
||||
if err := c.Delete("工作").Wait(); err == nil {
|
||||
t.Fatal("删除非空文件夹应返回 NO")
|
||||
}
|
||||
|
||||
// RENAME 自定义文件夹
|
||||
if err := c.Rename("工作", "归档", nil).Wait(); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
datas3, err := c.List("", "*", nil).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("list after rename: %v", err)
|
||||
}
|
||||
renamed := false
|
||||
for _, d := range datas3 {
|
||||
if d.Mailbox == "归档" {
|
||||
renamed = true
|
||||
}
|
||||
if d.Mailbox == "工作" {
|
||||
t.Fatal("旧文件夹名仍出现在 LIST 中")
|
||||
}
|
||||
}
|
||||
if !renamed {
|
||||
t.Fatal("重命名后的文件夹未出现在 LIST 中")
|
||||
}
|
||||
|
||||
// UNSUBSCRIBE → LSUB 不再返回
|
||||
if err := c.Unsubscribe("归档").Wait(); err != nil {
|
||||
t.Fatalf("unsubscribe: %v", err)
|
||||
}
|
||||
lsub, err := c.List("", "*", &imap.ListOptions{SelectSubscribed: true}).Collect()
|
||||
if err != nil {
|
||||
t.Fatalf("lsub: %v", err)
|
||||
}
|
||||
for _, d := range lsub {
|
||||
if d.Mailbox == "归档" {
|
||||
t.Fatal("退订后的文件夹不应出现在 LSUB 中")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// containsAttr 判断属性列表是否包含目标属性。
|
||||
func containsAttr(attrs []imap.MailboxAttr, want imap.MailboxAttr) bool {
|
||||
for _, a := range attrs {
|
||||
if a == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
+125
-217
@@ -10,18 +10,24 @@ import (
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap/backend"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TestPushNewMessage 验证本地投递成功后推送的 MessageUpdate 内容正确。
|
||||
func TestPushNewMessage(t *testing.T) {
|
||||
// fakeSession 构造一个挂接在推送中心上的裸会话(无网络连接),用于
|
||||
// 验证 Pusher 的跨会话推送内容与来源排除语义。
|
||||
func fakeSession() *imapSession {
|
||||
return &imapSession{notify: make(chan struct{}, 1)}
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*IMAPServer, *store.Stores) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.MailboxState{}, &db.Mailbox{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
@@ -34,108 +40,51 @@ func TestPushNewMessage(t *testing.T) {
|
||||
if err := stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
email := "alice@example.com"
|
||||
|
||||
// 已有一封旧邮件(日期更早);规范排序最新在前,新邮件应为 INBOX 第 1 封
|
||||
old := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)}
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, connhub.New())
|
||||
return srv, stores
|
||||
}
|
||||
|
||||
// TestPushNewMessage 验证本地投递成功后推送给已选中会话的 EXISTS 更新。
|
||||
func TestPushNewMessage(t *testing.T) {
|
||||
srv, stores := newTestServer(t)
|
||||
|
||||
// 已有一封旧邮件;新邮件投递后 INBOX 计数应为 2
|
||||
old := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "old", Date: time.Now().Add(-time.Hour)}
|
||||
if err := stores.Mails.Create(old); err != nil {
|
||||
t.Fatalf("create old message: %v", err)
|
||||
}
|
||||
inboxMsg := &db.Message{
|
||||
UserID: user.ID,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "sender@other.com",
|
||||
ToAddr: email,
|
||||
Subject: "新邮件",
|
||||
RawData: "From: sender@other.com\r\nSubject: 新邮件\r\n\r\nhello",
|
||||
MessageID: "<new-1@other.com>",
|
||||
Date: time.Now(),
|
||||
IsRead: false,
|
||||
UserID: 1,
|
||||
Folder: "INBOX",
|
||||
FromAddr: "sender@other.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "新邮件",
|
||||
Date: time.Now(),
|
||||
}
|
||||
if err := stores.Mails.Create(inboxMsg); err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
// 模拟明文 + TLS 两个监听器(生产环境由 Start/StartTLS 注册)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
srv.PushNewMessage(email, inboxMsg)
|
||||
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
|
||||
sess := fakeSession()
|
||||
hub.add(sess)
|
||||
|
||||
// 两个监听器(明文/TLS)各有一个 backend 通道,都应收到同一更新
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
if len(bes) == 0 {
|
||||
t.Fatal("no backends registered")
|
||||
srv.PushNewMessage("alice@example.com", inboxMsg)
|
||||
|
||||
updates := sess.takeUpdates(true)
|
||||
if len(updates) != 1 || updates[0].exists == nil {
|
||||
t.Fatalf("updates = %+v, want 1 条 EXISTS", updates)
|
||||
}
|
||||
|
||||
for i, b := range bes {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
mu, ok := upd.(*backend.MessageUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("backend %d: update type = %T, want *MessageUpdate", i, upd)
|
||||
}
|
||||
if mu.Username() != email {
|
||||
t.Fatalf("backend %d: username = %q, want %q", i, mu.Username(), email)
|
||||
}
|
||||
if mu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("backend %d: mailbox = %q, want INBOX", i, mu.Mailbox())
|
||||
}
|
||||
if mu.Message.Uid != uint32(inboxMsg.ID) {
|
||||
t.Fatalf("backend %d: uid = %d, want %d", i, mu.Message.Uid, inboxMsg.ID)
|
||||
}
|
||||
if mu.Message.SeqNum != 1 {
|
||||
t.Fatalf("backend %d: seq = %d, want 1", i, mu.Message.SeqNum)
|
||||
}
|
||||
if mu.Message.Envelope == nil || mu.Message.Envelope.Subject != "新邮件" {
|
||||
t.Fatalf("backend %d: envelope missing subject", i)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("backend %d: no update received", i)
|
||||
}
|
||||
if *updates[0].exists != 2 {
|
||||
t.Fatalf("EXISTS = %d, want 2", *updates[0].exists)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushNewMessageChannelFull 验证通道满时推送不阻塞(非阻塞丢弃)。
|
||||
func TestPushNewMessageChannelFull(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
|
||||
msg := &db.Message{ID: 1, Folder: "INBOX", Date: time.Now()}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
// 灌满所有 backend 通道(容量 256),再调用必须立即返回
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
for _, b := range bes {
|
||||
for i := 0; i < cap(b.updates); i++ {
|
||||
b.updates <- backend.NewUpdate("a@b", "INBOX")
|
||||
}
|
||||
}
|
||||
srv.PushNewMessage("a@b", msg)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("PushNewMessage blocked on full channel")
|
||||
}
|
||||
// TestPushNewMessageNoSession 验证无会话选中时推送为 no-op(不 panic)。
|
||||
func TestPushNewMessageNoSession(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
srv.PushNewMessage("alice@example.com", &db.Message{ID: 1, UserID: 1, Folder: "INBOX", Date: time.Now()})
|
||||
}
|
||||
|
||||
// TestPushNewMessageNilSafe 验证空参数/空指针安全。
|
||||
@@ -145,155 +94,114 @@ func TestPushNewMessageNilSafe(t *testing.T) {
|
||||
srv = NewIMAPServer(config.IMAPConfig{}, nil, nil, config.BanConfig{}, nil)
|
||||
srv.PushNewMessage("", &db.Message{ID: 1}) // 空邮箱
|
||||
srv.PushNewMessage("a@b", nil) // 空消息
|
||||
srv.PushFlagsChanged("", "", nil)
|
||||
srv.PushExpunged("", "", nil)
|
||||
}
|
||||
|
||||
// TestPushFlagsChanged 验证标志变化(已读/星标)推送内容正确。
|
||||
func TestPushFlagsChanged(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
srv, stores := newTestServer(t)
|
||||
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := stores.Domains.Create(domain); err != nil {
|
||||
t.Fatalf("create domain: %v", err)
|
||||
}
|
||||
user := &db.User{Username: "alice", DomainID: domain.ID, IsActive: true}
|
||||
if err := stores.Users.Create(user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
msg := &db.Message{UserID: user.ID, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
|
||||
msg := &db.Message{UserID: 1, Folder: "INBOX", FromAddr: "x@y", Subject: "s", Date: time.Now()}
|
||||
if err := stores.Mails.Create(msg); err != nil {
|
||||
t.Fatalf("create message: %v", err)
|
||||
}
|
||||
msg.IsRead = true
|
||||
msg.IsFlagged = true
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
|
||||
sess := fakeSession()
|
||||
hub.add(sess)
|
||||
|
||||
srv.PushFlagsChanged("alice@example.com", "INBOX", msg)
|
||||
|
||||
srv.beMu.Lock()
|
||||
b := srv.bes[0]
|
||||
srv.beMu.Unlock()
|
||||
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
mu, ok := upd.(*backend.MessageUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("update type = %T, want *MessageUpdate", upd)
|
||||
}
|
||||
if mu.Username() != "alice@example.com" || mu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("update targeting = %s/%s", mu.Username(), mu.Mailbox())
|
||||
}
|
||||
if mu.Message.Uid != uint32(msg.ID) {
|
||||
t.Fatalf("uid = %d, want %d", mu.Message.Uid, msg.ID)
|
||||
}
|
||||
got := make(map[string]bool)
|
||||
for _, f := range mu.Message.Flags {
|
||||
got[f] = true
|
||||
}
|
||||
if !got["\\Seen"] || !got["\\Flagged"] {
|
||||
t.Fatalf("flags = %v, want \\Seen and \\Flagged", mu.Message.Flags)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no flags update received")
|
||||
updates := sess.takeUpdates(true)
|
||||
if len(updates) != 1 || updates[0].fetch == nil {
|
||||
t.Fatalf("updates = %+v, want 1 条 FETCH", updates)
|
||||
}
|
||||
f := updates[0].fetch
|
||||
if f.uid != imap.UID(msg.ID) {
|
||||
t.Fatalf("uid = %d, want %d", f.uid, msg.ID)
|
||||
}
|
||||
got := make(map[imap.Flag]bool)
|
||||
for _, fl := range f.flags {
|
||||
got[fl] = true
|
||||
}
|
||||
if !got[imap.FlagSeen] || !got[imap.FlagFlagged] {
|
||||
t.Fatalf("flags = %v, want \\Seen and \\Flagged", f.flags)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPushExpunged 验证删除推送:每条序号一个 ExpungeUpdate。
|
||||
// TestPushExpunged 验证删除推送:每条序号一个 EXPUNGE 更新。
|
||||
func TestPushExpunged(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
|
||||
sess := fakeSession()
|
||||
hub.add(sess)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.PushExpunged("alice@example.com", "INBOX", []uint32{2, 5})
|
||||
|
||||
srv.beMu.Lock()
|
||||
b := srv.bes[0]
|
||||
srv.beMu.Unlock()
|
||||
|
||||
var seqs []uint32
|
||||
for i := 0; i < 2; i++ {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
eu, ok := upd.(*backend.ExpungeUpdate)
|
||||
if !ok {
|
||||
t.Fatalf("update type = %T, want *ExpungeUpdate", upd)
|
||||
}
|
||||
if eu.Username() != "alice@example.com" || eu.Mailbox() != "INBOX" {
|
||||
t.Fatalf("update targeting = %s/%s", eu.Username(), eu.Mailbox())
|
||||
}
|
||||
seqs = append(seqs, eu.SeqNum)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no expunge update received")
|
||||
}
|
||||
}
|
||||
if seqs[0] != 2 || seqs[1] != 5 {
|
||||
t.Fatalf("seqs = %v, want [2 5]", seqs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBroadcastUpdateIsolatedPerListener 回归测试:同一更新广播到多个监听器
|
||||
// 时,每个监听器必须持有独立的 Update 对象(独立 Done channel),否则
|
||||
// 多个 listenUpdates 会对同一 channel 二次 close 导致
|
||||
// panic: close of closed channel。
|
||||
func TestBroadcastUpdateIsolatedPerListener(t *testing.T) {
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.Message{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
|
||||
hub := connhub.New()
|
||||
srv := NewIMAPServer(config.IMAPConfig{}, stores, nil, config.BanConfig{}, hub)
|
||||
srv.newServer("127.0.0.1:143", nil)
|
||||
srv.newServer("127.0.0.1:993", nil)
|
||||
|
||||
srv.PushExpunged("alice@example.com", "INBOX", []uint32{1})
|
||||
|
||||
srv.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), srv.bes...)
|
||||
srv.beMu.Unlock()
|
||||
|
||||
// 每个监听器各收到一条更新
|
||||
var updates []backend.Update
|
||||
for i, b := range bes {
|
||||
select {
|
||||
case upd := <-b.updates:
|
||||
updates = append(updates, upd)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("backend %d: no update received", i)
|
||||
}
|
||||
}
|
||||
updates := sess.takeUpdates(true)
|
||||
if len(updates) != 2 {
|
||||
t.Fatalf("updates = %d, want 2", len(updates))
|
||||
}
|
||||
|
||||
// 关键断言:两条更新必须拥有独立的 Done channel
|
||||
if updates[0].Done() == updates[1].Done() {
|
||||
t.Fatal("listeners share the same Done channel: double close would panic")
|
||||
if updates[0].expunge == nil || updates[1].expunge == nil {
|
||||
t.Fatalf("updates = %+v, want expunge updates", updates)
|
||||
}
|
||||
|
||||
// 模拟两个 listenUpdates 各自执行 close(update.Done()):修复前必 panic
|
||||
for _, upd := range updates {
|
||||
close(upd.Done())
|
||||
if *updates[0].expunge != 2 || *updates[1].expunge != 5 {
|
||||
t.Fatalf("seqs = %d,%d, want 2,5", *updates[0].expunge, *updates[1].expunge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHubExcludesSource 验证来源会话不会收到自己动作的回声推送:
|
||||
// 会话 A 的 STORE/EXPUNGE 只分发给同邮箱的其他会话(本会话的响应已由
|
||||
// 命令本身写回)。回归:v2 库自带 MailboxTracker 对 EXPUNGE/EXISTS 无法
|
||||
// 排除来源,曾导致本会话在后续 Poll 时收到重复 EXPUNGE。
|
||||
func TestHubExcludesSource(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
|
||||
source := fakeSession()
|
||||
other := fakeSession()
|
||||
hub.add(source)
|
||||
hub.add(other)
|
||||
|
||||
hub.enqueue(sessionUpdate{expunge: ptrU32(3)}, source)
|
||||
|
||||
if got := source.takeUpdates(true); len(got) != 0 {
|
||||
t.Fatalf("source 收到 %d 条回声更新,want 0", len(got))
|
||||
}
|
||||
got := other.takeUpdates(true)
|
||||
if len(got) != 1 || got[0].expunge == nil || *got[0].expunge != 3 {
|
||||
t.Fatalf("other updates = %+v, want 1 条 expunge(3)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPollAllowExpunge 验证 FETCH/STORE/SEARCH 期间不下发 EXPUNGE
|
||||
// (allowExpunge=false 时遇到 EXPUNGE 停止),与 RFC 一致。
|
||||
func TestPollAllowExpunge(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
|
||||
hub := srv.hubForOrCreate("alice@example.com", "INBOX")
|
||||
sess := fakeSession()
|
||||
hub.add(sess)
|
||||
|
||||
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 1, uid: 1, flags: []imap.Flag{imap.FlagSeen}}}, nil)
|
||||
hub.enqueue(sessionUpdate{expunge: ptrU32(2)}, nil)
|
||||
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{seq: 3, uid: 3, flags: []imap.Flag{imap.FlagSeen}}}, nil)
|
||||
|
||||
// allowExpunge=false:只取到第一条 FETCH,EXPUNGE 及其后的留在队列
|
||||
updates := sess.takeUpdates(false)
|
||||
if len(updates) != 1 || updates[0].fetch == nil {
|
||||
t.Fatalf("updates = %+v, want 仅 1 条 FETCH", updates)
|
||||
}
|
||||
// allowExpunge=true:剩余全部下发
|
||||
rest := sess.takeUpdates(true)
|
||||
if len(rest) != 2 {
|
||||
t.Fatalf("rest = %d, want 2", len(rest))
|
||||
}
|
||||
if rest[0].expunge == nil || *rest[0].expunge != 2 {
|
||||
t.Fatalf("rest[0] = %+v, want expunge(2)", rest[0])
|
||||
}
|
||||
}
|
||||
+129
-154
@@ -14,23 +14,22 @@ import (
|
||||
"mail_go/internal/store"
|
||||
"mail_go/internal/tlsutil"
|
||||
|
||||
"github.com/emersion/go-imap"
|
||||
"github.com/emersion/go-imap/backend"
|
||||
imapserver "github.com/emersion/go-imap/server"
|
||||
"github.com/emersion/go-imap/v2"
|
||||
imapserver "github.com/emersion/go-imap/v2/imapserver"
|
||||
)
|
||||
|
||||
// Pusher 是 IMAP 实时推送接口:SMTP/POP3/Web 在邮件状态变化后调用,
|
||||
// 由 go-imap 广播给相关客户端(按用户名+邮箱过滤,IDLE 时即时送达)。
|
||||
// 更新经 mailboxHub 分发给已选中对应邮箱的其他会话(IDLE 时即时送达)。
|
||||
type Pusher interface {
|
||||
// PushNewMessage 推送新邮件(本地投递成功)。
|
||||
PushNewMessage(userEmail string, msg *db.Message)
|
||||
// PushFlagsChanged 推送已读/星标等标志变化(MessageUpdate)。
|
||||
// PushFlagsChanged 推送已读/星标等标志变化。
|
||||
PushFlagsChanged(userEmail, mailbox string, msg *db.Message)
|
||||
// PushExpunged 推送邮件被删除(ExpungeUpdate,seqNums 为删除前序号)。
|
||||
// PushExpunged 推送邮件被删除(seqNums 为删除前序号)。
|
||||
PushExpunged(userEmail, mailbox string, seqNums []uint32)
|
||||
}
|
||||
|
||||
// IMAPServer wraps a go-imap Server and provides mailbox access capability.
|
||||
// IMAPServer 管理 IMAP/IMAPS 监听器、会话注册与跨会话推送。
|
||||
type IMAPServer struct {
|
||||
stores *store.Stores
|
||||
cfg config.IMAPConfig
|
||||
@@ -38,9 +37,14 @@ type IMAPServer struct {
|
||||
tlsLoader *tlsutil.Loader
|
||||
hub *connhub.Hub
|
||||
|
||||
beMu sync.Mutex
|
||||
bes []*imapBackend // 各监听器(明文/TLS)的 backend,用于新邮件推送
|
||||
srvs []*imapserver.Server // 各监听器实例,用于强制断开连接
|
||||
// svc 邮箱服务层(文件夹目录/消息操作),IMAP 会话与 Web 共用。
|
||||
svc *MailboxService
|
||||
|
||||
// hubs 按「用户邮箱 + 文件夹」索引的推送中心,会话 SELECT 时加入。
|
||||
mu sync.Mutex
|
||||
hubs map[string]*mailboxHub
|
||||
// sessions 全部活跃会话(DisconnectByAddr 用)。
|
||||
sessions map[*imapSession]struct{}
|
||||
}
|
||||
|
||||
// NewIMAPServer creates a new IMAP server instance. tlsLoader may be nil
|
||||
@@ -52,158 +56,115 @@ func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores, tlsLoader *tlsut
|
||||
banCfg: banCfg,
|
||||
tlsLoader: tlsLoader,
|
||||
hub: hub,
|
||||
svc: NewMailboxService(stores),
|
||||
hubs: make(map[string]*mailboxHub),
|
||||
sessions: make(map[*imapSession]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyNewMessage 向所有 IMAP 监听器推送新邮件通知(go-imap 广播时按
|
||||
// 用户名+邮箱过滤,只送达已选中 INBOX 的客户端,IDLE 挂起时实时收到
|
||||
// FETCH 响应)。由 SMTP/Web 本地投递成功时调用;channel 满时非阻塞丢弃。
|
||||
// MailboxService 返回邮箱服务层(Web handler 共用)。
|
||||
func (s *IMAPServer) MailboxService() *MailboxService {
|
||||
return s.svc
|
||||
}
|
||||
|
||||
// hubKey 生成推送中心索引键。
|
||||
func hubKey(userEmail, mailbox string) string {
|
||||
return userEmail + "\x00" + mailbox
|
||||
}
|
||||
|
||||
// hubFor 返回已存在的推送中心,不存在返回 nil。
|
||||
func (s *IMAPServer) hubFor(userEmail, mailbox string) *mailboxHub {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.hubs[hubKey(userEmail, mailbox)]
|
||||
}
|
||||
|
||||
// hubForOrCreate 返回推送中心,不存在则创建。
|
||||
func (s *IMAPServer) hubForOrCreate(userEmail, mailbox string) *mailboxHub {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := hubKey(userEmail, mailbox)
|
||||
h := s.hubs[key]
|
||||
if h == nil {
|
||||
h = newMailboxHub()
|
||||
s.hubs[key] = h
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func (s *IMAPServer) unregisterSession(sess *imapSession) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, sess)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// NotifyNewMessage 推送新邮件到达通知:EXISTS 计数 + 标记更新。
|
||||
// 由 SMTP/Web 本地投递成功时调用;无会话选中该邮箱时为 no-op。
|
||||
// 推送目标邮箱取 msg.Folder(本地投递为 INBOX;Web 移动/恢复时
|
||||
// 用于通知目标文件夹,如 Trash)。
|
||||
func (s *IMAPServer) PushNewMessage(userEmail string, msg *db.Message) {
|
||||
if s == nil || userEmail == "" || msg == nil {
|
||||
if s == nil || userEmail == "" || msg == nil || msg.Folder == "" {
|
||||
return
|
||||
}
|
||||
update := buildNewMessageUpdate(s.stores, userEmail, "INBOX", msg)
|
||||
if update == nil {
|
||||
hub := s.hubFor(userEmail, msg.Folder)
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
// 先发 EXISTS 通知再发 FETCH 更新:RFC 2177(IDLE)要求新邮件到达
|
||||
// 时服务器发送 EXISTS,不少客户端(如 Apple Mail)只认 EXISTS 才会
|
||||
// 唤醒并主动拉取,仅裸 FETCH 更新会被忽略(表现为"必须手动同步")。
|
||||
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, "INBOX"); err == nil {
|
||||
s.pushExists(userEmail, "INBOX", uint32(count))
|
||||
if count, err := s.stores.Mails.CountByUserAndFolder(msg.UserID, msg.Folder); err == nil {
|
||||
hub.enqueue(sessionUpdate{exists: ptrU32(uint32(count))}, nil)
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, msg.ID)
|
||||
}
|
||||
|
||||
// PushFlagsChanged 推送邮件标志(已读/星标等)变化给同用户的其他客户端。
|
||||
// PushFlagsChanged 推送邮件标志(已读/星标/删除标记)变化给同用户其他会话。
|
||||
func (s *IMAPServer) PushFlagsChanged(userEmail, mailbox string, msg *db.Message) {
|
||||
if s == nil || userEmail == "" || mailbox == "" || msg == nil {
|
||||
return
|
||||
}
|
||||
update := buildFlagsUpdate(s.stores, userEmail, mailbox, msg, false)
|
||||
if update == nil {
|
||||
hub := s.hubFor(userEmail, mailbox)
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, msg.ID)
|
||||
seq := seqOf(s.stores, msg.UserID, mailbox, msg.ID)
|
||||
if seq == 0 {
|
||||
return
|
||||
}
|
||||
hub.enqueue(sessionUpdate{fetch: &sessionFetchUpdate{
|
||||
seq: seq,
|
||||
uid: imap.UID(msg.ID),
|
||||
flags: flagsOf(msg.IsRead, msg.IsFlagged, msg.IsDeleted),
|
||||
}}, nil)
|
||||
}
|
||||
|
||||
// PushExpunged 推送邮件被删除(每条序号一个 ExpungeUpdate)。
|
||||
// PushExpunged 推送邮件被删除(每条序号一个 EXPUNGE 更新)。
|
||||
func (s *IMAPServer) PushExpunged(userEmail, mailbox string, seqNums []uint32) {
|
||||
if s == nil || userEmail == "" || mailbox == "" || len(seqNums) == 0 {
|
||||
return
|
||||
}
|
||||
hub := s.hubFor(userEmail, mailbox)
|
||||
if hub == nil {
|
||||
return
|
||||
}
|
||||
for _, seq := range seqNums {
|
||||
update := &backend.ExpungeUpdate{
|
||||
Update: backend.NewUpdate(userEmail, mailbox),
|
||||
SeqNum: seq,
|
||||
}
|
||||
s.broadcastUpdate(update, userEmail, 0)
|
||||
hub.enqueue(sessionUpdate{expunge: ptrU32(seq)}, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// broadcastUpdate 把一条更新非阻塞地投递到所有监听器的推送通道。
|
||||
// 每个监听器必须收到独立的 Update 对象(各自独立的 Done channel):
|
||||
// 每个监听器的 listenUpdates 都会对 update.Done() 执行 close,共享
|
||||
// 同一对象会导致对同一 channel 二次 close 而 panic。
|
||||
func (s *IMAPServer) broadcastUpdate(update backend.Update, userEmail string, msgID uint) {
|
||||
s.beMu.Lock()
|
||||
bes := append([]*imapBackend(nil), s.bes...)
|
||||
s.beMu.Unlock()
|
||||
|
||||
for _, b := range bes {
|
||||
select {
|
||||
case b.updates <- cloneUpdate(update):
|
||||
default:
|
||||
log.Printf("IMAP: 推送通道已满,丢弃 %s 的更新 (msg=%d)", userEmail, msgID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pushExists 向所有监听器中「已登录该用户且已选中该邮箱」的连接直接写入
|
||||
// 未请求的 "* N EXISTS" 响应(绕过 go-imap 更新通道——其仅支持 FETCH/
|
||||
// EXPUNGE 类更新,无法表达 EXISTS)。通道满时非阻塞丢弃,与广播一致。
|
||||
func (s *IMAPServer) pushExists(userEmail, mailbox string, exists uint32) {
|
||||
s.beMu.Lock()
|
||||
srvs := append([]*imapserver.Server(nil), s.srvs...)
|
||||
s.beMu.Unlock()
|
||||
|
||||
for _, srv := range srvs {
|
||||
srv.ForEachConn(func(conn imapserver.Conn) {
|
||||
ctx := conn.Context()
|
||||
if ctx == nil || ctx.User == nil || ctx.Mailbox == nil {
|
||||
return
|
||||
}
|
||||
if ctx.User.Username() != userEmail || ctx.Mailbox.Name() != mailbox {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case ctx.Responses <- existsResponse(exists):
|
||||
default:
|
||||
log.Printf("IMAP: EXISTS 推送通道已满,丢弃 (user=%s mailbox=%s)", userEmail, mailbox)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// existsResponse 序列化为 "* N EXISTS\r\n"。
|
||||
type existsResponse uint32
|
||||
|
||||
func (n existsResponse) WriteTo(w *imap.Writer) error {
|
||||
_, err := fmt.Fprintf(w, "* %d EXISTS\r\n", uint32(n))
|
||||
return err
|
||||
}
|
||||
|
||||
// cloneUpdate 按类型复制一条 backend.Update:载荷(消息/序号)共享,
|
||||
// 但 Username/Mailbox/Done channel 重置为独立实例。
|
||||
func cloneUpdate(u backend.Update) backend.Update {
|
||||
switch u := u.(type) {
|
||||
case *backend.MessageUpdate:
|
||||
return &backend.MessageUpdate{
|
||||
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
|
||||
Message: u.Message,
|
||||
}
|
||||
case *backend.ExpungeUpdate:
|
||||
return &backend.ExpungeUpdate{
|
||||
Update: backend.NewUpdate(u.Username(), u.Mailbox()),
|
||||
SeqNum: u.SeqNum,
|
||||
}
|
||||
default:
|
||||
// 防御:未知类型原样传递(当前不存在此类更新)
|
||||
return u
|
||||
}
|
||||
}
|
||||
|
||||
// registerBackend 记录新建的 backend(用于新邮件推送)。
|
||||
func (s *IMAPServer) registerBackend(be *imapBackend) {
|
||||
s.beMu.Lock()
|
||||
s.bes = append(s.bes, be)
|
||||
s.beMu.Unlock()
|
||||
}
|
||||
|
||||
// registerServer 记录监听器实例(用于强制断开连接)。
|
||||
func (s *IMAPServer) registerServer(srv *imapserver.Server) {
|
||||
s.beMu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.beMu.Unlock()
|
||||
}
|
||||
|
||||
// DisconnectByAddr 强制断开指定远端地址的连接(管理后台「断开并封禁」)。
|
||||
// 关闭连接会触发 go-imap 的收尾流程(user.Logout、协议日志回填、hub 注销)。
|
||||
// 发送 BYE 并关闭底层连接,触发库的收尾流程(session.Close、协议日志回填)。
|
||||
func (s *IMAPServer) DisconnectByAddr(remoteAddr string) {
|
||||
if s == nil || remoteAddr == "" {
|
||||
return
|
||||
}
|
||||
s.beMu.Lock()
|
||||
srvs := append([]*imapserver.Server(nil), s.srvs...)
|
||||
s.beMu.Unlock()
|
||||
|
||||
for _, srv := range srvs {
|
||||
srv.ForEachConn(func(conn imapserver.Conn) {
|
||||
info := conn.Info()
|
||||
if info != nil && info.RemoteAddr != nil && info.RemoteAddr.String() == remoteAddr {
|
||||
_ = conn.Close()
|
||||
}
|
||||
})
|
||||
s.mu.Lock()
|
||||
var targets []*imapSession
|
||||
for sess := range s.sessions {
|
||||
if sess.remoteAddr == remoteAddr {
|
||||
targets = append(targets, sess)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
for _, sess := range targets {
|
||||
_ = sess.conn.Bye("Connection closed by administrator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,25 +176,42 @@ func (s *IMAPServer) tlsConfig() (*tls.Config, error) {
|
||||
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
|
||||
}
|
||||
|
||||
// newServer creates a configured imapserver.Server with the given address.
|
||||
// imapCaps 是服务器支持的能力集:UIDPLUS(RFC 4315)提供 UID EXPUNGE、
|
||||
// COPYUID/APPENDUID 支持;MOVE 由 SessionMove 实现;IDLE/UNSELECT 在
|
||||
// IMAP4rev1 认证后由库自动广告。
|
||||
var imapCaps = imap.CapSet{
|
||||
imap.CapIMAP4rev1: {},
|
||||
imap.CapUIDPlus: {},
|
||||
imap.CapMove: {},
|
||||
imap.CapLiteralPlus: {},
|
||||
imap.CapChildren: {},
|
||||
imap.CapSpecialUse: {},
|
||||
}
|
||||
|
||||
// newServer 创建指定监听地址的 IMAP 服务(会话工厂捕获监听端口)。
|
||||
func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server {
|
||||
be := &imapBackend{
|
||||
stores: s.stores,
|
||||
banCfg: s.banCfg,
|
||||
port: portOf(addr),
|
||||
hub: s.hub,
|
||||
updates: make(chan backend.Update, 256),
|
||||
disconnectAddr: s.DisconnectByAddr,
|
||||
}
|
||||
s.registerBackend(be)
|
||||
srv := imapserver.New(be)
|
||||
srv.Addr = addr
|
||||
srv.TLSConfig = tlsConfig
|
||||
srv.AllowInsecureAuth = tlsConfig == nil
|
||||
s.registerServer(srv)
|
||||
port := portOf(addr)
|
||||
srv := imapserver.New(&imapserver.Options{
|
||||
Caps: imapCaps,
|
||||
NewSession: s.newSessionFactory(port),
|
||||
TLSConfig: tlsConfig,
|
||||
InsecureAuth: tlsConfig == nil,
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
// newSessionFactory 构造会话工厂:每个连接一个 imapSession,注册到
|
||||
// 会话表(DisconnectByAddr 用)。
|
||||
func (s *IMAPServer) newSessionFactory(port int) func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
return func(conn *imapserver.Conn) (imapserver.Session, *imapserver.GreetingData, error) {
|
||||
sess := newImapSession(s, conn, port)
|
||||
s.mu.Lock()
|
||||
s.sessions[sess] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
return sess, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
// portOf 从监听地址解析端口号,失败返回 0。
|
||||
func portOf(addr string) int {
|
||||
_, portStr, err := net.SplitHostPort(addr)
|
||||
@@ -247,29 +225,26 @@ func portOf(addr string) int {
|
||||
return port
|
||||
}
|
||||
|
||||
// Start starts the IMAP server on the plain-text port.
|
||||
// Start starts the IMAP server on the plain-text port (STARTTLS enabled
|
||||
// when a certificate is configured).
|
||||
func (s *IMAPServer) Start() error {
|
||||
tlsConfig, err := s.tlsConfig()
|
||||
if err != nil {
|
||||
log.Printf("IMAP STARTTLS 未启用: %v", err)
|
||||
tlsConfig = nil
|
||||
}
|
||||
srv := s.newServer(s.cfg.Addr, tlsConfig)
|
||||
log.Printf("IMAP server listening on %s", s.cfg.Addr)
|
||||
return srv.ListenAndServe()
|
||||
return srv.ListenAndServe(s.cfg.Addr)
|
||||
}
|
||||
|
||||
// StartTLS starts the IMAP server on the TLS port.
|
||||
// StartTLS starts the IMAP server on the implicit TLS port.
|
||||
func (s *IMAPServer) StartTLS() error {
|
||||
tlsConfig, err := s.tlsConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
srv := s.newServer(s.cfg.TLSAddr, tlsConfig)
|
||||
|
||||
log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr)
|
||||
return srv.ListenAndServeTLS()
|
||||
return srv.ListenAndServeTLS(s.cfg.TLSAddr)
|
||||
}
|
||||
|
||||
// ensure imapBackend satisfies backend.Backend at compile time
|
||||
var _ backend.Backend = (*imapBackend)(nil)
|
||||
@@ -0,0 +1,335 @@
|
||||
package imap_server
|
||||
|
||||
// MailboxService 是邮箱(文件夹)与消息操作的服务层:
|
||||
// IMAP 会话与 Web handler 共用同一份实现,保证「IMAP LIST 返回什么,
|
||||
// Web 就显示什么」,且移动/删除等操作走同一语义(如移入 Trash)。
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/emersion/go-imap/v2"
|
||||
)
|
||||
|
||||
// FolderInfo 是一个文件夹在列表页(IMAP LIST / Web 侧边栏)
|
||||
// 展示所需的信息。
|
||||
type FolderInfo struct {
|
||||
Name string
|
||||
SpecialUse string
|
||||
Subscribed bool
|
||||
Total int64
|
||||
Unseen int64
|
||||
}
|
||||
|
||||
// MailboxService 提供用户级邮箱操作。
|
||||
type MailboxService struct {
|
||||
stores *store.Stores
|
||||
}
|
||||
|
||||
// NewMailboxService creates a MailboxService backed by stores.
|
||||
func NewMailboxService(stores *store.Stores) *MailboxService {
|
||||
return &MailboxService{stores: stores}
|
||||
}
|
||||
|
||||
// ListAll 返回用户全部文件夹(先确保系统文件夹存在)。
|
||||
func (s *MailboxService) ListAll(userID uint) ([]db.Mailbox, error) {
|
||||
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.stores.Mailboxes.List(userID)
|
||||
}
|
||||
|
||||
// List 返回用户全部文件夹及统计信息(IMAP LIST / Web 侧边栏同源)。
|
||||
func (s *MailboxService) List(userID uint) ([]FolderInfo, error) {
|
||||
mbs, err := s.ListAll(userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]FolderInfo, 0, len(mbs))
|
||||
for _, mb := range mbs {
|
||||
total, err := s.stores.Mails.CountByUserAndFolder(userID, mb.Name)
|
||||
if err != nil {
|
||||
log.Printf("mailbox: 统计 %s 邮件数失败: %v", mb.Name, err)
|
||||
}
|
||||
unseen, err := s.stores.Mails.CountUnread(userID, mb.Name)
|
||||
if err != nil {
|
||||
log.Printf("mailbox: 统计 %s 未读数失败: %v", mb.Name, err)
|
||||
}
|
||||
infos = append(infos, FolderInfo{
|
||||
Name: mb.Name,
|
||||
SpecialUse: mb.SpecialUse,
|
||||
Subscribed: mb.IsSubscribed,
|
||||
Total: total,
|
||||
Unseen: unseen,
|
||||
})
|
||||
}
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// Canonical 规范化邮箱名:INBOX 大小写不敏感,其余按 DB 实际名称
|
||||
// 精确匹配。会先确保系统文件夹存在(客户端可能不 LIST 直接 SELECT)。
|
||||
func (s *MailboxService) Canonical(userID uint, name string) (string, bool) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", false
|
||||
}
|
||||
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
|
||||
log.Printf("mailbox: EnsureSystem 失败 user=%d: %v", userID, err)
|
||||
return "", false
|
||||
}
|
||||
if strings.EqualFold(name, "INBOX") {
|
||||
return "INBOX", true
|
||||
}
|
||||
mb, err := s.stores.Mailboxes.GetByName(userID, name)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return mb.Name, true
|
||||
}
|
||||
|
||||
// Messages 分页返回用户某文件夹的邮件。
|
||||
func (s *MailboxService) Messages(userID uint, name string, page, size int) ([]db.Message, int64, error) {
|
||||
return s.stores.Mails.ListByUserAndFolder(userID, name, page, size)
|
||||
}
|
||||
|
||||
// Select 计算 SELECT 响应数据(会话负责 hub 绑定与选中状态)。
|
||||
func (s *MailboxService) Select(userID uint, name string) (*imap.SelectData, error) {
|
||||
msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
uidValidity, err := s.stores.MailboxState.UidValidity(userID, name)
|
||||
if err != nil {
|
||||
log.Printf("IMAP: 获取 UIDVALIDITY 失败 folder=%s: %v", name, err)
|
||||
uidValidity = 1
|
||||
}
|
||||
flags := []imap.Flag{imap.FlagAnswered, imap.FlagFlagged, imap.FlagDeleted, imap.FlagSeen, imap.FlagDraft}
|
||||
return &imap.SelectData{
|
||||
Flags: flags,
|
||||
PermanentFlags: append(flags, imap.FlagWildcard),
|
||||
NumMessages: uint32(len(msgs)),
|
||||
NumRecent: 0,
|
||||
UIDNext: imap.UID(maxID + 1),
|
||||
UIDValidity: uidValidity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Status 计算 STATUS 响应数据。
|
||||
func (s *MailboxService) Status(userID uint, name string, options *imap.StatusOptions) (*imap.StatusData, error) {
|
||||
msgs, err := s.stores.Mails.ListAllByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := &imap.StatusData{Mailbox: name}
|
||||
|
||||
if options.NumMessages || options.NumUnseen || options.NumDeleted || options.Size {
|
||||
var unseen, deleted uint32
|
||||
var size int64
|
||||
for i := range msgs {
|
||||
if !msgs[i].IsRead {
|
||||
unseen++
|
||||
}
|
||||
if msgs[i].IsDeleted {
|
||||
deleted++
|
||||
}
|
||||
size += int64(len(messageRawData(&msgs[i])))
|
||||
}
|
||||
if options.NumMessages {
|
||||
n := uint32(len(msgs))
|
||||
data.NumMessages = &n
|
||||
}
|
||||
if options.NumUnseen {
|
||||
data.NumUnseen = &unseen
|
||||
}
|
||||
if options.NumDeleted {
|
||||
data.NumDeleted = &deleted
|
||||
}
|
||||
if options.Size {
|
||||
data.Size = &size
|
||||
}
|
||||
}
|
||||
if options.NumRecent {
|
||||
zero := uint32(0)
|
||||
data.NumRecent = &zero
|
||||
}
|
||||
if options.UIDNext {
|
||||
maxID, err := s.stores.Mails.MaxIDByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.UIDNext = imap.UID(maxID + 1)
|
||||
}
|
||||
if options.UIDValidity {
|
||||
uidValidity, err := s.stores.MailboxState.UidValidity(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data.UIDValidity = uidValidity
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// validateMailboxName 校验自定义文件夹名(IMAP CREATE/RENAME 用)。
|
||||
func validateMailboxName(name string) error {
|
||||
if name == "" || len(name) > 64 {
|
||||
return store.ErrMailboxInvalid
|
||||
}
|
||||
if strings.Contains(name, "/") || name == "." || name == ".." {
|
||||
return store.ErrMailboxInvalid
|
||||
}
|
||||
for _, r := range name {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return store.ErrMailboxInvalid
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建自定义文件夹。
|
||||
func (s *MailboxService) Create(userID uint, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if err := validateMailboxName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(name, "INBOX") {
|
||||
return store.ErrMailboxExists
|
||||
}
|
||||
if err := s.stores.Mailboxes.EnsureSystem(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := s.stores.Mailboxes.GetByName(userID, name); err == nil {
|
||||
return store.ErrMailboxExists
|
||||
}
|
||||
return s.stores.Mailboxes.Create(&db.Mailbox{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
IsSubscribed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Delete 删除空的自定义文件夹(系统文件夹拒绝)。
|
||||
func (s *MailboxService) Delete(userID uint, name string) error {
|
||||
name, ok := s.Canonical(userID, name)
|
||||
if !ok {
|
||||
return store.ErrMailboxNotFound
|
||||
}
|
||||
if isSystemMailboxName(name) {
|
||||
return store.ErrMailboxSystem
|
||||
}
|
||||
return s.stores.Mailboxes.Delete(userID, name)
|
||||
}
|
||||
|
||||
// Rename 重命名自定义文件夹(系统文件夹拒绝)。
|
||||
func (s *MailboxService) Rename(userID uint, oldName, newName string) error {
|
||||
oldName, ok := s.Canonical(userID, oldName)
|
||||
if !ok {
|
||||
return store.ErrMailboxNotFound
|
||||
}
|
||||
if isSystemMailboxName(oldName) {
|
||||
return store.ErrMailboxSystem
|
||||
}
|
||||
newName = strings.TrimSpace(newName)
|
||||
if err := validateMailboxName(newName); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.EqualFold(newName, "INBOX") || isSystemMailboxName(newName) {
|
||||
return store.ErrMailboxInvalid
|
||||
}
|
||||
if _, err := s.stores.Mailboxes.GetByName(userID, newName); err == nil {
|
||||
return store.ErrMailboxExists
|
||||
}
|
||||
return s.stores.Mailboxes.Rename(userID, oldName, newName)
|
||||
}
|
||||
|
||||
// SetSubscribed 更新文件夹订阅状态(LSUB 过滤用)。
|
||||
func (s *MailboxService) SetSubscribed(userID uint, name string, subscribed bool) error {
|
||||
name, ok := s.Canonical(userID, name)
|
||||
if !ok {
|
||||
return store.ErrMailboxNotFound
|
||||
}
|
||||
return s.stores.Mailboxes.SetSubscribed(userID, name, subscribed)
|
||||
}
|
||||
|
||||
// Move 把多封邮件移动到目标文件夹(web 删除=移入 Trash、恢复等共用)。
|
||||
// 不属于该用户的邮件被跳过。
|
||||
func (s *MailboxService) Move(userID uint, msgIDs []uint, dest string) error {
|
||||
if len(msgIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, ok := s.Canonical(userID, dest); !ok {
|
||||
return store.ErrMailboxNotFound
|
||||
}
|
||||
for _, id := range msgIDs {
|
||||
msg, err := s.stores.Mails.GetByID(id)
|
||||
if err != nil || msg.UserID != userID {
|
||||
continue
|
||||
}
|
||||
if err := s.stores.Mails.MoveToFolder(id, dest); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Purge 永久删除文件夹中的邮件;msgIDs 为空时删除全部。
|
||||
// 返回被删除前的邮件列表(调用方据此推送 EXPUNGE)。
|
||||
func (s *MailboxService) Purge(userID uint, name string, msgIDs []uint) ([]db.Message, error) {
|
||||
var msgs []db.Message
|
||||
var err error
|
||||
if len(msgIDs) == 0 {
|
||||
msgs, err = s.stores.Mails.ListAllByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
msgIDs = make([]uint, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
msgIDs = append(msgIDs, msgs[i].ID)
|
||||
}
|
||||
} else {
|
||||
for _, id := range msgIDs {
|
||||
msg, err := s.stores.Mails.GetByID(id)
|
||||
if err != nil || msg.UserID != userID || msg.Folder != name {
|
||||
continue
|
||||
}
|
||||
msgs = append(msgs, *msg)
|
||||
}
|
||||
}
|
||||
if len(msgIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
if err := s.stores.Mails.DeleteMany(msgIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// isSystemMailboxName reports whether name is one of the system mailboxes.
|
||||
func isSystemMailboxName(name string) bool {
|
||||
for _, def := range db.SystemMailboxes {
|
||||
if def.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// mailboxAttrs 把 SpecialUse 映射为 RFC 6154 的 IMAP 属性。
|
||||
func mailboxAttrs(mb db.Mailbox) []imap.MailboxAttr {
|
||||
switch mb.SpecialUse {
|
||||
case "Sent":
|
||||
return []imap.MailboxAttr{imap.MailboxAttrSent}
|
||||
case "Drafts":
|
||||
return []imap.MailboxAttr{imap.MailboxAttrDrafts}
|
||||
case "Trash":
|
||||
return []imap.MailboxAttr{imap.MailboxAttrTrash}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -397,8 +397,9 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) (
|
||||
|
||||
authUser, err := s.stores.Users.AuthenticateLogin(user.Username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多")
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
|
||||
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
|
||||
s.stores.RecordAuthFailure(clientIP, s.banCfg.MaxFailAttempts, s.banCfg.BanDurationMin, "邮件协议认证失败次数过多", s.stores.Users.LoginExists(user.Username))
|
||||
sendResponse(conn, "-ERR authentication failed")
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
@@ -221,12 +221,14 @@ func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
||||
|
||||
user, err := s.backend.server.stores.Users.AuthenticateLogin(username, password)
|
||||
if err != nil {
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)
|
||||
// 认证失败计数,达到阈值按档位封禁(与 Web 登录共用 ban_entries)。
|
||||
// 用户名不存在(枚举型爆破)跳过宽限首次触发即封。
|
||||
s.backend.server.stores.RecordAuthFailure(
|
||||
s.clientIP,
|
||||
s.backend.server.banCfg.MaxFailAttempts,
|
||||
s.backend.server.banCfg.BanDurationMin,
|
||||
"邮件协议认证失败次数过多",
|
||||
s.backend.server.stores.Users.LoginExists(username),
|
||||
)
|
||||
s.recordFail("用户名或密码错误")
|
||||
return smtp.ErrAuthFailed
|
||||
|
||||
@@ -23,12 +23,15 @@ func ClientIPFromAddr(addr net.Addr) string {
|
||||
// 协议层统一入口):
|
||||
// - 失败计数累加(每 IP 一条记录,upsert);
|
||||
// - 达到 maxFail 阈值时触发次数 BanCount+1:
|
||||
// 前 freeTriggers(3)次只计数不封禁;
|
||||
// 从第 4 次起封禁,时长按档位递增(stageDuration),上限半年;
|
||||
// knownUser(用户名存在、疑似真实用户输错)前 freeTriggers(3)次
|
||||
// 只计数不封禁(防误封);
|
||||
// !knownUser(用户名不存在,枚举型爆破)跳过宽限,首次触发即按
|
||||
// 第 1 档封禁;
|
||||
// 封禁时长按档位递增(stageDuration),上限半年;
|
||||
// - reason 为失败场景描述(如“登录失败次数过多”),封禁原因会带上档位。
|
||||
//
|
||||
// 返回 (是否触发封禁, 当前失败计数)。成功登录后调用 ResetFail 清零。
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string) (banned bool, failCount int) {
|
||||
func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reason string, knownUser bool) (banned bool, failCount int) {
|
||||
if ip == "" || maxFail <= 0 {
|
||||
return false, 0
|
||||
}
|
||||
@@ -50,16 +53,26 @@ func (s *Stores) RecordAuthFailure(ip string, maxFail int, firstBanMin int, reas
|
||||
entry.BanCount = banCount
|
||||
entry.FailCount = failCount
|
||||
|
||||
// 前 3 次只计数,不封禁(保留零到期时间与空原因)
|
||||
if banCount <= freeTriggers {
|
||||
// 未知用户名(枚举型爆破)跳过宽限档:等效于已用完 3 次宽限
|
||||
effectiveCount := banCount
|
||||
if !knownUser {
|
||||
effectiveCount += freeTriggers
|
||||
}
|
||||
|
||||
// 宽限期内只计数,不封禁(保留零到期时间与空原因)
|
||||
if effectiveCount <= freeTriggers {
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
}
|
||||
return false, failCount
|
||||
}
|
||||
|
||||
banNum := banCount - freeTriggers
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
banNum := effectiveCount - freeTriggers
|
||||
if knownUser {
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
} else {
|
||||
entry.Reason = fmt.Sprintf("第%d次封禁:%s(未知用户名,跳过宽限;第%d次触发,失败%d次)", banNum, reason, banCount, failCount)
|
||||
}
|
||||
entry.ExpiresAt = time.Now().Add(stageDuration(banNum, firstBanMin))
|
||||
if err := s.Bans.Update(entry); err != nil {
|
||||
return false, failCount
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -33,7 +34,7 @@ func TestRecordAuthFailureFreeTriggers(t *testing.T) {
|
||||
const maxFail = 2
|
||||
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
return banned
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ func TestStagedBanEscalation(t *testing.T) {
|
||||
const maxFail = 2
|
||||
|
||||
failOnce := func() bool {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多")
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
return banned
|
||||
}
|
||||
|
||||
@@ -228,7 +229,7 @@ func TestBanListOnlyBannedOrExpired(t *testing.T) {
|
||||
// TestRecordAuthFailureEmptyIPSafe 空 IP 不应产生副作用。
|
||||
func TestRecordAuthFailureEmptyIPSafe(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多")
|
||||
banned, count := s.RecordAuthFailure("", 3, 30, "登录失败次数过多", true)
|
||||
if banned || count != 0 {
|
||||
t.Fatalf("empty IP must be a no-op: banned=%v count=%d", banned, count)
|
||||
}
|
||||
@@ -350,3 +351,200 @@ func TestTryReserveQuotaNonPositiveDelta(t *testing.T) {
|
||||
t.Fatalf("used_bytes = %d, want 0", got.UsedBytes)
|
||||
}
|
||||
}
|
||||
|
||||
// P5 #18 方案 A:未知用户名(枚举型爆破)跳过宽限,首次触发即封。
|
||||
func TestRecordAuthFailureUnknownUserSkipsGrace(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.77"
|
||||
const maxFail = 3
|
||||
|
||||
// 未知用户名:第 1 次触发(累计失败 3 次)即封第 1 档(30 分钟)
|
||||
for i := 1; i <= maxFail; i++ {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", false)
|
||||
if i < maxFail && banned {
|
||||
t.Fatalf("attempt %d should not ban before threshold", i)
|
||||
}
|
||||
if i == maxFail && !banned {
|
||||
t.Fatal("unknown user: first trigger must ban immediately")
|
||||
}
|
||||
}
|
||||
banned, entry := s.Bans.IsBanned(ip)
|
||||
if !banned {
|
||||
t.Fatal("IP should be banned")
|
||||
}
|
||||
// 第 1 档 = 30 分钟
|
||||
if entry.ExpiresAt.Before(time.Now().Add(29 * time.Minute)) {
|
||||
t.Fatalf("first-stage ban duration wrong: expires %v", entry.ExpiresAt)
|
||||
}
|
||||
if !strings.Contains(entry.Reason, "未知用户名") {
|
||||
t.Fatalf("reason should note unknown-user skip: %q", entry.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// P5 #18 方案 A:已知用户名(真实用户输错)保留前 3 次宽限(回归)。
|
||||
// 触发语义与 TestRecordAuthFailureFreeTriggers 一致:达到阈值后每次失败
|
||||
// 都会触发一次,前 3 次触发(第 2-4 次失败)不封禁,第 4 次触发
|
||||
// (第 5 次失败)封第 1 档。
|
||||
func TestRecordAuthFailureKnownUserKeepsGrace(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "198.51.100.88"
|
||||
const maxFail = 2
|
||||
|
||||
// 第 1 次失败:计数,未达阈值
|
||||
if banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true); banned {
|
||||
t.Fatal("failure 1 must not ban")
|
||||
}
|
||||
// 第 2-4 次失败 = 触发 1-3,宽限期内不封禁
|
||||
for i := 2; i <= 4; i++ {
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
if banned {
|
||||
t.Fatalf("failure %d (trigger within grace) should not ban", i)
|
||||
}
|
||||
}
|
||||
if banned, _ := s.Bans.IsBanned(ip); banned {
|
||||
t.Fatal("known user must not be banned within 3 free triggers")
|
||||
}
|
||||
entry, _ := s.Bans.GetByIP(ip)
|
||||
if entry.BanCount != 3 {
|
||||
t.Fatalf("ban_count = %d, want 3", entry.BanCount)
|
||||
}
|
||||
|
||||
// 第 5 次失败 = 触发 4 -> 第 1 档封禁
|
||||
banned, _ := s.RecordAuthFailure(ip, maxFail, 30, "登录失败次数过多", true)
|
||||
if !banned {
|
||||
t.Fatal("4th trigger should ban (stage 1)")
|
||||
}
|
||||
entry, _ = s.Bans.GetByIP(ip)
|
||||
if entry.BanCount != 4 {
|
||||
t.Fatalf("ban count = %d, want 4", entry.BanCount)
|
||||
}
|
||||
if !strings.Contains(entry.Reason, "第1次封禁") {
|
||||
t.Fatalf("reason = %q, want 第1次封禁", entry.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// LoginExists:完整邮箱与裸用户名两种形态。
|
||||
func TestLoginExists(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
domain := &db.Domain{Name: "example.com"}
|
||||
if err := s.Domains.Create(domain); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Users.Create(&db.User{Username: "alice", PasswordHash: "x", DomainID: domain.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
login string
|
||||
want bool
|
||||
}{
|
||||
{"alice@example.com", true},
|
||||
{"alice", true},
|
||||
{"bob@example.com", false},
|
||||
{"bob", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := s.Users.LoginExists(tc.login); got != tc.want {
|
||||
t.Errorf("LoginExists(%q) = %v, want %v", tc.login, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:BanIP 为 upsert 语义,已有观察记录的 IP 手动封禁后仅一条记录。
|
||||
func TestBanIPUpsertSingleRow(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.99"
|
||||
|
||||
// 先产生观察计数记录(未封禁)
|
||||
for i := 0; i < 2; i++ {
|
||||
_, _ = s.RecordAuthFailure(ip, 10, 30, "登录失败次数过多", true)
|
||||
}
|
||||
if banned, _ := s.Bans.IsBanned(ip); banned {
|
||||
t.Fatal("should be observation-only at this point")
|
||||
}
|
||||
|
||||
// 手动封禁 180 天
|
||||
if err := s.Bans.BanIP(ip, "管理员手动封禁", 180*24*time.Hour); err != nil {
|
||||
t.Fatalf("BanIP: %v", err)
|
||||
}
|
||||
|
||||
// 仅一条记录,且处于封禁状态、计数清零
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByIP: %v", err)
|
||||
}
|
||||
if entry.Reason != "管理员手动封禁" {
|
||||
t.Fatalf("reason = %q", entry.Reason)
|
||||
}
|
||||
if entry.FailCount != 0 || entry.BanCount != 0 {
|
||||
t.Fatalf("manual ban should reset counters, got fail=%d ban=%d", entry.FailCount, entry.BanCount)
|
||||
}
|
||||
if !entry.ExpiresAt.After(time.Now().Add(179 * 24 * time.Hour)) {
|
||||
t.Fatalf("manual ban duration wrong: %v", entry.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:ip_address 唯一索引生效——同一 IP 不允许第二条记录
|
||||
// (历史重复行由 InitDB 的 dedupeBanEntries 在 AutoMigrate 前清理,
|
||||
// BanIP 的事务内“先删后插”兼容既有脏数据)。
|
||||
func TestBanEntryUniqueIndex(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "198.51.100.3"
|
||||
|
||||
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "first", ExpiresAt: time.Time{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 第二条同 IP 记录必须被唯一约束拒绝
|
||||
if err := s.Bans.Create(&db.BanEntry{IPAddress: ip, Reason: "second", ExpiresAt: time.Time{}}); err == nil {
|
||||
t.Fatal("duplicate ban entry for same IP should be rejected by unique index")
|
||||
}
|
||||
|
||||
// 唯一记录上的 BanIP/IncrementFail 读写一致(无错位)
|
||||
if err := s.Bans.BanIP(ip, "管理员手动封禁", time.Hour); err != nil {
|
||||
t.Fatalf("BanIP: %v", err)
|
||||
}
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if entry.Reason != "管理员手动封禁" || entry.FailCount != 0 {
|
||||
t.Fatalf("unexpected entry: %+v", entry)
|
||||
}
|
||||
cnt, err := s.Bans.IncrementFail(ip)
|
||||
if err != nil || cnt != 1 {
|
||||
t.Fatalf("IncrementFail after BanIP = %d, %v; want 1, nil", cnt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// P4 #17:IncrementFail 并发安全(-race 下不产生重复行、计数准确)。
|
||||
func TestIncrementFailConcurrent(t *testing.T) {
|
||||
s := newTestStores(t)
|
||||
const ip = "203.0.113.100"
|
||||
const goroutines = 16
|
||||
const perG = 5
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for g := 0; g < goroutines; g++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := 0; i < perG; i++ {
|
||||
if _, err := s.Bans.IncrementFail(ip); err != nil {
|
||||
t.Errorf("IncrementFail: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
entry, err := s.Bans.GetByIP(ip)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByIP: %v", err)
|
||||
}
|
||||
want := goroutines * perG
|
||||
if entry.FailCount != want {
|
||||
t.Fatalf("fail count = %d, want %d (lost updates or duplicate rows)", entry.FailCount, want)
|
||||
}
|
||||
}
|
||||
+67
-18
@@ -1,11 +1,14 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// 阶段封禁档位(分钟/天),从第 4 次触发阈值开始封禁:
|
||||
@@ -41,6 +44,10 @@ func stageDuration(banCount int, firstBanMin int) time.Duration {
|
||||
// BanStore defines the interface for IP ban operations.
|
||||
type BanStore interface {
|
||||
Create(entry *db.BanEntry) error
|
||||
// BanIP 手动/直接封禁某 IP 指定时长:该 IP 只保留一条记录
|
||||
// (既有观察记录一并清理,计数清零),避免产生重复行导致
|
||||
// IncrementFail 与 GetByIP 读写错位。
|
||||
BanIP(ip, reason string, duration time.Duration) error
|
||||
GetByIP(ip string) (*db.BanEntry, error)
|
||||
Update(entry *db.BanEntry) error
|
||||
Delete(id uint) error
|
||||
@@ -67,6 +74,30 @@ func (s *banStoreGorm) Create(entry *db.BanEntry) error {
|
||||
return s.db.Create(entry).Error
|
||||
}
|
||||
|
||||
// BanIP 手动/直接封禁:事务内删除该 IP 的全部既有记录(含历史 bug
|
||||
// 产生的重复行与观察计数记录)后插入一条封禁记录,计数清零。
|
||||
// 与"管理员解封清零"语义一致:手动封禁视为对档位的重新评估。
|
||||
func (s *banStoreGorm) BanIP(ip, reason string, duration time.Duration) error {
|
||||
if ip == "" {
|
||||
return fmt.Errorf("empty ip")
|
||||
}
|
||||
if duration <= 0 {
|
||||
return fmt.Errorf("invalid ban duration: %v", duration)
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
Reason: reason,
|
||||
FailCount: 0,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetByIP retrieves the most recent ban entry for a given IP address.
|
||||
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
|
||||
var entry db.BanEntry
|
||||
@@ -118,33 +149,51 @@ func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
|
||||
return true, &entry
|
||||
}
|
||||
|
||||
// IncrementFail increments the fail count for an IP address.
|
||||
// If no record exists, it creates one with fail_count=1, ban_count=0 and a
|
||||
// zero expires_at (not yet banned). Existing BanCount is preserved.
|
||||
// Returns the updated fail count.
|
||||
// IncrementFail increments the fail count for an IP address atomically
|
||||
// (SQL-side increment, avoiding read-modify-write races). If no record
|
||||
// exists it creates one with fail_count=1, ban_count=0 and a zero
|
||||
// expires_at (not yet banned); a concurrent creator wins and the loser's
|
||||
// insert becomes a no-op via the unique index. Existing BanCount is
|
||||
// preserved. Returns the current fail count.
|
||||
func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||
var entry db.BanEntry
|
||||
err := s.db.Where("ip_address = ?", ip).First(&entry).Error
|
||||
if err != nil {
|
||||
// No record exists, create a new one
|
||||
entry = db.BanEntry{
|
||||
res := s.db.Model(&db.BanEntry{}).
|
||||
Where("ip_address = ?", ip).
|
||||
Update("fail_count", gorm.Expr("fail_count + 1"))
|
||||
if res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// 无记录:插入首条;ip_address 唯一索引下并发插入用
|
||||
// OnConflict DoNothing 兜底,失败方继续走下面的回读。
|
||||
err := s.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&db.BanEntry{
|
||||
IPAddress: ip,
|
||||
FailCount: 1,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Time{}, // Zero time, not yet banned
|
||||
}).Error
|
||||
if err != nil && !isUniqueConflictErr(err) {
|
||||
return 0, err
|
||||
}
|
||||
if createErr := s.db.Create(&entry).Error; createErr != nil {
|
||||
return 0, createErr
|
||||
}
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
// Record exists, increment fail count
|
||||
newCount := entry.FailCount + 1
|
||||
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil {
|
||||
return 0, updateErr
|
||||
// 回读计数(并发下取数据库最终值)
|
||||
var count int64
|
||||
if err := s.db.Model(&db.BanEntry{}).Where("ip_address = ?", ip).
|
||||
Select("fail_count").Scan(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return newCount, nil
|
||||
return int(count), nil
|
||||
}
|
||||
|
||||
// isUniqueConflictErr 判断是否为唯一约束冲突(并发插入竞态的预期结果)。
|
||||
func isUniqueConflictErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "UNIQUE constraint") || // SQLite
|
||||
strings.Contains(msg, "Duplicate entry") || // MySQL
|
||||
strings.Contains(msg, "unique constraint") // generic
|
||||
}
|
||||
|
||||
// ResetFail resets the fail count for an IP address by deleting its record.
|
||||
|
||||
@@ -33,6 +33,13 @@ type MailStore interface {
|
||||
// SetFlaggedStates 批量设置多封邮件的星标状态(单条 UPDATE ... IN)。
|
||||
SetFlaggedStates(ids []uint, flagged bool) error
|
||||
MoveToFolder(id uint, folder string) error
|
||||
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
||||
SetDeletedStates(ids []uint, deleted bool) error
|
||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
|
||||
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
||||
DeleteMany(ids []uint) error
|
||||
Delete(id uint) error
|
||||
CountUnread(userID uint, folder string) (int64, error)
|
||||
CountByFolder(folder string) (int64, error)
|
||||
@@ -130,6 +137,32 @@ func (s *mailStoreGorm) MoveToFolder(id uint, folder string) error {
|
||||
return s.db.Model(&db.Message{}).Where("id = ?", id).Update("folder", folder).Error
|
||||
}
|
||||
|
||||
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记。
|
||||
func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
||||
}
|
||||
|
||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
|
||||
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||
var messages []db.Message
|
||||
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
|
||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return messages, nil
|
||||
}
|
||||
|
||||
// DeleteMany 批量硬删除多封邮件。
|
||||
func (s *mailStoreGorm) DeleteMany(ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Where("id IN ?", ids).Delete(&db.Message{}).Error
|
||||
}
|
||||
|
||||
// Delete removes a message by ID.
|
||||
func (s *mailStoreGorm) Delete(id uint) error {
|
||||
return s.db.Delete(&db.Message{}, id).Error
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Mailbox store errors(英文文案:IMAP 响应直接使用,Web 侧另行提示)。
|
||||
var (
|
||||
ErrMailboxNotFound = errors.New("No such mailbox")
|
||||
ErrMailboxExists = errors.New("mailbox already exists")
|
||||
ErrMailboxInvalid = errors.New("invalid mailbox name")
|
||||
ErrMailboxSystem = errors.New("system mailbox cannot be deleted or renamed")
|
||||
ErrMailboxNotEmpty = errors.New("mailbox is not empty")
|
||||
)
|
||||
|
||||
// MailboxStore defines the interface for mailbox (folder) operations.
|
||||
type MailboxStore interface {
|
||||
// EnsureSystem 幂等创建用户的系统文件夹(INBOX/Sent/Drafts/Trash)。
|
||||
EnsureSystem(userID uint) error
|
||||
// List 返回用户全部文件夹:系统文件夹按规范顺序在前,
|
||||
// 自定义文件夹按名称升序在后。
|
||||
List(userID uint) ([]db.Mailbox, error)
|
||||
GetByName(userID uint, name string) (*db.Mailbox, error)
|
||||
Create(mb *db.Mailbox) error
|
||||
// Delete 删除空的自定义文件夹(含对应的 mailbox_states 记录)。
|
||||
Delete(userID uint, name string) error
|
||||
// Rename 重命名文件夹并同步迁移其邮件与 mailbox_states 记录。
|
||||
Rename(userID uint, oldName, newName string) error
|
||||
SetSubscribed(userID uint, name string, subscribed bool) error
|
||||
}
|
||||
|
||||
// mailboxStoreGorm implements MailboxStore using GORM.
|
||||
type mailboxStoreGorm struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// newMailboxStore creates a new GORM-backed MailboxStore.
|
||||
func newMailboxStore(database *gorm.DB) MailboxStore {
|
||||
return &mailboxStoreGorm{db: database}
|
||||
}
|
||||
|
||||
// isSystemMailboxName reports whether name is one of the system mailboxes.
|
||||
func isSystemMailboxName(name string) bool {
|
||||
for _, def := range db.SystemMailboxes {
|
||||
if def.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EnsureSystem creates missing system mailboxes for the user.
|
||||
// 已存在的行不覆盖:用户此前若已退订某系统文件夹(LSUB),状态保留。
|
||||
func (s *mailboxStoreGorm) EnsureSystem(userID uint) error {
|
||||
var existing []db.Mailbox
|
||||
if err := s.db.Where("user_id = ?", userID).Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
have := make(map[string]bool, len(existing))
|
||||
for _, mb := range existing {
|
||||
have[mb.Name] = true
|
||||
}
|
||||
for _, def := range db.SystemMailboxes {
|
||||
if have[def.Name] {
|
||||
continue
|
||||
}
|
||||
mb := &db.Mailbox{
|
||||
UserID: userID,
|
||||
Name: def.Name,
|
||||
SpecialUse: def.SpecialUse,
|
||||
IsSubscribed: true,
|
||||
}
|
||||
if err := s.db.Create(mb).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all folders for a user with system folders first.
|
||||
func (s *mailboxStoreGorm) List(userID uint) ([]db.Mailbox, error) {
|
||||
var mbs []db.Mailbox
|
||||
if err := s.db.Where("user_id = ?", userID).Order("name").Find(&mbs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byName := make(map[string]db.Mailbox, len(mbs))
|
||||
for _, mb := range mbs {
|
||||
byName[mb.Name] = mb
|
||||
}
|
||||
sys := make([]db.Mailbox, 0, len(db.SystemMailboxes))
|
||||
for _, def := range db.SystemMailboxes {
|
||||
if mb, ok := byName[def.Name]; ok {
|
||||
sys = append(sys, mb)
|
||||
}
|
||||
}
|
||||
custom := make([]db.Mailbox, 0, len(mbs))
|
||||
for _, mb := range mbs {
|
||||
if !isSystemMailboxName(mb.Name) {
|
||||
custom = append(custom, mb)
|
||||
}
|
||||
}
|
||||
return append(sys, custom...), nil
|
||||
}
|
||||
|
||||
// GetByName returns a folder by its exact name.
|
||||
func (s *mailboxStoreGorm) GetByName(userID uint, name string) (*db.Mailbox, error) {
|
||||
var mb db.Mailbox
|
||||
if err := s.db.Where("user_id = ? AND name = ?", userID, name).First(&mb).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &mb, nil
|
||||
}
|
||||
|
||||
// Create inserts a new mailbox row.
|
||||
func (s *mailboxStoreGorm) Create(mb *db.Mailbox) error {
|
||||
return s.db.Create(mb).Error
|
||||
}
|
||||
|
||||
// Delete removes an empty mailbox and its mailbox_states record.
|
||||
func (s *mailboxStoreGorm) Delete(userID uint, name string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&db.Message{}).
|
||||
Where("user_id = ? AND folder = ?", userID, name).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return ErrMailboxNotEmpty
|
||||
}
|
||||
if err := tx.Where("user_id = ? AND name = ?", userID, name).
|
||||
Delete(&db.Mailbox{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("user_id = ? AND folder = ?", userID, name).
|
||||
Delete(&db.MailboxState{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// Rename renames a mailbox, moving its messages along with it.
|
||||
func (s *mailboxStoreGorm) Rename(userID uint, oldName, newName string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&db.Mailbox{}).
|
||||
Where("user_id = ? AND name = ?", userID, oldName).
|
||||
Update("name", newName).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&db.Message{}).
|
||||
Where("user_id = ? AND folder = ?", userID, oldName).
|
||||
Update("folder", newName).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 旧 UIDVALIDITY 不再适用:删除状态记录,下次访问重新生成,
|
||||
// 客户端据此丢弃缓存并全量重同步。
|
||||
return tx.Where("user_id = ? AND folder = ?", userID, oldName).
|
||||
Delete(&db.MailboxState{}).Error
|
||||
})
|
||||
}
|
||||
|
||||
// SetSubscribed updates the subscription flag of a mailbox.
|
||||
func (s *mailboxStoreGorm) SetSubscribed(userID uint, name string, subscribed bool) error {
|
||||
return s.db.Model(&db.Mailbox{}).
|
||||
Where("user_id = ? AND name = ?", userID, name).
|
||||
Update("is_subscribed", subscribed).Error
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mail_go/internal/db"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newMailboxTestStore 返回基于内存库的 MailboxStore(含 mailboxes 表)。
|
||||
func newMailboxTestStore(t *testing.T) (MailboxStore, *gorm.DB) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.Mailbox{}, &db.Message{}, &db.MailboxState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return newMailboxStore(gdb), gdb
|
||||
}
|
||||
|
||||
// TestMailboxEnsureSystem 验证系统文件夹幂等创建与规范排序。
|
||||
func TestMailboxEnsureSystem(t *testing.T) {
|
||||
s, _ := newMailboxTestStore(t)
|
||||
|
||||
if err := s.EnsureSystem(1); err != nil {
|
||||
t.Fatalf("EnsureSystem: %v", err)
|
||||
}
|
||||
// 幂等:再次调用不报错、不产生重复行
|
||||
if err := s.EnsureSystem(1); err != nil {
|
||||
t.Fatalf("EnsureSystem 2nd: %v", err)
|
||||
}
|
||||
|
||||
mbs, err := s.List(1)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(mbs) != 4 {
|
||||
t.Fatalf("len(List) = %d, want 4", len(mbs))
|
||||
}
|
||||
want := []string{"INBOX", "Sent", "Drafts", "Trash"}
|
||||
for i, name := range want {
|
||||
if mbs[i].Name != name {
|
||||
t.Fatalf("List[%d] = %q, want %q", i, mbs[i].Name, name)
|
||||
}
|
||||
}
|
||||
if mbs[3].SpecialUse != "Trash" {
|
||||
t.Fatalf("Trash SpecialUse = %q, want Trash", mbs[3].SpecialUse)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailboxCustomFolderLifecycle 验证自定义文件夹:创建/排序/非空删除拒绝。
|
||||
func TestMailboxCustomFolderLifecycle(t *testing.T) {
|
||||
s, gdb := newMailboxTestStore(t)
|
||||
if err := s.EnsureSystem(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.Create(&db.Mailbox{UserID: 1, Name: "工作", IsSubscribed: true}); err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
|
||||
mbs, err := s.List(1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(mbs) != 5 || mbs[4].Name != "工作" {
|
||||
t.Fatalf("custom folder not last: %+v", mbs)
|
||||
}
|
||||
|
||||
// 放一封邮件进去 → 非空文件夹不可删除
|
||||
msg := &db.Message{UserID: 1, Folder: "工作", FromAddr: "a@b", Subject: "s"}
|
||||
if err := gdb.Create(msg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(1, "工作"); err != ErrMailboxNotEmpty {
|
||||
t.Fatalf("Delete non-empty mailbox = %v, want ErrMailboxNotEmpty", err)
|
||||
}
|
||||
// 清空后可删除
|
||||
if err := gdb.Where("id = ?", msg.ID).Delete(&db.Message{}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Delete(1, "工作"); err != nil {
|
||||
t.Fatalf("Delete empty mailbox: %v", err)
|
||||
}
|
||||
if _, err := s.GetByName(1, "工作"); err == nil {
|
||||
t.Fatal("mailbox should be gone after Delete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailboxRenameMovesMessages 验证重命名同步迁移邮件。
|
||||
func TestMailboxRenameMovesMessages(t *testing.T) {
|
||||
s, gdb := newMailboxTestStore(t)
|
||||
if err := s.EnsureSystem(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Create(&db.Mailbox{UserID: 1, Name: "旧名字", IsSubscribed: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
msg := &db.Message{UserID: 1, Folder: "旧名字", FromAddr: "a@b", Subject: "s"}
|
||||
if err := gdb.Create(msg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := s.Rename(1, "旧名字", "新名字"); err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := gdb.Model(&db.Message{}).
|
||||
Where("user_id = 1 AND folder = ?", "新名字").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("messages in 新名字 = %d, want 1", count)
|
||||
}
|
||||
if _, err := s.GetByName(1, "旧名字"); err == nil {
|
||||
t.Fatal("old mailbox name should be gone")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMailboxSubscribed 验证订阅状态写入。
|
||||
func TestMailboxSubscribed(t *testing.T) {
|
||||
s, _ := newMailboxTestStore(t)
|
||||
if err := s.EnsureSystem(1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.SetSubscribed(1, "Trash", false); err != nil {
|
||||
t.Fatalf("SetSubscribed: %v", err)
|
||||
}
|
||||
mb, err := s.GetByName(1, "Trash")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mb.IsSubscribed {
|
||||
t.Fatal("Trash should be unsubscribed")
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ type Stores struct {
|
||||
Outbound OutboundStore
|
||||
ProtocolLogs ProtocolLogStore
|
||||
MailboxState MailboxStateStore
|
||||
Mailboxes MailboxStore
|
||||
}
|
||||
|
||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||
@@ -29,6 +30,7 @@ func NewStores(database *gorm.DB) *Stores {
|
||||
Outbound: newOutboundStore(database),
|
||||
ProtocolLogs: newProtocolLogStore(database),
|
||||
MailboxState: newMailboxStateStore(database),
|
||||
Mailboxes: newMailboxStore(database),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,3 +42,4 @@ var _ = db.Attachment{}
|
||||
var _ = db.BanEntry{}
|
||||
var _ = db.ProtocolLog{}
|
||||
var _ = db.MailboxState{}
|
||||
var _ = db.Mailbox{}
|
||||
@@ -20,12 +20,18 @@ type UserStore interface {
|
||||
// 但支持裸用户名(如 "kevin"),自动解析到其唯一所属域名;多域名下
|
||||
// 用户名存在歧义时要求完整邮箱。兼容手机/客户端只填用户名的配置。
|
||||
AuthenticateLogin(login, password string) (*db.User, error)
|
||||
// LoginExists 判断登录名(完整邮箱或裸用户名)是否对应系统中的用户,
|
||||
// 供封禁逻辑区分“真实用户输错密码”(保留宽限)与“枚举型爆破”
|
||||
// (跳过宽限,见 RecordAuthFailure 的 knownUser 参数)。
|
||||
LoginExists(login string) bool
|
||||
Update(user *db.User) error
|
||||
Delete(id uint) error
|
||||
List(domainID uint, page, size int) ([]db.User, int64, error)
|
||||
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)
|
||||
@@ -127,6 +133,25 @@ func (s *userStoreGorm) AuthenticateLogin(login, password string) (*db.User, err
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// LoginExists 判断登录名是否对应系统中的用户:完整邮箱按邮箱查,
|
||||
// 裸用户名按用户名全局查(存在即算,歧义不影响存在性判定)。
|
||||
// 仅用于封禁分级(knownUser),不做认证。
|
||||
func (s *userStoreGorm) LoginExists(login string) bool {
|
||||
login = strings.TrimSpace(login)
|
||||
if login == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(login, "@") {
|
||||
_, err := s.GetByEmail(login)
|
||||
return err == nil
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Model(&db.User{}).Where("username = ?", login).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// Update saves changes to an existing user record.
|
||||
func (s *userStoreGorm) Update(user *db.User) error {
|
||||
return s.db.Save(user).Error
|
||||
@@ -181,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
|
||||
|
||||
+148
-125
@@ -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"
|
||||
@@ -35,12 +36,30 @@ type AdminHandler struct {
|
||||
protocolLogKeepDays int
|
||||
// hub 当前协议连接注册中心(「当前连接」页)
|
||||
hub *connhub.Hub
|
||||
// tz Web 展示时区(「今日」统计边界、日志日期筛选用),nil 回退本地时区
|
||||
tz *time.Location
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler with the given stores, attachment
|
||||
// storage, TLS directory, Caddy data directory and outbound delivery manager.
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub}
|
||||
func NewAdminHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, tlsDir string, caddyDataDir string, ob *outbound.Manager, protocolLogKeepDays int, hub *connhub.Hub, tz *time.Location) *AdminHandler {
|
||||
return &AdminHandler{stores: stores, storage: attStorage, tlsDir: tlsDir, caddyDataDir: caddyDataDir, outbound: ob, protocolLogKeepDays: protocolLogKeepDays, hub: hub, tz: tz}
|
||||
}
|
||||
|
||||
// displayTZ 返回 Web 展示时区(未配置时回退本地时区)。
|
||||
func (h *AdminHandler) displayTZ() *time.Location {
|
||||
if h.tz != nil {
|
||||
return h.tz
|
||||
}
|
||||
return time.Local
|
||||
}
|
||||
|
||||
// dayStartIn 返回展示时区的「今日零点」,并转换为服务器本地时区:
|
||||
// 库中 CreatedAt 按写入时服务器本地时区序列化(RFC3339 文本),
|
||||
// 边界需同偏移才能保证 SQLite 文本比较正确。
|
||||
func (h *AdminHandler) dayStartIn() time.Time {
|
||||
now := time.Now().In(h.displayTZ())
|
||||
return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, h.displayTZ()).In(time.Local)
|
||||
}
|
||||
|
||||
// manualBanDuration 管理员手动封禁时长(180 天,与自动封禁档位上限制一致)。
|
||||
@@ -51,25 +70,21 @@ 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
|
||||
}
|
||||
|
||||
// 加入黑名单:180 天封禁(管理员可随时解封)
|
||||
if err := h.stores.Bans.Create(&db.BanEntry{
|
||||
IPAddress: conn.IP,
|
||||
Reason: "管理员手动封禁(连接断开)",
|
||||
FailCount: 0,
|
||||
BanCount: 0,
|
||||
ExpiresAt: time.Now().Add(manualBanDuration),
|
||||
}); err != nil {
|
||||
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
|
||||
// 加入黑名单:180 天封禁(管理员可随时解封)。
|
||||
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
|
||||
// 避免与阶段性封禁的计数/档位记录错位。
|
||||
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
|
||||
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "封禁失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -91,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,
|
||||
@@ -100,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.
|
||||
@@ -119,8 +134,8 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
|
||||
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
|
||||
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
|
||||
|
||||
// Today and weekly statistics
|
||||
todayStart := time.Now().Truncate(24 * time.Hour)
|
||||
// Today and weekly statistics(「今日」按 Web 展示时区切日)
|
||||
todayStart := h.dayStartIn()
|
||||
weekStart := time.Now().AddDate(0, 0, -7)
|
||||
|
||||
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
|
||||
@@ -139,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,
|
||||
@@ -157,7 +172,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
|
||||
"weekSent": weekSent,
|
||||
"banCount": banCount,
|
||||
"activeFolder": "admin",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// ListDomains renders the domain list page.
|
||||
@@ -165,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
|
||||
}
|
||||
|
||||
@@ -179,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,
|
||||
@@ -187,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.
|
||||
@@ -213,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,
|
||||
@@ -225,7 +240,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
||||
Pop3Port: pop3Port,
|
||||
TlsEnabled: tlsEnabled,
|
||||
},
|
||||
})
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -249,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
|
||||
}
|
||||
|
||||
@@ -266,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
|
||||
}
|
||||
|
||||
@@ -283,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": "",
|
||||
@@ -297,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
|
||||
}
|
||||
|
||||
@@ -321,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
|
||||
}
|
||||
@@ -337,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
|
||||
}
|
||||
|
||||
@@ -350,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
|
||||
}
|
||||
|
||||
@@ -394,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
|
||||
}
|
||||
|
||||
@@ -437,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))
|
||||
@@ -451,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
|
||||
@@ -489,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,
|
||||
@@ -497,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
|
||||
}
|
||||
|
||||
@@ -520,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.
|
||||
@@ -546,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
|
||||
}
|
||||
|
||||
@@ -563,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,
|
||||
@@ -572,7 +589,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "users",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// NewUser renders the new user form page.
|
||||
@@ -581,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.
|
||||
@@ -602,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{
|
||||
@@ -613,7 +630,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -622,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{
|
||||
@@ -633,7 +650,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
||||
DomainID: domainID,
|
||||
IsAdmin: isAdmin,
|
||||
},
|
||||
})
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -647,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
|
||||
}
|
||||
|
||||
@@ -670,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
|
||||
}
|
||||
|
||||
@@ -692,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
|
||||
}
|
||||
|
||||
@@ -743,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 != "" {
|
||||
@@ -750,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)
|
||||
@@ -768,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
|
||||
}
|
||||
|
||||
@@ -788,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
|
||||
}
|
||||
|
||||
@@ -809,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,
|
||||
@@ -817,7 +839,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"activeFolder": "bans",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
|
||||
@@ -830,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
|
||||
}
|
||||
|
||||
@@ -860,8 +882,8 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
|
||||
success = &v
|
||||
}
|
||||
|
||||
from := parseDateQuery(c.Query("from"))
|
||||
to := parseDateQuery(c.Query("to"))
|
||||
from := h.parseDateQuery(c.Query("from"))
|
||||
to := h.parseDateQuery(c.Query("to"))
|
||||
// 日期选择到天,含当天
|
||||
if !to.IsZero() {
|
||||
to = to.AddDate(0, 0, 1)
|
||||
@@ -878,12 +900,12 @@ 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
|
||||
}
|
||||
|
||||
// 统计卡片:今日 + 全部成功/失败数(按协议),int64 → int 供模板 add 使用
|
||||
dayStart := time.Now().Truncate(24 * time.Hour)
|
||||
dayStart := h.dayStartIn()
|
||||
todayStats, _ := h.stores.ProtocolLogs.CountStats(dayStart)
|
||||
allStats, _ := h.stores.ProtocolLogs.CountStats(time.Time{})
|
||||
normStats := func(m map[string]map[string]int64) map[string]map[string]int {
|
||||
@@ -914,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,
|
||||
@@ -926,7 +948,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
|
||||
"allStats": normStats(allStats),
|
||||
"keepDays": h.protocolLogKeepDays,
|
||||
"activeFolder": "protocol-logs",
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。
|
||||
@@ -935,16 +957,17 @@ func (h *AdminHandler) CleanupProtocolLogs(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, "/admin/protocol-logs")
|
||||
}
|
||||
|
||||
// parseDateQuery 解析 YYYY-MM-DD 日期,失败返回零值。
|
||||
func parseDateQuery(s string) time.Time {
|
||||
// parseDateQuery 按 Web 展示时区解析 YYYY-MM-DD 日期,失败返回零值。
|
||||
// 解析结果转换为服务器本地时区,保证与库中时间序列化偏移一致。
|
||||
func (h *AdminHandler) parseDateQuery(s string) time.Time {
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.ParseInLocation("2006-01-02", s, time.Local)
|
||||
t, err := time.ParseInLocation("2006-01-02", s, h.displayTZ())
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
return t.In(time.Local)
|
||||
}
|
||||
|
||||
// ListMails renders the admin mail list page showing all messages across all users.
|
||||
@@ -963,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
|
||||
}
|
||||
|
||||
@@ -977,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,
|
||||
@@ -986,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
|
||||
}
|
||||
|
||||
@@ -1008,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
|
||||
}
|
||||
|
||||
@@ -1047,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
|
||||
}
|
||||
|
||||
@@ -1073,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,
|
||||
@@ -1084,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
|
||||
}
|
||||
|
||||
@@ -1111,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,31 +63,34 @@ 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
|
||||
}
|
||||
|
||||
user, err := h.stores.Users.Authenticate(email, password)
|
||||
if err != nil {
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多")
|
||||
// 区分失败性质:用户名存在(真实用户输错,保留宽限)vs
|
||||
// 用户名不存在(枚举型爆破,跳过宽限首次触发即封)
|
||||
knownUser := h.stores.Users.LoginExists(email)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -102,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
|
||||
}
|
||||
|
||||
@@ -121,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
|
||||
}
|
||||
|
||||
@@ -129,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
|
||||
}
|
||||
|
||||
@@ -146,42 +150,43 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||||
if err != nil {
|
||||
log.Printf("LDAP 认证失败: %v", err)
|
||||
|
||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多")
|
||||
// LDAP 侧用户存在性无法判定,保守按已知用户处理(保留宽限防误封)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -197,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
|
||||
}
|
||||
|
||||
@@ -242,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
|
||||
}
|
||||
|
||||
@@ -250,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)
|
||||
@@ -260,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
|
||||
}
|
||||
|
||||
@@ -270,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 一次性使用:无论后续成败都立即失效
|
||||
@@ -282,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
|
||||
}
|
||||
|
||||
@@ -295,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
|
||||
}
|
||||
|
||||
@@ -335,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
+268
-296
@@ -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"
|
||||
@@ -51,38 +52,88 @@ type MailHandler struct {
|
||||
stores *store.Stores
|
||||
storage *storage.AttachmentStorage
|
||||
outbound *outbound.Manager
|
||||
// svc 邮箱服务层(IMAP 层共用):文件夹目录与消息操作同源。
|
||||
svc *imap_server.MailboxService
|
||||
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
|
||||
pusher imap_server.Pusher
|
||||
}
|
||||
|
||||
// NewMailHandler creates a new MailHandler with the given stores, attachment
|
||||
// storage and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, pusher imap_server.Pusher) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, pusher: pusher}
|
||||
// storage, mailbox service and outbound delivery manager.
|
||||
func NewMailHandler(stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, svc *imap_server.MailboxService, pusher imap_server.Pusher) *MailHandler {
|
||||
return &MailHandler{stores: stores, storage: attStorage, outbound: ob, svc: svc, pusher: pusher}
|
||||
}
|
||||
|
||||
// folderCounts returns sidebar badge counts for the current user.
|
||||
func (h *MailHandler) folderCounts(userID uint) (inboxUnread, draftsTotal, sentTotal int64) {
|
||||
inboxUnread, _ = h.stores.Mails.CountUnread(userID, "INBOX")
|
||||
draftsTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Drafts")
|
||||
sentTotal, _ = h.stores.Mails.CountByUserAndFolder(userID, "Sent")
|
||||
return
|
||||
}
|
||||
|
||||
// Inbox renders the inbox page showing all messages in the user's INBOX folder.
|
||||
func (h *MailHandler) Inbox(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "INBOX", page, 20)
|
||||
// foldersFor 返回当前用户的侧边栏文件夹列表(与 IMAP LIST 同源:
|
||||
// IMAP 返回什么文件夹,Web 就显示什么)。
|
||||
func (h *MailHandler) foldersFor(userID uint) []imap_server.FolderInfo {
|
||||
infos, err := h.svc.List(userID)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载收件箱失败: %v", err)
|
||||
log.Printf("web: 加载文件夹列表失败 user=%d: %v", userID, err)
|
||||
return nil
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// userEmailOf 从 context 取当前用户完整邮箱(推送用),失败返回空串。
|
||||
func userEmailOf(c *gin.Context) string {
|
||||
if cu, ok := c.Get("currentUser"); ok {
|
||||
if u, ok := cu.(*db.User); ok {
|
||||
return u.Username + "@" + u.Domain.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// seqOfFolder 返回消息在文件夹中的序号(1 基,与 IMAP 序号排序一致)。
|
||||
func (h *MailHandler) seqOfFolder(userID uint, folder string, msgID uint) uint32 {
|
||||
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, folder)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for i := range msgs {
|
||||
if msgs[i].ID == msgID {
|
||||
return uint32(i + 1)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// purgeMessages 永久删除邮件(含附件文件与配额回退)。
|
||||
func (h *MailHandler) purgeMessages(userID uint, msgs []db.Message) {
|
||||
ids := make([]uint, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(msgs[i].ID)
|
||||
for _, att := range attachments {
|
||||
_ = h.storage.Delete(att.FilePath)
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
|
||||
}
|
||||
if err := h.stores.Attachments.DeleteByMessage(msgs[i].ID); err != nil {
|
||||
log.Printf("web: 删除附件记录失败 msg=%d: %v", msgs[i].ID, err)
|
||||
}
|
||||
ids = append(ids, msgs[i].ID)
|
||||
}
|
||||
if err := h.stores.Mails.DeleteMany(ids); err != nil {
|
||||
log.Printf("web: 删除邮件失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Folder renders the generic mailbox page for any folder the IMAP layer
|
||||
// exposes (INBOX / Sent / Drafts / Trash / custom mailboxes).
|
||||
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, i18n.T(langOf(c), "邮箱不存在"))
|
||||
return
|
||||
}
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
messages, total, err := h.svc.Messages(userID, name, page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
@@ -92,19 +143,19 @@ func (h *MailHandler) Inbox(c *gin.Context) {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "inbox", gin.H{
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
c.HTML(200, "folder", withLang(c, gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "INBOX",
|
||||
"activeFolder": "inbox",
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
})
|
||||
"folder": name,
|
||||
"activeFolder": name,
|
||||
"isTrash": name == "Trash",
|
||||
"folders": h.foldersFor(userID),
|
||||
}))
|
||||
}
|
||||
|
||||
// View renders the email detail page for a specific message.
|
||||
@@ -112,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
|
||||
}
|
||||
|
||||
@@ -140,17 +191,15 @@ func (h *MailHandler) View(c *gin.Context) {
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
c.HTML(200, "view", gin.H{
|
||||
c.HTML(200, "view", withLang(c, gin.H{
|
||||
"currentUser": currentUser,
|
||||
"message": msg,
|
||||
"attachments": attachments,
|
||||
"activeFolder": resolveActiveFolder(msg.Folder),
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
})
|
||||
"activeFolder": msg.Folder,
|
||||
"inTrash": msg.Folder == "Trash",
|
||||
"folders": h.foldersFor(userID),
|
||||
}))
|
||||
}
|
||||
|
||||
// Compose renders the email composition page.
|
||||
@@ -167,9 +216,7 @@ func (h *MailHandler) Compose(c *gin.Context) {
|
||||
quotaBytes = user.QuotaBytes
|
||||
}
|
||||
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
c.HTML(200, "compose", gin.H{
|
||||
c.HTML(200, "compose", withLang(c, gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "",
|
||||
@@ -178,9 +225,23 @@ func (h *MailHandler) Compose(c *gin.Context) {
|
||||
"bodyContent": "",
|
||||
"usedBytes": usedBytes,
|
||||
"quotaBytes": quotaBytes,
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
"folders": h.foldersFor(userID),
|
||||
}))
|
||||
}
|
||||
|
||||
// composeData builds the shared template context for the compose page.
|
||||
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,
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": body,
|
||||
"usedBytes": user.UsedBytes,
|
||||
"quotaBytes": user.QuotaBytes,
|
||||
"folders": h.foldersFor(userID),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -198,17 +259,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
cc := c.PostForm("cc")
|
||||
|
||||
if to == "" {
|
||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "请输入收件人",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "请输入收件人"), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -226,17 +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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "配额检查失败,请稍后重试",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "配额检查失败,请稍后重试"), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
if !reserved {
|
||||
@@ -245,17 +286,18 @@ 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,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": usedBytes,
|
||||
"quotaBytes": quotaBytes,
|
||||
})
|
||||
"folders": h.foldersFor(userID),
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -314,46 +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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": "外部投递未启用",
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -375,17 +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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("投递邮件失败: %v", createErr),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||
@@ -411,17 +413,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
||||
}
|
||||
|
||||
if createErr := h.stores.Mails.Create(msg); createErr != nil {
|
||||
c.HTML(http.StatusInternalServerError, "compose", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "compose",
|
||||
"error": fmt.Sprintf("保存邮件失败: %v", createErr),
|
||||
"to": to,
|
||||
"subject": subject,
|
||||
"cc": cc,
|
||||
"bodyContent": htmlBody,
|
||||
"usedBytes": currentUser.UsedBytes,
|
||||
"quotaBytes": currentUser.QuotaBytes,
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -572,43 +564,6 @@ var mimeTypes = map[string]string{
|
||||
".csv": "text/csv",
|
||||
}
|
||||
|
||||
// Sent renders the sent mail folder page.
|
||||
func (h *MailHandler) Sent(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Sent", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载发件箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "sent", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "Sent",
|
||||
"activeFolder": "sent",
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// safeRedirectPath 仅接受同站相对路径(以 / 开头且非 //),
|
||||
// 防止把用户重定向到外部站点(开放重定向)。非法值返回空串,
|
||||
// 调用方应回退到默认路径。
|
||||
@@ -619,76 +574,155 @@ func safeRedirectPath(referer string) string {
|
||||
return referer
|
||||
}
|
||||
|
||||
// Delete removes a message by ID after verifying ownership.
|
||||
// Delete 删除邮件(IMAP 语义):非 Trash 文件夹 → 移入 Trash;
|
||||
// 已在 Trash → 彻底删除。
|
||||
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)
|
||||
|
||||
// Delete attachments on disk and in DB, and decrease UsedBytes
|
||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||
for _, att := range attachments {
|
||||
_ = h.storage.Delete(att.FilePath)
|
||||
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
|
||||
}
|
||||
if err := h.stores.Attachments.DeleteByMessage(uint(id)); err != nil {
|
||||
log.Printf("web: 删除附件记录失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// 删除前计算消息在所属文件夹中的序号(用于 Expunge 推送)
|
||||
var seq uint32
|
||||
if msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, msg.Folder); err == nil {
|
||||
for i := range msgs {
|
||||
if msgs[i].ID == uint(id) {
|
||||
seq = uint32(i + 1)
|
||||
break
|
||||
}
|
||||
if msg.Folder == "Trash" {
|
||||
// 垃圾箱中删除 = 彻底删除
|
||||
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
|
||||
h.purgeMessages(userID, []db.Message{*msg})
|
||||
if h.pusher != nil && userEmail != "" {
|
||||
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
|
||||
}
|
||||
}
|
||||
if err := h.stores.Mails.Delete(uint(id)); err != nil {
|
||||
log.Printf("web: 删除邮件失败 msg=%d: %v", id, err)
|
||||
}
|
||||
|
||||
// 删除 → 推送给该用户的其他 IMAP 客户端
|
||||
if h.pusher != nil && seq > 0 {
|
||||
userEmail := ""
|
||||
if cu, ok := c.Get("currentUser"); ok {
|
||||
if u, ok := cu.(*db.User); ok {
|
||||
userEmail = u.Username + "@" + u.Domain.Name
|
||||
}
|
||||
} else {
|
||||
// 其余文件夹删除 = 移入垃圾箱(与 IMAP MOVE 同源语义)
|
||||
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, i18n.T(langOf(c), "删除失败"))
|
||||
return
|
||||
}
|
||||
if h.pusher != nil && userEmail != "" {
|
||||
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
|
||||
h.pusher.PushNewMessage(userEmail, &db.Message{UserID: userID, Folder: "Trash"})
|
||||
}
|
||||
h.pusher.PushExpunged(userEmail, msg.Folder, []uint32{seq})
|
||||
}
|
||||
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
referer = "/folder/" + msg.Folder
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// Restore 把垃圾箱中的邮件恢复到收件箱。
|
||||
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, i18n.T(langOf(c), "无效的邮件ID"))
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||
return
|
||||
}
|
||||
if msg.Folder != "Trash" {
|
||||
c.Redirect(http.StatusFound, "/folder/"+msg.Folder)
|
||||
return
|
||||
}
|
||||
|
||||
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, i18n.T(langOf(c), "恢复失败"))
|
||||
return
|
||||
}
|
||||
if h.pusher != nil {
|
||||
if email := userEmailOf(c); email != "" {
|
||||
h.pusher.PushExpunged(email, "Trash", []uint32{seq})
|
||||
h.pusher.PushNewMessage(email, &db.Message{UserID: userID, Folder: "INBOX"})
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/folder/Trash")
|
||||
}
|
||||
|
||||
// Purge 彻底删除一封邮件(任意文件夹)。
|
||||
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, i18n.T(langOf(c), "无效的邮件ID"))
|
||||
return
|
||||
}
|
||||
|
||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||
if err != nil || msg.UserID != userID {
|
||||
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||
return
|
||||
}
|
||||
|
||||
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
|
||||
h.purgeMessages(userID, []db.Message{*msg})
|
||||
if h.pusher != nil {
|
||||
if email := userEmailOf(c); email != "" {
|
||||
h.pusher.PushExpunged(email, msg.Folder, []uint32{seq})
|
||||
}
|
||||
}
|
||||
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/folder/" + msg.Folder
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
|
||||
// EmptyFolder 清空文件夹(永久删除其中全部邮件)。
|
||||
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, i18n.T(langOf(c), "邮箱不存在"))
|
||||
return
|
||||
}
|
||||
|
||||
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "清空文件夹失败: %v", err))
|
||||
return
|
||||
}
|
||||
seqs := make([]uint32, 0, len(msgs))
|
||||
for i := range msgs {
|
||||
seqs = append(seqs, uint32(i+1))
|
||||
}
|
||||
h.purgeMessages(userID, msgs)
|
||||
if h.pusher != nil {
|
||||
if email := userEmailOf(c); email != "" {
|
||||
h.pusher.PushExpunged(email, name, seqs)
|
||||
}
|
||||
}
|
||||
c.Redirect(http.StatusFound, "/folder/"+name)
|
||||
}
|
||||
|
||||
// MarkRead marks a message as read.
|
||||
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
|
||||
}
|
||||
|
||||
@@ -709,7 +743,7 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
|
||||
// Redirect back based on the folder(仅同站相对路径,防开放重定向)
|
||||
referer := safeRedirectPath(c.GetHeader("Referer"))
|
||||
if referer == "" {
|
||||
referer = "/inbox"
|
||||
referer = "/folder/INBOX"
|
||||
}
|
||||
c.Redirect(http.StatusFound, referer)
|
||||
}
|
||||
@@ -719,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
|
||||
}
|
||||
|
||||
@@ -760,142 +794,80 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
|
||||
return page
|
||||
}
|
||||
|
||||
// Drafts renders the drafts folder page.
|
||||
func (h *MailHandler) Drafts(c *gin.Context) {
|
||||
userID := c.GetUint("userID")
|
||||
page := getPageParam(c, "page", 1)
|
||||
|
||||
messages, total, err := h.stores.Mails.ListByUserAndFolder(userID, "Drafts", page, 20)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "加载草稿箱失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(userID)
|
||||
|
||||
totalPages := int(total) / 20
|
||||
if int(total)%20 > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 0
|
||||
}
|
||||
|
||||
c.HTML(200, "drafts", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"messages": messages,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": 20,
|
||||
"totalPages": totalPages,
|
||||
"folder": "Drafts",
|
||||
"activeFolder": "drafts",
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
})
|
||||
}
|
||||
|
||||
// Settings renders the user settings page.
|
||||
func (h *MailHandler) Settings(c *gin.Context) {
|
||||
currentUser, _ := c.Get("currentUser")
|
||||
userID := c.GetUint("userID")
|
||||
inboxUnread, draftsTotal, sentTotal := h.folderCounts(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",
|
||||
"inboxUnread": inboxUnread,
|
||||
"draftsTotal": draftsTotal,
|
||||
"sentTotal": sentTotal,
|
||||
"folders": h.foldersFor(userID),
|
||||
}))
|
||||
}
|
||||
|
||||
// settingsData builds the shared template context for the settings page.
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "当前密码不正确",
|
||||
"success": "",
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "当前密码不正确"), ""))
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword == "" {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "新密码不能为空",
|
||||
"success": "",
|
||||
})
|
||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "新密码不能为空"), ""))
|
||||
return
|
||||
}
|
||||
|
||||
if newPassword != confirmPassword {
|
||||
c.HTML(http.StatusBadRequest, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "两次输入的密码不一致",
|
||||
"success": "",
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码加密失败",
|
||||
"success": "",
|
||||
})
|
||||
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", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "密码更新失败",
|
||||
"success": "",
|
||||
})
|
||||
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码更新失败"), ""))
|
||||
return
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "settings", gin.H{
|
||||
"currentUser": currentUser,
|
||||
"activeFolder": "settings",
|
||||
"error": "",
|
||||
"success": "密码修改成功",
|
||||
})
|
||||
}
|
||||
|
||||
// resolveActiveFolder maps a folder name to a sidebar active state key.
|
||||
func resolveActiveFolder(folder string) string {
|
||||
switch folder {
|
||||
case "INBOX":
|
||||
return "inbox"
|
||||
case "Sent":
|
||||
return "sent"
|
||||
case "Drafts":
|
||||
return "drafts"
|
||||
default:
|
||||
return folder
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
package handlers
|
||||
|
||||
// Web 删除语义回归测试:删除=移入垃圾箱(IMAP 语义)、垃圾箱中删除=彻底
|
||||
// 删除、恢复=回到收件箱、清空=永久删除。文件夹操作全部经由 MailboxService
|
||||
// (与 IMAP 会话共用同一份实现)。
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/store"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func newMailTestHandler(t *testing.T) (*MailHandler, *store.Stores) {
|
||||
t.Helper()
|
||||
gdb, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "test.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.Mailbox{}, &db.MailboxState{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stores := store.NewStores(gdb)
|
||||
if err := stores.Users.Create(&db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}, DomainID: 1}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return NewMailHandler(stores, nil, nil, imap_server.NewMailboxService(stores), nil), stores
|
||||
}
|
||||
|
||||
// newMailTestRouter 注册删除/恢复/清空路由并注入认证上下文。
|
||||
func newMailTestRouter(h *MailHandler) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
// Folder 页渲染需要模板(与 oauth2 测试共用同一份测试函数表)
|
||||
tmpl := template.Must(template.New("").Funcs(testTemplateFuncs()).ParseGlob(filepath.Join("..", "templates", "*.html")))
|
||||
r.SetHTMLTemplate(tmpl)
|
||||
r.Use(func(c *gin.Context) {
|
||||
c.Set("userID", uint(1))
|
||||
c.Set("currentUser", &db.User{ID: 1, Username: "alice", Domain: db.Domain{Name: "example.com"}})
|
||||
c.Next()
|
||||
})
|
||||
r.POST("/mail/delete/:id", h.Delete)
|
||||
r.POST("/mail/restore/:id", h.Restore)
|
||||
r.POST("/mail/purge/:id", h.Purge)
|
||||
r.POST("/folder/:name/empty", h.EmptyFolder)
|
||||
r.GET("/folder/:name", h.Folder)
|
||||
return r
|
||||
}
|
||||
|
||||
func seedWebMsg(t *testing.T, stores *store.Stores, folder string) *db.Message {
|
||||
t.Helper()
|
||||
msg := &db.Message{
|
||||
UserID: 1,
|
||||
Folder: folder,
|
||||
FromAddr: "sender@other.com",
|
||||
ToAddr: "alice@example.com",
|
||||
Subject: "测试邮件",
|
||||
Date: time.Now(),
|
||||
}
|
||||
if err := stores.Mails.Create(msg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
|
||||
func msgFolder(t *testing.T, stores *store.Stores, id uint) (string, bool) {
|
||||
t.Helper()
|
||||
msg, err := stores.Mails.GetByID(id)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return msg.Folder, true
|
||||
}
|
||||
|
||||
func TestWebDeleteMovesToTrash(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
msg := seedWebMsg(t, stores, "INBOX")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
// 收件箱删除 → 移入垃圾箱(非物理删除)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||||
req.Header.Set("Referer", "/folder/INBOX")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("delete status = %d, want 302", w.Code)
|
||||
}
|
||||
folder, ok := msgFolder(t, stores, msg.ID)
|
||||
if !ok || folder != "Trash" {
|
||||
t.Fatalf("deleted msg folder = %q, want Trash", folder)
|
||||
}
|
||||
|
||||
// 垃圾箱中再删除 → 彻底删除
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/mail/delete/"+itoa(msg.ID), nil)
|
||||
req2.Header.Set("Referer", "/folder/Trash")
|
||||
r.ServeHTTP(w2, req2)
|
||||
if _, ok := msgFolder(t, stores, msg.ID); ok {
|
||||
t.Fatal("trash delete should purge the message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebRestoreToInbox(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
msg := seedWebMsg(t, stores, "Trash")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/mail/restore/"+itoa(msg.ID), nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("restore status = %d, want 302", w.Code)
|
||||
}
|
||||
folder, ok := msgFolder(t, stores, msg.ID)
|
||||
if !ok || folder != "INBOX" {
|
||||
t.Fatalf("restored msg folder = %q, want INBOX", folder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebEmptyFolderPurgesAll(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
seedWebMsg(t, stores, "Trash")
|
||||
seedWebMsg(t, stores, "Trash")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/folder/Trash/empty", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusFound {
|
||||
t.Fatalf("empty status = %d, want 302", w.Code)
|
||||
}
|
||||
count, err := stores.Mails.CountByUserAndFolder(1, "Trash")
|
||||
if err != nil || count != 0 {
|
||||
t.Fatalf("Trash count after empty = %d, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebFolderPageListsDynamicFolders(t *testing.T) {
|
||||
h, stores := newMailTestHandler(t)
|
||||
seedWebMsg(t, stores, "INBOX")
|
||||
r := newMailTestRouter(h)
|
||||
|
||||
// IMAP CREATE 语义创建的自定义文件夹(经同一 MailboxService)
|
||||
svc := imap_server.NewMailboxService(stores)
|
||||
if err := svc.Create(1, "工作"); err != nil {
|
||||
t.Fatalf("create custom mailbox: %v", err)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/folder/工作", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("custom folder page status = %d, want 200", w.Code)
|
||||
}
|
||||
|
||||
// 不存在的文件夹 → 404
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodGet, "/folder/不存在", nil)
|
||||
r.ServeHTTP(w2, req2)
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("missing folder status = %d, want 404", w2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n uint) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -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,8 +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(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(lang, s string) string { return s },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
+23
-15
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"mail_go/internal/db"
|
||||
"mail_go/internal/imap_server"
|
||||
)
|
||||
|
||||
func TestRenderAllPages(t *testing.T) {
|
||||
@@ -46,29 +47,36 @@ func TestRenderAllPages(t *testing.T) {
|
||||
{ID: 2, FileName: "logo.png", FileSize: 128 * 1024},
|
||||
}
|
||||
|
||||
folders := []imap_server.FolderInfo{
|
||||
{Name: "INBOX", SpecialUse: "", Unseen: 2},
|
||||
{Name: "Sent", SpecialUse: "Sent", Total: 3},
|
||||
{Name: "Drafts", SpecialUse: "Drafts", Total: 1},
|
||||
{Name: "Trash", SpecialUse: "Trash", Total: 5},
|
||||
{Name: "工作", SpecialUse: "", Total: 4},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
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)}}},
|
||||
{"inbox", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "activeFolder": "inbox", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
|
||||
{"drafts", ginH{"currentUser": user, "messages": messages, "total": 1, "page": 1, "totalPages": 1, "activeFolder": "drafts", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
|
||||
{"sent", ginH{"currentUser": user, "messages": messages, "total": 3, "page": 1, "totalPages": 1, "activeFolder": "sent", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
|
||||
{"view", ginH{
|
||||
"currentUser": user, "activeFolder": "inbox",
|
||||
{"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, "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3),
|
||||
"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),
|
||||
"inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3),
|
||||
"folders": folders,
|
||||
}},
|
||||
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "inboxUnread": int64(2), "draftsTotal": int64(1), "sentTotal": int64(3)}},
|
||||
{"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
|
||||
@@ -80,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},
|
||||
@@ -101,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
|
||||
|
||||
+117
-18
@@ -4,18 +4,21 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"mail_go/config"
|
||||
"mail_go/internal/connhub"
|
||||
"mail_go/internal/i18n"
|
||||
"mail_go/internal/imap_server"
|
||||
"mail_go/internal/mailutil"
|
||||
"mail_go/internal/outbound"
|
||||
@@ -55,6 +58,8 @@ type WebServer struct {
|
||||
caddyDataDir string
|
||||
outbound *outbound.Manager
|
||||
hub *connhub.Hub
|
||||
// staticFS 内嵌的静态资源(/static 路由),二进制自包含
|
||||
staticFS http.FileSystem
|
||||
// pusher 邮件状态变化推送(IMAP 客户端实时同步),可空
|
||||
pusher imap_server.Pusher
|
||||
}
|
||||
@@ -80,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 注入。
|
||||
@@ -104,12 +124,41 @@ func templateFuncs() template.FuncMap {
|
||||
"initial": initial,
|
||||
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
|
||||
"truncate": truncate,
|
||||
// shortDate 按 QQ 邮箱习惯格式化:今天显示 HH:mm,今年显示 MM-DD,更早显示 YYYY-MM-DD。
|
||||
// shortDate 邮件列表时间:按界面语言输出 12 小时制。
|
||||
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
|
||||
// 更早显示「2026-08-20 下午 2:35」。
|
||||
"shortDate": shortDate,
|
||||
// time12 完整时间(含秒),先转换为 Web 时区。
|
||||
"time12": time12,
|
||||
// time12m 同上但不含秒。
|
||||
"time12m": time12m,
|
||||
// localTime 把存储的 UTC 时间转换为 Web 配置时区(默认 Asia/Shanghai)。
|
||||
"localTime": localTime,
|
||||
// avatarStyle 根据字符串哈希生成头像背景/前景色。
|
||||
"avatarStyle": avatarStyle,
|
||||
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
|
||||
"urlPath": url.PathEscape,
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,19 +207,50 @@ func truncate(s string, n int) string {
|
||||
return string(r[:n]) + "…"
|
||||
}
|
||||
|
||||
// shortDate formats a time like QQ Mail does: today -> HH:mm,
|
||||
// this year -> MM-DD, otherwise -> YYYY-MM-DD.
|
||||
// 时间先按 Web 配置时区转换(库内为 UTC),"今天"判断也使用该时区。
|
||||
func shortDate(t time.Time) string {
|
||||
// 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 = i18n.T(lang, "下午")
|
||||
}
|
||||
h := t.Hour() % 12
|
||||
if h == 0 {
|
||||
h = 12
|
||||
}
|
||||
return period, h
|
||||
}
|
||||
|
||||
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
|
||||
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
|
||||
// 更早 → 「2026-08-20 下午 2:35」。
|
||||
func shortDate(lang string, t time.Time) string {
|
||||
t = inWebTZ(t)
|
||||
now := time.Now().In(t.Location())
|
||||
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 t.Format("15:04")
|
||||
return clock
|
||||
}
|
||||
if t.Year() == now.Year() {
|
||||
return t.Format("01-02")
|
||||
return fmt.Sprintf("%s %s", t.Format("01-02"), clock)
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
return fmt.Sprintf("%s %s", t.Format("2006-01-02"), clock)
|
||||
}
|
||||
|
||||
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
|
||||
// 「2026-08-20 下午 2:35:05」。
|
||||
func time12(lang string, t time.Time) string {
|
||||
t = inWebTZ(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(lang string, t time.Time) string {
|
||||
t = inWebTZ(t)
|
||||
period, h := periodOf(lang, t)
|
||||
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
|
||||
}
|
||||
|
||||
// webTZ 是 Web 界面显示时间使用的时区(默认 Asia/Shanghai)。
|
||||
@@ -273,6 +353,12 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
template.Must(tmpl.ParseGlob("internal/web/templates/admin/*.html"))
|
||||
engine.SetHTMLTemplate(tmpl)
|
||||
|
||||
// 内嵌静态资源根目录(/static 路由数据源)
|
||||
staticSub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载内嵌静态资源失败: %w", err)
|
||||
}
|
||||
|
||||
ws := &WebServer{
|
||||
engine: engine,
|
||||
stores: stores,
|
||||
@@ -284,6 +370,7 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
caddyDataDir: caddyCfg.DataDir,
|
||||
outbound: ob,
|
||||
hub: hub,
|
||||
staticFS: http.FS(staticSub),
|
||||
pusher: pusher,
|
||||
}
|
||||
|
||||
@@ -294,14 +381,19 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||
func (ws *WebServer) registerRoutes() {
|
||||
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, ws.pusher)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub)
|
||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage, ws.outbound, imap_server.NewMailboxService(ws.stores), ws.pusher)
|
||||
adminHandler := handlers.NewAdminHandler(ws.stores, ws.storage, filepath.Join(ws.storageCfg.BaseDir, "tls", "domains"), ws.caddyDataDir, ws.outbound, ws.cfg.ProtocolLogKeepDays, ws.hub, webTZ)
|
||||
|
||||
// Apply BanMiddleware globally before public routes
|
||||
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||
// Security headers on every response
|
||||
ws.engine.Use(middleware.SecurityHeaders())
|
||||
|
||||
// 静态资源(本地化的 Quill 编辑器等第三方前端库)。
|
||||
// 内嵌于二进制:同源加载以满足 CSP script-src/style-src 'self',
|
||||
// 且不依赖部署目录(install.sh 只复制二进制 + templates)。
|
||||
ws.engine.StaticFS("/static", ws.staticFS)
|
||||
|
||||
// Public routes (no auth required)
|
||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||
ws.engine.POST("/login", authHandler.DoLogin)
|
||||
@@ -318,20 +410,27 @@ func (ws *WebServer) registerRoutes() {
|
||||
c.Redirect(302, "/inbox")
|
||||
})
|
||||
|
||||
// Mail routes
|
||||
auth.GET("/inbox", mailHandler.Inbox)
|
||||
auth.GET("/inbox/:id", mailHandler.View)
|
||||
// Mail routes:通用文件夹页(文件夹目录与 IMAP LIST 同源)
|
||||
auth.GET("/folder/:name", mailHandler.Folder)
|
||||
auth.GET("/folder/:name/:id", mailHandler.View)
|
||||
auth.POST("/folder/:name/empty", mailHandler.EmptyFolder)
|
||||
auth.GET("/compose", mailHandler.Compose)
|
||||
auth.POST("/compose", mailHandler.DoSend)
|
||||
auth.GET("/drafts", mailHandler.Drafts)
|
||||
auth.GET("/drafts/:id", mailHandler.View)
|
||||
auth.GET("/sent", mailHandler.Sent)
|
||||
auth.GET("/sent/:id", mailHandler.View)
|
||||
auth.GET("/settings", mailHandler.Settings)
|
||||
auth.POST("/settings", mailHandler.UpdateSettings)
|
||||
auth.POST("/mail/delete/:id", mailHandler.Delete)
|
||||
auth.POST("/mail/restore/:id", mailHandler.Restore)
|
||||
auth.POST("/mail/purge/:id", mailHandler.Purge)
|
||||
auth.POST("/mail/read/:id", mailHandler.MarkRead)
|
||||
auth.GET("/attachment/:id", mailHandler.DownloadAttachment)
|
||||
|
||||
// 旧路径兼容重定向(登录跳转、书签、外部链接仍指向 /inbox 等)
|
||||
auth.GET("/inbox", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX") })
|
||||
auth.GET("/inbox/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/INBOX/"+c.Param("id")) })
|
||||
auth.GET("/sent", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent") })
|
||||
auth.GET("/sent/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Sent/"+c.Param("id")) })
|
||||
auth.GET("/drafts", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts") })
|
||||
auth.GET("/drafts/:id", func(c *gin.Context) { c.Redirect(http.StatusFound, "/folder/Drafts/"+c.Param("id")) })
|
||||
}
|
||||
|
||||
// Admin routes (auth + admin required)
|
||||
|
||||
@@ -41,7 +41,7 @@ func newTestStores(t *testing.T) *store.Stores {
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}); err != nil {
|
||||
if err := gdb.AutoMigrate(&db.User{}, &db.Domain{}, &db.Message{}, &db.Attachment{}, &db.BanEntry{}, &db.OutboundMessage{}, &db.Mailbox{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return store.NewStores(gdb)
|
||||
@@ -122,7 +122,7 @@ func TestSessionSignedWithConfiguredSecretKey(t *testing.T) {
|
||||
}
|
||||
|
||||
// 合法会话可以访问收件箱
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: sessionCookie})
|
||||
resp2, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -151,7 +151,7 @@ func TestLegacyHardcodedKeyCannotForgeSession(t *testing.T) {
|
||||
t.Fatalf("forge cookie: %v", err)
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: forged})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -229,7 +229,7 @@ func TestSessionAbsoluteExpiryForcesRelogin(t *testing.T) {
|
||||
expired := time.Now().Add(-8 * 24 * time.Hour).Unix()
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, expired))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
@@ -254,7 +254,7 @@ func TestSessionWithinExpiryWorks(t *testing.T) {
|
||||
|
||||
cookie := encodeSessionCookie(t, key, authCookieValues(1, time.Now().Add(-time.Hour).Unix()))
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/inbox", nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL + "/folder/INBOX", nil)
|
||||
req.AddCookie(&http.Cookie{Name: "mail_go_session", Value: cookie})
|
||||
client := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
|
||||
File diff suppressed because one or more lines are too long
+945
@@ -0,0 +1,945 @@
|
||||
/*!
|
||||
* Quill Editor v1.3.7
|
||||
* https://quilljs.com/
|
||||
* Copyright (c) 2014, Jason Chen
|
||||
* Copyright (c) 2013, salesforce.com
|
||||
*/
|
||||
.ql-container {
|
||||
box-sizing: border-box;
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
height: 100%;
|
||||
margin: 0px;
|
||||
position: relative;
|
||||
}
|
||||
.ql-container.ql-disabled .ql-tooltip {
|
||||
visibility: hidden;
|
||||
}
|
||||
.ql-container.ql-disabled .ql-editor ul[data-checked] > li::before {
|
||||
pointer-events: none;
|
||||
}
|
||||
.ql-clipboard {
|
||||
left: -100000px;
|
||||
height: 1px;
|
||||
overflow-y: hidden;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
}
|
||||
.ql-clipboard p {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.ql-editor {
|
||||
box-sizing: border-box;
|
||||
line-height: 1.42;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
overflow-y: auto;
|
||||
padding: 12px 15px;
|
||||
tab-size: 4;
|
||||
-moz-tab-size: 4;
|
||||
text-align: left;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
.ql-editor > * {
|
||||
cursor: text;
|
||||
}
|
||||
.ql-editor p,
|
||||
.ql-editor ol,
|
||||
.ql-editor ul,
|
||||
.ql-editor pre,
|
||||
.ql-editor blockquote,
|
||||
.ql-editor h1,
|
||||
.ql-editor h2,
|
||||
.ql-editor h3,
|
||||
.ql-editor h4,
|
||||
.ql-editor h5,
|
||||
.ql-editor h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol,
|
||||
.ql-editor ul {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.ql-editor ol > li,
|
||||
.ql-editor ul > li {
|
||||
list-style-type: none;
|
||||
}
|
||||
.ql-editor ul > li::before {
|
||||
content: '\2022';
|
||||
}
|
||||
.ql-editor ul[data-checked=true],
|
||||
.ql-editor ul[data-checked=false] {
|
||||
pointer-events: none;
|
||||
}
|
||||
.ql-editor ul[data-checked=true] > li *,
|
||||
.ql-editor ul[data-checked=false] > li * {
|
||||
pointer-events: all;
|
||||
}
|
||||
.ql-editor ul[data-checked=true] > li::before,
|
||||
.ql-editor ul[data-checked=false] > li::before {
|
||||
color: #777;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
}
|
||||
.ql-editor ul[data-checked=true] > li::before {
|
||||
content: '\2611';
|
||||
}
|
||||
.ql-editor ul[data-checked=false] > li::before {
|
||||
content: '\2610';
|
||||
}
|
||||
.ql-editor li::before {
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
width: 1.2em;
|
||||
}
|
||||
.ql-editor li:not(.ql-direction-rtl)::before {
|
||||
margin-left: -1.5em;
|
||||
margin-right: 0.3em;
|
||||
text-align: right;
|
||||
}
|
||||
.ql-editor li.ql-direction-rtl::before {
|
||||
margin-left: 0.3em;
|
||||
margin-right: -1.5em;
|
||||
}
|
||||
.ql-editor ol li:not(.ql-direction-rtl),
|
||||
.ql-editor ul li:not(.ql-direction-rtl) {
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
.ql-editor ol li.ql-direction-rtl,
|
||||
.ql-editor ul li.ql-direction-rtl {
|
||||
padding-right: 1.5em;
|
||||
}
|
||||
.ql-editor ol li {
|
||||
counter-reset: list-1 list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
|
||||
counter-increment: list-0;
|
||||
}
|
||||
.ql-editor ol li:before {
|
||||
content: counter(list-0, decimal) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-1 {
|
||||
counter-increment: list-1;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-1:before {
|
||||
content: counter(list-1, lower-alpha) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-1 {
|
||||
counter-reset: list-2 list-3 list-4 list-5 list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-2 {
|
||||
counter-increment: list-2;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-2:before {
|
||||
content: counter(list-2, lower-roman) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-2 {
|
||||
counter-reset: list-3 list-4 list-5 list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-3 {
|
||||
counter-increment: list-3;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-3:before {
|
||||
content: counter(list-3, decimal) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-3 {
|
||||
counter-reset: list-4 list-5 list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-4 {
|
||||
counter-increment: list-4;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-4:before {
|
||||
content: counter(list-4, lower-alpha) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-4 {
|
||||
counter-reset: list-5 list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-5 {
|
||||
counter-increment: list-5;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-5:before {
|
||||
content: counter(list-5, lower-roman) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-5 {
|
||||
counter-reset: list-6 list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-6 {
|
||||
counter-increment: list-6;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-6:before {
|
||||
content: counter(list-6, decimal) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-6 {
|
||||
counter-reset: list-7 list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-7 {
|
||||
counter-increment: list-7;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-7:before {
|
||||
content: counter(list-7, lower-alpha) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-7 {
|
||||
counter-reset: list-8 list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-8 {
|
||||
counter-increment: list-8;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-8:before {
|
||||
content: counter(list-8, lower-roman) '. ';
|
||||
}
|
||||
.ql-editor ol li.ql-indent-8 {
|
||||
counter-reset: list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-9 {
|
||||
counter-increment: list-9;
|
||||
}
|
||||
.ql-editor ol li.ql-indent-9:before {
|
||||
content: counter(list-9, decimal) '. ';
|
||||
}
|
||||
.ql-editor .ql-indent-1:not(.ql-direction-rtl) {
|
||||
padding-left: 3em;
|
||||
}
|
||||
.ql-editor li.ql-indent-1:not(.ql-direction-rtl) {
|
||||
padding-left: 4.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-1.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 3em;
|
||||
}
|
||||
.ql-editor li.ql-indent-1.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 4.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-2:not(.ql-direction-rtl) {
|
||||
padding-left: 6em;
|
||||
}
|
||||
.ql-editor li.ql-indent-2:not(.ql-direction-rtl) {
|
||||
padding-left: 7.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-2.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 6em;
|
||||
}
|
||||
.ql-editor li.ql-indent-2.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 7.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-3:not(.ql-direction-rtl) {
|
||||
padding-left: 9em;
|
||||
}
|
||||
.ql-editor li.ql-indent-3:not(.ql-direction-rtl) {
|
||||
padding-left: 10.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-3.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 9em;
|
||||
}
|
||||
.ql-editor li.ql-indent-3.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 10.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-4:not(.ql-direction-rtl) {
|
||||
padding-left: 12em;
|
||||
}
|
||||
.ql-editor li.ql-indent-4:not(.ql-direction-rtl) {
|
||||
padding-left: 13.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-4.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 12em;
|
||||
}
|
||||
.ql-editor li.ql-indent-4.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 13.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-5:not(.ql-direction-rtl) {
|
||||
padding-left: 15em;
|
||||
}
|
||||
.ql-editor li.ql-indent-5:not(.ql-direction-rtl) {
|
||||
padding-left: 16.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-5.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 15em;
|
||||
}
|
||||
.ql-editor li.ql-indent-5.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 16.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-6:not(.ql-direction-rtl) {
|
||||
padding-left: 18em;
|
||||
}
|
||||
.ql-editor li.ql-indent-6:not(.ql-direction-rtl) {
|
||||
padding-left: 19.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-6.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 18em;
|
||||
}
|
||||
.ql-editor li.ql-indent-6.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 19.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-7:not(.ql-direction-rtl) {
|
||||
padding-left: 21em;
|
||||
}
|
||||
.ql-editor li.ql-indent-7:not(.ql-direction-rtl) {
|
||||
padding-left: 22.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-7.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 21em;
|
||||
}
|
||||
.ql-editor li.ql-indent-7.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 22.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-8:not(.ql-direction-rtl) {
|
||||
padding-left: 24em;
|
||||
}
|
||||
.ql-editor li.ql-indent-8:not(.ql-direction-rtl) {
|
||||
padding-left: 25.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-8.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 24em;
|
||||
}
|
||||
.ql-editor li.ql-indent-8.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 25.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-9:not(.ql-direction-rtl) {
|
||||
padding-left: 27em;
|
||||
}
|
||||
.ql-editor li.ql-indent-9:not(.ql-direction-rtl) {
|
||||
padding-left: 28.5em;
|
||||
}
|
||||
.ql-editor .ql-indent-9.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 27em;
|
||||
}
|
||||
.ql-editor li.ql-indent-9.ql-direction-rtl.ql-align-right {
|
||||
padding-right: 28.5em;
|
||||
}
|
||||
.ql-editor .ql-video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.ql-editor .ql-video.ql-align-center {
|
||||
margin: 0 auto;
|
||||
}
|
||||
.ql-editor .ql-video.ql-align-right {
|
||||
margin: 0 0 0 auto;
|
||||
}
|
||||
.ql-editor .ql-bg-black {
|
||||
background-color: #000;
|
||||
}
|
||||
.ql-editor .ql-bg-red {
|
||||
background-color: #e60000;
|
||||
}
|
||||
.ql-editor .ql-bg-orange {
|
||||
background-color: #f90;
|
||||
}
|
||||
.ql-editor .ql-bg-yellow {
|
||||
background-color: #ff0;
|
||||
}
|
||||
.ql-editor .ql-bg-green {
|
||||
background-color: #008a00;
|
||||
}
|
||||
.ql-editor .ql-bg-blue {
|
||||
background-color: #06c;
|
||||
}
|
||||
.ql-editor .ql-bg-purple {
|
||||
background-color: #93f;
|
||||
}
|
||||
.ql-editor .ql-color-white {
|
||||
color: #fff;
|
||||
}
|
||||
.ql-editor .ql-color-red {
|
||||
color: #e60000;
|
||||
}
|
||||
.ql-editor .ql-color-orange {
|
||||
color: #f90;
|
||||
}
|
||||
.ql-editor .ql-color-yellow {
|
||||
color: #ff0;
|
||||
}
|
||||
.ql-editor .ql-color-green {
|
||||
color: #008a00;
|
||||
}
|
||||
.ql-editor .ql-color-blue {
|
||||
color: #06c;
|
||||
}
|
||||
.ql-editor .ql-color-purple {
|
||||
color: #93f;
|
||||
}
|
||||
.ql-editor .ql-font-serif {
|
||||
font-family: Georgia, Times New Roman, serif;
|
||||
}
|
||||
.ql-editor .ql-font-monospace {
|
||||
font-family: Monaco, Courier New, monospace;
|
||||
}
|
||||
.ql-editor .ql-size-small {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.ql-editor .ql-size-large {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.ql-editor .ql-size-huge {
|
||||
font-size: 2.5em;
|
||||
}
|
||||
.ql-editor .ql-direction-rtl {
|
||||
direction: rtl;
|
||||
text-align: inherit;
|
||||
}
|
||||
.ql-editor .ql-align-center {
|
||||
text-align: center;
|
||||
}
|
||||
.ql-editor .ql-align-justify {
|
||||
text-align: justify;
|
||||
}
|
||||
.ql-editor .ql-align-right {
|
||||
text-align: right;
|
||||
}
|
||||
.ql-editor.ql-blank::before {
|
||||
color: rgba(0,0,0,0.6);
|
||||
content: attr(data-placeholder);
|
||||
font-style: italic;
|
||||
left: 15px;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
}
|
||||
.ql-snow.ql-toolbar:after,
|
||||
.ql-snow .ql-toolbar:after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
.ql-snow.ql-toolbar button,
|
||||
.ql-snow .ql-toolbar button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
float: left;
|
||||
height: 24px;
|
||||
padding: 3px 5px;
|
||||
width: 28px;
|
||||
}
|
||||
.ql-snow.ql-toolbar button svg,
|
||||
.ql-snow .ql-toolbar button svg {
|
||||
float: left;
|
||||
height: 100%;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:active:hover,
|
||||
.ql-snow .ql-toolbar button:active:hover {
|
||||
outline: none;
|
||||
}
|
||||
.ql-snow.ql-toolbar input.ql-image[type=file],
|
||||
.ql-snow .ql-toolbar input.ql-image[type=file] {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:hover,
|
||||
.ql-snow .ql-toolbar button:hover,
|
||||
.ql-snow.ql-toolbar button:focus,
|
||||
.ql-snow .ql-toolbar button:focus,
|
||||
.ql-snow.ql-toolbar button.ql-active,
|
||||
.ql-snow .ql-toolbar button.ql-active,
|
||||
.ql-snow.ql-toolbar .ql-picker-label:hover,
|
||||
.ql-snow .ql-toolbar .ql-picker-label:hover,
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active,
|
||||
.ql-snow.ql-toolbar .ql-picker-item:hover,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover,
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected {
|
||||
color: #06c;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:hover .ql-fill,
|
||||
.ql-snow .ql-toolbar button:hover .ql-fill,
|
||||
.ql-snow.ql-toolbar button:focus .ql-fill,
|
||||
.ql-snow .ql-toolbar button:focus .ql-fill,
|
||||
.ql-snow.ql-toolbar button.ql-active .ql-fill,
|
||||
.ql-snow .ql-toolbar button.ql-active .ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-fill,
|
||||
.ql-snow.ql-toolbar button:hover .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar button:hover .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar button:focus .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar button:focus .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke.ql-fill,
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke.ql-fill {
|
||||
fill: #06c;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:hover .ql-stroke,
|
||||
.ql-snow .ql-toolbar button:hover .ql-stroke,
|
||||
.ql-snow.ql-toolbar button:focus .ql-stroke,
|
||||
.ql-snow .ql-toolbar button:focus .ql-stroke,
|
||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke,
|
||||
.ql-snow .ql-toolbar button.ql-active .ql-stroke,
|
||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
|
||||
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke,
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke,
|
||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke,
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke,
|
||||
.ql-snow.ql-toolbar button:hover .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar button:hover .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar button:focus .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar button:focus .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar button.ql-active .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar button.ql-active .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar .ql-picker-label:hover .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar .ql-picker-label.ql-active .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar .ql-picker-item:hover .ql-stroke-miter,
|
||||
.ql-snow.ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected .ql-stroke-miter {
|
||||
stroke: #06c;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.ql-snow.ql-toolbar button:hover:not(.ql-active),
|
||||
.ql-snow .ql-toolbar button:hover:not(.ql-active) {
|
||||
color: #444;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill,
|
||||
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill,
|
||||
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill,
|
||||
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill {
|
||||
fill: #444;
|
||||
}
|
||||
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke,
|
||||
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke,
|
||||
.ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter,
|
||||
.ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter {
|
||||
stroke: #444;
|
||||
}
|
||||
}
|
||||
.ql-snow {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ql-snow * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ql-snow .ql-hidden {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow .ql-out-bottom,
|
||||
.ql-snow .ql-out-top {
|
||||
visibility: hidden;
|
||||
}
|
||||
.ql-snow .ql-tooltip {
|
||||
position: absolute;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
.ql-snow .ql-tooltip a {
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ql-snow .ql-tooltip.ql-flip {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
.ql-snow .ql-formats {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ql-snow .ql-formats:after {
|
||||
clear: both;
|
||||
content: '';
|
||||
display: table;
|
||||
}
|
||||
.ql-snow .ql-stroke {
|
||||
fill: none;
|
||||
stroke: #444;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.ql-snow .ql-stroke-miter {
|
||||
fill: none;
|
||||
stroke: #444;
|
||||
stroke-miterlimit: 10;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.ql-snow .ql-fill,
|
||||
.ql-snow .ql-stroke.ql-fill {
|
||||
fill: #444;
|
||||
}
|
||||
.ql-snow .ql-empty {
|
||||
fill: none;
|
||||
}
|
||||
.ql-snow .ql-even {
|
||||
fill-rule: evenodd;
|
||||
}
|
||||
.ql-snow .ql-thin,
|
||||
.ql-snow .ql-stroke.ql-thin {
|
||||
stroke-width: 1;
|
||||
}
|
||||
.ql-snow .ql-transparent {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.ql-snow .ql-direction svg:last-child {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow .ql-direction.ql-active svg:last-child {
|
||||
display: inline;
|
||||
}
|
||||
.ql-snow .ql-direction.ql-active svg:first-child {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow .ql-editor h1 {
|
||||
font-size: 2em;
|
||||
}
|
||||
.ql-snow .ql-editor h2 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.ql-snow .ql-editor h3 {
|
||||
font-size: 1.17em;
|
||||
}
|
||||
.ql-snow .ql-editor h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
.ql-snow .ql-editor h5 {
|
||||
font-size: 0.83em;
|
||||
}
|
||||
.ql-snow .ql-editor h6 {
|
||||
font-size: 0.67em;
|
||||
}
|
||||
.ql-snow .ql-editor a {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.ql-snow .ql-editor blockquote {
|
||||
border-left: 4px solid #ccc;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
padding-left: 16px;
|
||||
}
|
||||
.ql-snow .ql-editor code,
|
||||
.ql-snow .ql-editor pre {
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.ql-snow .ql-editor pre {
|
||||
white-space: pre-wrap;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 5px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
.ql-snow .ql-editor code {
|
||||
font-size: 85%;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.ql-snow .ql-editor pre.ql-syntax {
|
||||
background-color: #23241f;
|
||||
color: #f8f8f2;
|
||||
overflow: visible;
|
||||
}
|
||||
.ql-snow .ql-editor img {
|
||||
max-width: 100%;
|
||||
}
|
||||
.ql-snow .ql-picker {
|
||||
color: #444;
|
||||
display: inline-block;
|
||||
float: left;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
height: 24px;
|
||||
position: relative;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.ql-snow .ql-picker-label {
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
padding-left: 8px;
|
||||
padding-right: 2px;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.ql-snow .ql-picker-label::before {
|
||||
display: inline-block;
|
||||
line-height: 22px;
|
||||
}
|
||||
.ql-snow .ql-picker-options {
|
||||
background-color: #fff;
|
||||
display: none;
|
||||
min-width: 100%;
|
||||
padding: 4px 8px;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ql-snow .ql-picker-options .ql-picker-item {
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
padding-bottom: 5px;
|
||||
padding-top: 5px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-expanded .ql-picker-label {
|
||||
color: #ccc;
|
||||
z-index: 2;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-fill {
|
||||
fill: #ccc;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-expanded .ql-picker-label .ql-stroke {
|
||||
stroke: #ccc;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-expanded .ql-picker-options {
|
||||
display: block;
|
||||
margin-top: -1px;
|
||||
top: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
.ql-snow .ql-color-picker,
|
||||
.ql-snow .ql-icon-picker {
|
||||
width: 28px;
|
||||
}
|
||||
.ql-snow .ql-color-picker .ql-picker-label,
|
||||
.ql-snow .ql-icon-picker .ql-picker-label {
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.ql-snow .ql-color-picker .ql-picker-label svg,
|
||||
.ql-snow .ql-icon-picker .ql-picker-label svg {
|
||||
right: 4px;
|
||||
}
|
||||
.ql-snow .ql-icon-picker .ql-picker-options {
|
||||
padding: 4px 0px;
|
||||
}
|
||||
.ql-snow .ql-icon-picker .ql-picker-item {
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.ql-snow .ql-color-picker .ql-picker-options {
|
||||
padding: 3px 5px;
|
||||
width: 152px;
|
||||
}
|
||||
.ql-snow .ql-color-picker .ql-picker-item {
|
||||
border: 1px solid transparent;
|
||||
float: left;
|
||||
height: 16px;
|
||||
margin: 2px;
|
||||
padding: 0px;
|
||||
width: 16px;
|
||||
}
|
||||
.ql-snow .ql-picker:not(.ql-color-picker):not(.ql-icon-picker) svg {
|
||||
position: absolute;
|
||||
margin-top: -9px;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
width: 18px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-label]:not([data-label=''])::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-label]:not([data-label=''])::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-label]:not([data-label=''])::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-label]:not([data-label=''])::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-label]:not([data-label=''])::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-label]:not([data-label=''])::before {
|
||||
content: attr(data-label);
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header {
|
||||
width: 98px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
|
||||
content: 'Normal';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="1"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
||||
content: 'Heading 1';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="2"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
||||
content: 'Heading 2';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="3"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
||||
content: 'Heading 3';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="4"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
||||
content: 'Heading 4';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="5"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
||||
content: 'Heading 5';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value="6"]::before,
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
||||
content: 'Heading 6';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="1"]::before {
|
||||
font-size: 2em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="2"]::before {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="3"]::before {
|
||||
font-size: 1.17em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="4"]::before {
|
||||
font-size: 1em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="5"]::before {
|
||||
font-size: 0.83em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value="6"]::before {
|
||||
font-size: 0.67em;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font {
|
||||
width: 108px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
|
||||
content: 'Sans Serif';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=serif]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
|
||||
content: 'Serif';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value=monospace]::before,
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
|
||||
content: 'Monospace';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=serif]::before {
|
||||
font-family: Georgia, Times New Roman, serif;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value=monospace]::before {
|
||||
font-family: Monaco, Courier New, monospace;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size {
|
||||
width: 98px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
|
||||
content: 'Normal';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=small]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
|
||||
content: 'Small';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=large]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
|
||||
content: 'Large';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value=huge]::before,
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
|
||||
content: 'Huge';
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=small]::before {
|
||||
font-size: 10px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=large]::before {
|
||||
font-size: 18px;
|
||||
}
|
||||
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value=huge]::before {
|
||||
font-size: 32px;
|
||||
}
|
||||
.ql-snow .ql-color-picker.ql-background .ql-picker-item {
|
||||
background-color: #fff;
|
||||
}
|
||||
.ql-snow .ql-color-picker.ql-color .ql-picker-item {
|
||||
background-color: #000;
|
||||
}
|
||||
.ql-toolbar.ql-snow {
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif;
|
||||
padding: 8px;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-formats {
|
||||
margin-right: 15px;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-picker-label {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-picker-options {
|
||||
border: 1px solid transparent;
|
||||
box-shadow: rgba(0,0,0,0.2) 0 2px 8px;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-label {
|
||||
border-color: #ccc;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-picker.ql-expanded .ql-picker-options {
|
||||
border-color: #ccc;
|
||||
}
|
||||
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item.ql-selected,
|
||||
.ql-toolbar.ql-snow .ql-color-picker .ql-picker-item:hover {
|
||||
border-color: #000;
|
||||
}
|
||||
.ql-toolbar.ql-snow + .ql-container.ql-snow {
|
||||
border-top: 0px;
|
||||
}
|
||||
.ql-snow .ql-tooltip {
|
||||
background-color: #fff;
|
||||
border: 1px solid #ccc;
|
||||
box-shadow: 0px 0px 5px #ddd;
|
||||
color: #444;
|
||||
padding: 5px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ql-snow .ql-tooltip::before {
|
||||
content: "Visit URL:";
|
||||
line-height: 26px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.ql-snow .ql-tooltip input[type=text] {
|
||||
display: none;
|
||||
border: 1px solid #ccc;
|
||||
font-size: 13px;
|
||||
height: 26px;
|
||||
margin: 0px;
|
||||
padding: 3px 5px;
|
||||
width: 170px;
|
||||
}
|
||||
.ql-snow .ql-tooltip a.ql-preview {
|
||||
display: inline-block;
|
||||
max-width: 200px;
|
||||
overflow-x: hidden;
|
||||
text-overflow: ellipsis;
|
||||
vertical-align: top;
|
||||
}
|
||||
.ql-snow .ql-tooltip a.ql-action::after {
|
||||
border-right: 1px solid #ccc;
|
||||
content: 'Edit';
|
||||
margin-left: 16px;
|
||||
padding-right: 8px;
|
||||
}
|
||||
.ql-snow .ql-tooltip a.ql-remove::before {
|
||||
content: 'Remove';
|
||||
margin-left: 8px;
|
||||
}
|
||||
.ql-snow .ql-tooltip a {
|
||||
line-height: 26px;
|
||||
}
|
||||
.ql-snow .ql-tooltip.ql-editing a.ql-preview,
|
||||
.ql-snow .ql-tooltip.ql-editing a.ql-remove {
|
||||
display: none;
|
||||
}
|
||||
.ql-snow .ql-tooltip.ql-editing input[type=text] {
|
||||
display: inline-block;
|
||||
}
|
||||
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
|
||||
border-right: 0px;
|
||||
content: 'Save';
|
||||
padding-right: 0px;
|
||||
}
|
||||
.ql-snow .ql-tooltip[data-mode=link]::before {
|
||||
content: "Enter link:";
|
||||
}
|
||||
.ql-snow .ql-tooltip[data-mode=formula]::before {
|
||||
content: "Enter formula:";
|
||||
}
|
||||
.ql-snow .ql-tooltip[data-mode=video]::before {
|
||||
content: "Enter video:";
|
||||
}
|
||||
.ql-snow a {
|
||||
color: #06c;
|
||||
}
|
||||
.ql-container.ql-snow {
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package web
|
||||
|
||||
// 静态资源内嵌进二进制:install.sh 只部署二进制 + templates,
|
||||
// 此前 internal/web/static/ 未被复制导致线上 /static 全部 404。
|
||||
// go:embed 使前端资源自包含,不再依赖部署目录与工作目录。
|
||||
|
||||
import "embed"
|
||||
|
||||
// staticFS 包含 internal/web/static/ 下的全部静态资源
|
||||
// (目前为本地化的 Quill 编辑器 css/js)。
|
||||
//
|
||||
//go:embed all:static
|
||||
var staticFS embed.FS
|
||||
@@ -0,0 +1,40 @@
|
||||
package web
|
||||
|
||||
// 回归测试:写邮件页的 Quill 编辑器必须由本服务同源提供静态资源
|
||||
// (CSP script-src/style-src 'self' 会拦截外部 CDN),否则正文无法输入。
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestQuillAssetsServedLocally(t *testing.T) {
|
||||
ws, _ := newTestWebServer(t, "0123456789abcdef0123456789abcdef")
|
||||
srv := httptest.NewServer(ws.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
for _, path := range []string{
|
||||
"/static/vendor/quill/quill.min.js",
|
||||
"/static/vendor/quill/quill.snow.css",
|
||||
} {
|
||||
resp, err := http.Get(srv.URL + path)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s status = %d, want 200", path, resp.StatusCode)
|
||||
}
|
||||
if len(body) < 1000 {
|
||||
t.Fatalf("GET %s body too small (%d bytes), vendor file missing?", path, len(body))
|
||||
}
|
||||
if strings.HasPrefix(path, "/static/vendor/quill/quill.min.js") &&
|
||||
!strings.Contains(string(body), "Quill") {
|
||||
t.Fatalf("%s 不是 Quill 脚本", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}{{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}}
|
||||
|
||||
@@ -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>{{.Connected.Format "2006-01-02 15:04:05"}}</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>{{.LastActive.Format "2006-01-02 15:04:05"}}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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----- ... -----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----- ... -----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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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> {{.message.Date.Format "2006-01-02 15:04:05"}}</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>
|
||||
|
||||
@@ -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>{{.Date.Format "2006-01-02 15:04"}}</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}}
|
||||
|
||||
@@ -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")}}{{.NextAttemptAt.Format "2006-01-02 15:04"}}{{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>{{.CreatedAt.Format "2006-01-02 15:04"}}</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>
|
||||
|
||||
@@ -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;">{{.CreatedAt.Format "2006-01-02 15:04:05"}}</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</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>
|
||||
|
||||
@@ -165,7 +165,7 @@
|
||||
font-size: 12.5px; color: var(--text-3);
|
||||
}
|
||||
.cell-date {
|
||||
width: 78px; flex-shrink: 0; text-align: right;
|
||||
flex: 0 0 auto; white-space: nowrap; text-align: right;
|
||||
font-size: 12.5px; color: var(--text-3);
|
||||
}
|
||||
.mail-row.unread .cell-date { color: var(--text-2); }
|
||||
@@ -353,11 +353,12 @@
|
||||
.folder {
|
||||
flex: 1; flex-direction: column; justify-content: center; gap: 2px;
|
||||
height: 46px; padding: 0; border-radius: 8px; font-size: 10.5px;
|
||||
position: relative;
|
||||
position: relative; order: 7; /* 自定义文件夹排在最后 */
|
||||
}
|
||||
.folder-nav .folder:nth-child(1) { order: 1; }
|
||||
.folder-nav .folder:nth-child(2) { order: 2; }
|
||||
.folder-nav .folder:nth-child(3) { order: 4; }
|
||||
.folder-nav .folder:nth-child(4) { order: 6; }
|
||||
.folder svg { width: 19px; height: 19px; }
|
||||
.folder.active::before { display: none; }
|
||||
.folder .badge {
|
||||
@@ -376,7 +377,7 @@
|
||||
.compose-btn svg { width: 20px; height: 20px; }
|
||||
.sidebar-footer {
|
||||
margin: 0; border: none; padding: 0;
|
||||
flex-direction: row; gap: 2px; order: 5;
|
||||
flex-direction: row; gap: 2px; order: 8;
|
||||
}
|
||||
.mail-main { padding-bottom: calc(58px + env(safe-area-inset-bottom)); }
|
||||
|
||||
@@ -387,11 +388,11 @@
|
||||
.page-info { font-size: 12px; }
|
||||
.mail-row { padding: 0 10px; height: 60px; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; font-size: 13px; }
|
||||
.cell-from { width: 88px; font-size: 13px; }
|
||||
.cell-from { width: 76px; font-size: 13px; }
|
||||
.cell-subject-wrap { flex: 1; min-width: 0; }
|
||||
.cell-subject { font-size: 13px; }
|
||||
.cell-snippet { display: none; }
|
||||
.cell-date { width: 56px; font-size: 11.5px; }
|
||||
.cell-date { width: auto; font-size: 10.5px; }
|
||||
.row-del { opacity: 1; }
|
||||
.list-footer { padding: 8px 10px; }
|
||||
.pager { margin-left: auto; gap: 4px; }
|
||||
@@ -432,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>
|
||||
@@ -460,30 +461,34 @@
|
||||
<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">
|
||||
<a class="folder {{if eq .activeFolder `inbox`}}active{{end}}" href="/inbox">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
收件箱
|
||||
{{if .inboxUnread}}<span class="badge">{{.inboxUnread}}</span>{{end}}
|
||||
</a>
|
||||
<a class="folder {{if eq .activeFolder `drafts`}}active{{end}}" href="/drafts">
|
||||
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
草稿箱
|
||||
{{if .draftsTotal}}<span class="count">{{.draftsTotal}}</span>{{end}}
|
||||
</a>
|
||||
<a class="folder {{if eq .activeFolder `sent`}}active{{end}}" href="/sent">
|
||||
{{range .folders}}
|
||||
<a class="folder {{if eq $.activeFolder .Name}}active{{end}}" href="/folder/{{urlPath .Name}}">
|
||||
{{if eq .SpecialUse "Sent"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
|
||||
已发送
|
||||
{{if .sentTotal}}<span class="count">{{.sentTotal}}</span>{{end}}
|
||||
{{else if eq .SpecialUse "Drafts"}}
|
||||
<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="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
||||
{{else if eq .SpecialUse "Trash"}}
|
||||
<svg width="16" height="16" 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>
|
||||
{{else if eq .Name "INBOX"}}
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 16 12 14 15 10 15 8 12 2 12"/><path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
|
||||
{{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 $.Lang .Name}}
|
||||
{{if eq .Name "INBOX"}}
|
||||
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
|
||||
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
{{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>
|
||||
@@ -536,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() })
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{{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>
|
||||
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
|
||||
<title>{{t .Lang "写信"}} - MailGo</title>
|
||||
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-compose">
|
||||
@@ -16,55 +16,55 @@
|
||||
<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>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
|
||||
<script src="/static/vendor/quill/quill.min.js"></script>
|
||||
<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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
{{define "drafts"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>草稿箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<label class="check-all" title="全选/取消全选">
|
||||
<input type="checkbox" id="select-all">
|
||||
全选
|
||||
</label>
|
||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
||||
<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>
|
||||
刷新
|
||||
</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>
|
||||
删除
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📝</span>草稿箱暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
|
||||
</span>
|
||||
<span class="cell-from" title="收件人:{{.ToAddr}}">致:{{mailName .ToAddr}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
<a class="cell-subject" href="/drafts/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
||||
onsubmit="return confirm('确定要删除这封草稿吗?');">
|
||||
<button type="submit" class="icon-btn" title="删除">
|
||||
<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>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/drafts?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>
|
||||
上一页
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/drafts?page={{add .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="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,119 @@
|
||||
{{define "folder"}}
|
||||
<!DOCTYPE html>
|
||||
<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 .Lang .folder}} - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<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="{{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}}{{t $.Lang "彻底删除"}}{{else}}{{t $.Lang "删除"}}{{end}}
|
||||
</button>
|
||||
{{if .isTrash}}
|
||||
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
|
||||
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">{{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 $.Lang $.folder}} · {{t $.Lang "暂无邮件"}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
||||
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
|
||||
{{else}}
|
||||
<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="{{tf $.Lang "收件人:%s" .ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
|
||||
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
||||
{{if eq $.folder "Drafts"}}{{t $.Lang "致:"}}{{end}}{{mailName .ToAddr}}
|
||||
{{else}}
|
||||
{{mailName (decodeHeader .FromAddr)}}
|
||||
{{end}}
|
||||
</span>
|
||||
<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}}{{t $.Lang "(无主题)"}}{{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}{{t $.Lang "[HTML 邮件]"}}{{end}}
|
||||
</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('{{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('{{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>
|
||||
{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<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">{{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">{{t $.Lang "下一页"}}</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -1,88 +0,0 @@
|
||||
{{define "inbox"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>收件箱 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<label class="check-all" title="全选/取消全选">
|
||||
<input type="checkbox" id="select-all">
|
||||
全选
|
||||
</label>
|
||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
||||
<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>
|
||||
刷新
|
||||
</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>
|
||||
删除
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📭</span>收件箱暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row {{if not .IsRead}}unread{{end}}" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
|
||||
</span>
|
||||
<span class="cell-from" title="{{decodeHeader .FromAddr}}">{{mailName (decodeHeader .FromAddr)}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
|
||||
<a class="cell-subject" href="/inbox/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/inbox?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>
|
||||
上一页
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/inbox?page={{add .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="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
{{define "sent"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>已发送 - MailGo</title>
|
||||
{{template "styles" .}}
|
||||
</head>
|
||||
<body class="page-list">
|
||||
{{template "navbar" .}}
|
||||
<div class="app-body">
|
||||
{{template "sidebar" .}}
|
||||
<main class="mail-main">
|
||||
<div class="list-toolbar">
|
||||
<label class="check-all" title="全选/取消全选">
|
||||
<input type="checkbox" id="select-all">
|
||||
全选
|
||||
</label>
|
||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
||||
<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>
|
||||
刷新
|
||||
</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>
|
||||
删除
|
||||
</button>
|
||||
<div class="toolbar-spacer"></div>
|
||||
<span class="page-info">共 {{.total}} 封</span>
|
||||
</div>
|
||||
|
||||
{{if not .messages}}
|
||||
<div class="mail-list">
|
||||
<div class="empty-tip"><span class="empty-icon">📤</span>已发送暂无邮件</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<ul class="mail-list">
|
||||
{{range .messages}}
|
||||
<li class="mail-row" data-id="{{.ID}}">
|
||||
<label class="cell-check" onclick="event.stopPropagation()">
|
||||
<input type="checkbox" class="row-check" data-id="{{.ID}}">
|
||||
</label>
|
||||
<span class="cell-avatar">
|
||||
<span class="avatar" style="{{avatarStyle .ToAddr}}">{{initial (mailName .ToAddr)}}</span>
|
||||
</span>
|
||||
<span class="cell-from" title="收件人:{{.ToAddr}}">{{mailName .ToAddr}}</span>
|
||||
<span class="cell-subject-wrap">
|
||||
<a class="cell-subject" href="/sent/{{.ID}}">
|
||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
||||
</a>
|
||||
</span>
|
||||
<span class="cell-snippet">
|
||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
||||
</span>
|
||||
<span class="cell-date">{{shortDate .Date}}</span>
|
||||
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<button type="submit" class="icon-btn" title="删除">
|
||||
<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>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
|
||||
<div class="list-footer">
|
||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
||||
<div class="pager">
|
||||
{{if gt .page 1}}
|
||||
<a class="page-btn" href="/sent?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>
|
||||
上一页
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">上一页</span>
|
||||
{{end}}
|
||||
{{if lt .page .totalPages}}
|
||||
<a class="page-btn" href="/sent?page={{add .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="9 18 15 12 9 6"/></svg>
|
||||
</a>
|
||||
{{else}}
|
||||
<span class="page-btn disabled">下一页</span>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{template "listjs" .}}
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,34 +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>
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<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>
|
||||
删除
|
||||
{{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('{{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('{{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">{{(localTime .message.Date).Format "2006-01-02 15:04:05"}}</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>
|
||||
@@ -72,15 +88,31 @@
|
||||
<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>
|
||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
||||
<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>
|
||||
删除邮件
|
||||
{{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('{{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('{{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}}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package web
|
||||
|
||||
// 12 小时制时间格式化(上午/下午)回归测试:
|
||||
// time12 / time12m / shortDate 需先按 Web 时区转换,再输出中文习惯的
|
||||
// 12 小时制(「2026-08-20 下午 2:35:05」),后台页面此前直接 Format
|
||||
// 输出库内 UTC 时间(比北京时间慢 8 小时)。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// setWebTZForTest 临时把展示时区固定为 UTC+8,测试结束恢复。
|
||||
func setWebTZForTest(t *testing.T, loc *time.Location) {
|
||||
t.Helper()
|
||||
old := webTZ
|
||||
webTZ = loc
|
||||
t.Cleanup(func() { webTZ = old })
|
||||
}
|
||||
|
||||
func TestTime12Format(t *testing.T) {
|
||||
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in time.Time
|
||||
want string
|
||||
}{
|
||||
{"上午", time.Date(2026, 8, 20, 1, 5, 7, 0, time.UTC), "2026-08-20 上午 9:05:07"},
|
||||
{"正午", time.Date(2026, 8, 20, 4, 0, 0, 0, time.UTC), "2026-08-20 下午 12:00:00"},
|
||||
{"下午", time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC), "2026-08-20 下午 6:30:45"},
|
||||
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := time12("zh", tc.in); got != tc.want {
|
||||
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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("zh", in), "2026-08-20 下午 6:30"; got != want {
|
||||
t.Fatalf("time12m = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortDate12Hour(t *testing.T) {
|
||||
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
|
||||
now := time.Now().In(webTZ)
|
||||
|
||||
// 今天 → 「下午 2:35」(无日期)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
|
||||
if got, want := shortDate("zh", today), "下午 2:35"; got != want {
|
||||
t.Fatalf("shortDate(today) = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
// 今年非今天 → 「06-15 上午 9:05」(避开今天,防止午夜跨日抖动)
|
||||
day := time.Date(now.Year(), 6, 15, 9, 5, 0, 0, webTZ)
|
||||
if day.YearDay() == now.YearDay() {
|
||||
day = day.AddDate(0, 0, 1)
|
||||
}
|
||||
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("zh", older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
|
||||
t.Fatalf("shortDate(older) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
+39
-3
@@ -185,7 +185,43 @@
|
||||
2. ~~#2、#3、#4(P1)~~ 已完成 2026-08-19
|
||||
3. ~~#5-#11(P2)~~ 已完成 2026-08-19
|
||||
4. ~~#12-#16(P3)~~ 已完成 2026-08-19
|
||||
5. ~~#17(P4)、#18(P5,方案 A)~~ 已完成 2026-08-20
|
||||
|
||||
**全部安全审计项已修复完成。** 剩余建议(非代码项):
|
||||
- 部署侧:Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达、GitHub 仓库中 3 个 50MB+ 的 exe 文件建议改用 LFS 或删除
|
||||
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)
|
||||
## P4 低危:第二轮审计发现(2026-08-20,8ea4a62..37b4816)
|
||||
|
||||
### 17. 手动封禁 Create 非 upsert,与阶段性封禁体系数据错位
|
||||
|
||||
- [x] 位置:`internal/web/handlers/admin.go`(`DisconnectConnection`)、`internal/store/ban_store.go`、`internal/db/models.go`、`internal/db/db.go`
|
||||
- 现状:`f2493da` 阶段性封禁已改为"每 IP 一条记录 upsert"(`RecordAuthFailure` 内部 GetByIP + Update),但管理员"断开并封禁"仍直接 `Create`。`ip_address` 无唯一索引,当目标 IP 已有失败计数记录时会插入**第二条**记录,造成:
|
||||
- `IncrementFail` 用 `First`(默认主键升序)更新**旧行**,`GetByIP` 用 `Order("id DESC")` 返回**新行** -> 自动封禁的档位判定(BanCount)与失败计数(FailCount)读写错行;
|
||||
- `UnbanIP` 按 ID 删除一行后另一行仍在,可能出现"解封后仍被旧记录挡住/计数异常"。
|
||||
- 修复方案:
|
||||
- [x] BanStore 新增 `BanIP(ip, reason, duration)`:事务内删除该 IP 全部既有记录(兼容历史脏数据)后插入单条封禁记录,计数清零(与"管理员解封清零"语义一致);`DisconnectConnection` 改用该方法。
|
||||
- [x] `BanEntry.IPAddress` 升级为 `uniqueIndex`;`InitDB` 在 AutoMigrate 前调用 `dedupeBanEntries` 清理历史重复行(保留每 IP 最大 id,SQLite/MySQL 兼容的派生表写法),表不存在时静默。
|
||||
- [x] `IncrementFail` 原子化:SQL 侧 `fail_count + 1`,miss 时 `OnConflict DoNothing` 插入兜底并发竞态,回读计数。
|
||||
- 验证:
|
||||
- [x] 单测:已有观察记录的 IP 手动封禁后仅一条、计数清零、封禁生效(`TestBanIPUpsertSingleRow`)。
|
||||
- [x] 单测:同 IP 第二条记录被唯一约束拒绝(`TestBanEntryUniqueIndex`)。
|
||||
- [x] 并发单测(`-race`):16 协程并发 IncrementFail 计数精确无重复行(`TestIncrementFailConcurrent`)。
|
||||
- [x] db 包单测:旧表重复行清理保留最大 id、表不存在静默(`dedupe_test.go`)。
|
||||
|
||||
## P5 备注:产品权衡项(需决策后实施)
|
||||
|
||||
### 18. 阶段性封禁"前 3 次触发不封禁"降低爆破门槛
|
||||
|
||||
- [x] 位置:`internal/store/auth_guard.go`(`RecordAuthFailure`)、`internal/store/user_store.go`(`LoginExists`)、Web/LDAP/SMTP/IMAP/POP3 五处调用点
|
||||
- 现状:为防误封手机客户端(配置向导探测、裸用户名重试等),达到失败阈值记为一次触发,前 3 次**只计数不封禁**。副作用:攻击者每次触发前可"免费"尝试 `max_fail_attempts`(默认 5)次,即约 **15 次失败尝试零封禁**;第 4 次起才进入 30min -> 3h -> 3 个月 -> 半年的递增档位。长期防护足够,但自动化爆破的起步门槛降低。
|
||||
- 已实施(方案 A,2026-08-20):
|
||||
- [x] `RecordAuthFailure` 新增 `knownUser bool` 参数:用户名存在(真实用户输错)保留前 3 次宽限;用户名不存在(枚举型爆破)跳过宽限、首次触发即按第 1 档封禁,封禁原因注明"未知用户名,跳过宽限"。
|
||||
- [x] 新增 `UserStore.LoginExists(login)`(完整邮箱或裸用户名),五个失败调用点按场景传入:Web 登录查邮箱存在性;SMTP/IMAP/POP3 用登录名查;LDAP 侧存在性无法判定,保守按已知用户处理(防误封)。
|
||||
- 验证:
|
||||
- [x] A:未知用户名第 1 次触发即封(30 分钟,reason 含"未知用户名")(`TestRecordAuthFailureUnknownUserSkipsGrace`)。
|
||||
- [x] 已知用户名前 3 次触发不封、第 4 次封第 1 档(回归)(`TestRecordAuthFailureKnownUserKeepsGrace`)。
|
||||
- [x] `LoginExists` 邮箱/裸用户名/不存在/空输入矩阵(`TestLoginExists`)。
|
||||
- 决策记录:**方案 A**(按失败性质区分宽限:真实用户防误封,枚举爆破即时封禁)——在不改变正常用户体验的前提下,让针对不存在账号的字典爆破首次达到阈值即被封,兼顾误封防护与爆破门槛。
|
||||
|
||||
## 部署侧建议(非代码项)
|
||||
|
||||
- Caddy 加固(可选,应用层已加安全头)、8080 端口保持仅本机可达。
|
||||
- GitHub 仓库中 3 个 50MB+ 的 exe 文件(mailgo.exe / mail_go.exe / mailgo_qa.exe)建议改用 Git LFS 或从历史中删除。
|
||||
- 线上验证:部署新版后检查登录/收件箱/管理页、协议认证封禁、邮件远程图片加载(CSP 影响)。
|
||||
Reference in New Issue
Block a user