Compare commits
6
Commits
0576e6e348
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17d8dc1567 | ||
|
|
cca7e4f092 | ||
|
|
c881146a40 | ||
|
|
d1ffd10408 | ||
|
|
975fd34f61 | ||
|
|
5abe5c4528 |
@@ -1,174 +1,186 @@
|
|||||||
# MailGo
|
# MailGo
|
||||||
|
|
||||||
Go 语言编写的轻量级邮件系统,集成 SMTP / IMAP / POP3 协议服务和 Web 管理界面。
|
> English | [中文](README_cn.md)
|
||||||
Web 前端采用 QQ 邮箱风格的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设计。
|
|
||||||
|
|
||||||
## 功能特性
|
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 加密
|
## Features
|
||||||
- **外部投递**:认证用户可向外部邮箱(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 本地调试
|
|
||||||
|
|
||||||
## 界面预览
|
- **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
|
```bash
|
||||||
go build -o mailgo .
|
go build -o mailgo .
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动
|
### Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./mailgo
|
./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` |
|
| User mailbox | `http://localhost:8080/` |
|
||||||
| 密码 | `admin` |
|
| Admin console | `http://localhost:8080/admin` |
|
||||||
|
|
||||||
> ⚠️ 请在生产环境中立即修改默认密码。
|
|
||||||
|
|
||||||
### 访问
|
|
||||||
|
|
||||||
| 页面 | 地址 |
|
|
||||||
|------|------|
|
|
||||||
| 用户邮箱 | `http://localhost:8080/` |
|
|
||||||
| 管理后台 | `http://localhost:8080/admin` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 配置文件
|
## Configuration File
|
||||||
|
|
||||||
配置文件路径(TOML 格式):
|
Config file path (TOML format):
|
||||||
|
|
||||||
| 系统 | 路径 |
|
| System | Path |
|
||||||
|------|------|
|
|--------|------|
|
||||||
| Linux | `/etc/mail_go/mail_go.toml` |
|
| Linux | `/etc/mail_go/mail_go.toml` |
|
||||||
| Windows | `./win/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
|
```toml
|
||||||
[database]
|
[database]
|
||||||
driver = "sqlite" # sqlite | mysql
|
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]
|
[storage]
|
||||||
base_dir = "/srv/mail_go" # 数据根目录
|
base_dir = "/srv/mail_go" # data root directory
|
||||||
attach_dir = "/srv/mail_go/attachments" # 附件存储目录
|
attach_dir = "/srv/mail_go/attachments" # attachment storage directory
|
||||||
|
|
||||||
[web]
|
[web]
|
||||||
addr = ":8080" # 监听地址,支持 TCP 端口或 Unix socket
|
addr = ":8080" # listen address; TCP port or Unix socket
|
||||||
secret_key = "" # Web 会话签名密钥;留空时首次启动自动生成
|
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
|
||||||
cookie_secure = true # 会话 cookie 仅通过 HTTPS 传输(Secure 标志);
|
# forging sessions, losing it invalidates all)
|
||||||
# 仅本地 HTTP 调试时才改为 false
|
cookie_secure = true # session cookie sent over HTTPS only (Secure
|
||||||
protocol_log_keep_days = 30 # SMTP/IMAP/POP3 协议调用日志保留天数,
|
# flag); set to false only for local HTTP debug
|
||||||
# 超出后由后台任务自动清理;0 表示不清理
|
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]
|
[smtp]
|
||||||
addr = ":25" # SMTP 明文端口
|
addr = ":25" # SMTP plaintext port
|
||||||
tls_addr = ":465" # SMTPS 端口(需配置 TLS 证书)
|
tls_addr = ":465" # SMTPS port (requires TLS certs)
|
||||||
domain = "example.com" # 邮件域名
|
domain = "example.com" # mail domain
|
||||||
tls_cert = "" # TLS 证书路径(留空则不启用 TLS)
|
tls_cert = "" # TLS certificate path (empty = TLS disabled)
|
||||||
tls_key = "" # TLS 私钥路径
|
tls_key = "" # TLS private key path
|
||||||
max_message_bytes = 67108864 # 单封邮件最大 64MB
|
max_message_bytes = 67108864 # max 64MB per message
|
||||||
|
|
||||||
[imap]
|
[imap]
|
||||||
addr = ":143" # IMAP 明文端口
|
addr = ":143" # IMAP plaintext port
|
||||||
tls_addr = ":993" # IMAPS 端口
|
tls_addr = ":993" # IMAPS port
|
||||||
tls_cert = ""
|
tls_cert = ""
|
||||||
tls_key = ""
|
tls_key = ""
|
||||||
|
|
||||||
[pop3]
|
[pop3]
|
||||||
addr = ":110" # POP3 明文端口
|
addr = ":110" # POP3 plaintext port
|
||||||
tls_addr = ":995" # POP3S 端口
|
tls_addr = ":995" # POP3S port
|
||||||
tls_cert = ""
|
tls_cert = ""
|
||||||
tls_key = ""
|
tls_key = ""
|
||||||
|
|
||||||
[auth]
|
[auth]
|
||||||
oauth2_enabled = false # 是否启用 OAuth2 登录
|
oauth2_enabled = false # enable OAuth2 login
|
||||||
oauth2_provider = "" # google | github
|
oauth2_provider = "" # google | github
|
||||||
oauth2_client_id = ""
|
oauth2_client_id = ""
|
||||||
oauth2_client_secret = ""
|
oauth2_client_secret = ""
|
||||||
oauth2_redirect_url = ""
|
oauth2_redirect_url = ""
|
||||||
ldap_enabled = false # 是否启用 LDAP 登录
|
ldap_enabled = false # enable LDAP login
|
||||||
ldap_server = "" # 例: ldap://localhost:389
|
ldap_server = "" # e.g. ldap://localhost:389
|
||||||
ldap_bind_dn = "" # 例: cn=admin,dc=example,dc=com
|
ldap_bind_dn = "" # e.g. cn=admin,dc=example,dc=com
|
||||||
ldap_bind_password = ""
|
ldap_bind_password = ""
|
||||||
ldap_search_base = "" # 例: ou=users,dc=example,dc=com
|
ldap_search_base = "" # e.g. ou=users,dc=example,dc=com
|
||||||
ldap_search_filter = "" # 例: (uid=%s)
|
ldap_search_filter = "" # e.g. (uid=%s)
|
||||||
ldap_use_tls = false
|
ldap_use_tls = false
|
||||||
|
|
||||||
[ban]
|
[ban]
|
||||||
max_fail_attempts = 5 # 登录失败次数阈值
|
max_fail_attempts = 5 # login failure threshold
|
||||||
ban_duration_min = 30 # 第 1 次封禁时长(分钟);之后按档位递增:
|
ban_duration_min = 30 # 1st ban duration (minutes); then escalates:
|
||||||
# 第 2 次 3 小时 → 第 3 次 3 个月 → 第 4 次起半年(上限)
|
# 2nd 3h → 3rd 3 months → 4th+ 6 months (cap)
|
||||||
# 前 3 次达到阈值只计数不封禁,成功登录后清零
|
# first 3 threshold hits only count, no ban;
|
||||||
|
# cleared on successful login
|
||||||
|
|
||||||
[caddy]
|
[caddy]
|
||||||
data_dir = "" # Caddy 数据目录(含 certificates/ 的那个),
|
data_dir = "" # Caddy data directory (the one containing
|
||||||
# 供后台一键导入证书;留空自动探测
|
# certificates/), used for one-click cert import
|
||||||
# /var/lib/caddy/.local/share/caddy 等常见位置
|
# in the admin console; auto-detected when empty
|
||||||
|
# (e.g. /var/lib/caddy/.local/share/caddy)
|
||||||
|
|
||||||
[outbound]
|
[outbound]
|
||||||
hostname = "" # EHLO 主机名,留空使用 [smtp] domain
|
hostname = "" # EHLO hostname; defaults to [smtp] domain
|
||||||
poll_interval = 15 # 外发队列扫描间隔(秒)
|
poll_interval = 15 # outbound queue scan interval (seconds)
|
||||||
workers = 4 # 并发投递 worker 数(多线程并行发送,
|
workers = 4 # concurrent delivery workers (parallel sends,
|
||||||
# 大量邮件时吞吐提升;0/1 为串行)
|
# improves throughput for bulk mail; 0/1 serial)
|
||||||
batch_size = 50 # 每次扫描最多取出的待投递邮件数
|
batch_size = 50 # max messages picked per scan
|
||||||
max_concurrent_per_domain = 2 # 同一收件域(或中继)的最大并发连接数,
|
max_concurrent_per_domain = 2 # max concurrent connections to one recipient
|
||||||
# 防被判定为滥发;0 表示不限制
|
# domain (or relay), prevents spam-lookalike
|
||||||
max_attempts = 12 # 单封邮件最大投递尝试次数
|
# bursts; 0 = unlimited
|
||||||
retry_base_min = 5 # 重试退避基数(分钟),指数增长:5/10/20/40...
|
max_attempts = 12 # max delivery attempts per message
|
||||||
max_recipients = 50 # 单封邮件最大外部收件人数
|
retry_base_min = 5 # retry backoff base (minutes), exponential:
|
||||||
max_per_min = 30 # 每用户每分钟最大外发数
|
# 5/10/20/40...
|
||||||
max_per_day = 500 # 每用户每日最大外发数,0 表示禁用外部投递
|
max_recipients = 50 # max external recipients per message
|
||||||
connect_timeout = 30 # 连接远程 MX 超时(秒)
|
max_per_min = 30 # max outgoing messages per user per minute
|
||||||
relay_host = "" # 智能主机(smarthost),留空则直投 MX
|
max_per_day = 500 # max outgoing messages per user per day;
|
||||||
relay_port = 587 # 465 = 隐式 TLS,其他端口按需 STARTTLS
|
# 0 disables external delivery
|
||||||
relay_user = "" # 中继认证用户名(AUTH PLAIN)
|
connect_timeout = 30 # remote MX connect timeout (seconds)
|
||||||
relay_password = "" # 中继认证密码
|
relay_host = "" # smarthost; empty = direct MX delivery
|
||||||
relay_starttls = true # 非 465 端口是否使用 STARTTLS
|
relay_port = 587 # 465 = implicit TLS, other ports use STARTTLS
|
||||||
relay_tls_insecure = false # 是否跳过中继服务器 TLS 证书验证;
|
relay_user = "" # relay auth username (AUTH PLAIN)
|
||||||
# 默认验证证书(保护中继凭据),仅自签证书
|
relay_password = "" # relay auth password
|
||||||
# 内网中继且明确知晓风险时才改为 true
|
relay_starttls = true # use STARTTLS on non-465 ports
|
||||||
ip_family = "ipv4" # 出站地址族:ipv4(默认)| ipv6 | auto
|
relay_tls_insecure = false # skip relay TLS certificate verification; certs
|
||||||
source_ip = "" # 出站源地址绑定(如静态 IPv6 地址),留空由内核选择
|
# 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
|
```toml
|
||||||
[database]
|
[database]
|
||||||
@@ -176,9 +188,9 @@ driver = "mysql"
|
|||||||
dsn = "mailgo:YourPassword@tcp(127.0.0.1:3306)/mailgo?charset=utf8mb4&parseTime=True&loc=Local"
|
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
|
> ```sql
|
||||||
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
> CREATE DATABASE mailgo CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
|
> CREATE USER 'mailgo'@'localhost' IDENTIFIED BY 'YourPassword';
|
||||||
@@ -186,26 +198,26 @@ MySQL DSN 格式:`用户名:密码@tcp(主机:端口)/数据库名?参数`
|
|||||||
> FLUSH PRIVILEGES;
|
> FLUSH PRIVILEGES;
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
### 2. Web 使用 Unix Socket
|
### 2. Web over Unix Socket
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[web]
|
[web]
|
||||||
addr = "/run/mail_go/web.sock"
|
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
|
```bash
|
||||||
MAILGO_SECRET_KEY="$(openssl rand -hex 32)" mail_go
|
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
|
```nginx
|
||||||
server {
|
server {
|
||||||
@@ -222,15 +234,15 @@ server {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> 确保 socket 目录存在并有写权限:
|
> Make sure the socket directory exists and is writable:
|
||||||
> ```bash
|
> ```bash
|
||||||
> mkdir -p /run/mail_go
|
> mkdir -p /run/mail_go
|
||||||
> chown mailgo:mailgo /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
|
```toml
|
||||||
[smtp]
|
[smtp]
|
||||||
@@ -246,42 +258,42 @@ tls_cert = "/etc/mail_go/certs/server.crt"
|
|||||||
tls_key = "/etc/mail_go/certs/server.key"
|
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
|
> ```bash
|
||||||
> certbot certonly --standalone -d mail.example.com
|
> certbot certonly --standalone -d mail.example.com
|
||||||
> # 证书路径: /etc/letsencrypt/live/mail.example.com/fullchain.pem
|
> # Certificate: /etc/letsencrypt/live/mail.example.com/fullchain.pem
|
||||||
> # 私钥路径: /etc/letsencrypt/live/mail.example.com/privkey.pem
|
> # Private key: /etc/letsencrypt/live/mail.example.com/privkey.pem
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
#### 从 Caddy 一键导入证书
|
#### One-Click Certificate Import from Caddy
|
||||||
|
|
||||||
如果本机已用 [Caddy](https://caddyserver.com/) 托管该域名 HTTPS(Caddy 会自动签发并续期证书),
|
If this machine already serves HTTPS for the domain via [Caddy](https://caddyserver.com/) (Caddy issues and renews certificates automatically),
|
||||||
可在管理后台 **域名管理 → 编辑域名** 页面点击 **“从 Caddy 获取证书”** 按钮,
|
you can click **"Fetch certificate from Caddy"** on the **Domain Management → Edit Domain** page in the admin console
|
||||||
一键把 Caddy 存储中的证书与私钥导入邮件服务(自动启用该域名的 TLS),无需手动复制 PEM 文件。
|
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.
|
||||||
支持通配符证书(如 `*.example.com` 可匹配 `mail.example.com`)。
|
Wildcard certificates are supported (e.g. `*.example.com` matches `mail.example.com`).
|
||||||
证书支持**热加载**:导入(或手动上传)后立即生效,无需重启服务——SMTP/IMAP/POP3
|
Certificates support **hot reload**: they take effect immediately after import (or manual upload) without restarting the service — SMTP/IMAP/POP3
|
||||||
每次 TLS 握手会自动检查并重载变化的证书文件。
|
re-check and reload changed certificate files on every TLS handshake.
|
||||||
|
|
||||||
由于 Caddy 的证书目录仅 `caddy` 用户可读,install.sh 会安装一个 root 权限的证书同步任务
|
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}`),把 Caddy 证书树镜像到 `/srv/mail_go/tls/caddy`,
|
(`mailgo-caddy-sync.{path,timer}`) that mirrors Caddy's certificate tree to `/srv/mail_go/tls/caddy`,
|
||||||
证书续期后自动同步,mail_go 始终可读;另外还会授予 ACL 权限作为直接读取的兜底。
|
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
|
```bash
|
||||||
sudo ./install.sh setup-caddy-cert # 自动探测 Caddy 数据目录并配置同步 + ACL
|
sudo ./install.sh setup-caddy-cert # auto-detect Caddy data dir, configure sync + ACL
|
||||||
sudo ./install.sh setup-caddy-cert /path/to/caddy/data # 或手动指定数据目录
|
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
|
```toml
|
||||||
[caddy]
|
[caddy]
|
||||||
data_dir = "/var/lib/caddy/.local/share/caddy"
|
data_dir = "/var/lib/caddy/.local/share/caddy"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. 启用 OAuth2 登录(Google 示例)
|
### 5. Enabling OAuth2 Login (Google Example)
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[auth]
|
[auth]
|
||||||
@@ -292,9 +304,9 @@ oauth2_client_secret = "your-client-secret"
|
|||||||
oauth2_redirect_url = "https://mail.example.com/auth/oauth2/callback"
|
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
|
```toml
|
||||||
[auth]
|
[auth]
|
||||||
@@ -307,149 +319,164 @@ ldap_search_filter = "(uid=%s)"
|
|||||||
ldap_use_tls = true
|
ldap_use_tls = true
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. 对外发送邮件(外部投递)
|
### 7. Sending Mail to External Recipients (External Delivery)
|
||||||
|
|
||||||
认证用户(Web 邮箱 / SMTP 提交)可以向外部邮箱地址发送邮件。系统通过
|
Authenticated users (Web mail / SMTP submission) can send mail to external addresses. The system
|
||||||
收件人域名的 MX 记录直投,自动处理 STARTTLS、指数退避重试,并为外发
|
delivers directly via the recipient domain's MX records, handling STARTTLS and exponential backoff
|
||||||
邮件添加 DKIM 签名(使用后台域名管理中生成的密钥)。
|
retries automatically, and DKIM-signs outgoing mail (using the key generated in the admin domain management).
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[smtp]
|
[smtp]
|
||||||
domain = "example.com" # 必须设置为真实的邮件域名(EHLO/退信地址)
|
domain = "example.com" # must be the real mail domain (EHLO/bounce address)
|
||||||
|
|
||||||
[outbound]
|
[outbound]
|
||||||
hostname = "mail.example.com" # 建议与邮件主机名一致
|
hostname = "mail.example.com" # recommended to match the mail hostname
|
||||||
poll_interval = 15
|
poll_interval = 15
|
||||||
max_attempts = 12
|
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` |
|
| MX | `example.com MX 10 mail.example.com` |
|
||||||
| SPF | `example.com TXT "v=spf1 mx -all"` |
|
| 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"` |
|
| DMARC | `_dmarc.example.com TXT "v=DMARC1; p=none; rua=mailto:postmaster@example.com"` |
|
||||||
| PTR | 服务器 IP 反向解析指向邮件主机名(需向机房申请) |
|
| PTR | Server IP reverse-resolves to the mail hostname (request from your hosting provider) |
|
||||||
| 网络 | 服务器 25 端口出站未被云厂商封锁 |
|
| 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"`),因为很多收件方
|
> **IPv4/IPv6**: Only IPv4 outbound is used by default (`ip_family = "ipv4"`), because many recipients
|
||||||
> (如 Gmail)会拒收没有 PTR 的 IPv6 地址,而 IPv4 通常具备正反向一致的 PTR。
|
> (e.g. Gmail) reject IPv6 addresses without PTR, while IPv4 usually has matching forward/reverse PTR.
|
||||||
> 如需走 IPv6:请运营商为静态地址配置 PTR(指向 `mail.example.com`),
|
> To use IPv6: ask your provider to configure PTR for the static address (pointing to `mail.example.com`),
|
||||||
> 然后设置 `ip_family = "ipv6"` 并把 `source_ip` 绑定到该静态地址
|
> 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 等策略列表收录,
|
When the server IP is on a residential/dynamic IP range, it is often listed in policy lists such as Spamhaus PBL,
|
||||||
Microsoft(Outlook/Hotmail)等收件方会直接拒收。此时建议把外发邮件交给
|
and recipients like Microsoft (Outlook/Hotmail) reject the mail outright. In that case, it is recommended to hand
|
||||||
第三方 SMTP 中继(Mailgun / SendGrid / Amazon SES / 阿里云邮件推送等),
|
outbound mail to a third-party SMTP relay (Mailgun / SendGrid / Amazon SES / Aliyun Direct Mail, etc.),
|
||||||
在 `[outbound]` 中配置即可,所有外部投递自动改走中继:
|
configured in `[outbound]` — all external delivery automatically routes through the relay:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[outbound]
|
[outbound]
|
||||||
relay_host = "smtp.example-relay.com"
|
relay_host = "smtp.example-relay.com"
|
||||||
relay_port = 587 # 465 为隐式 TLS
|
relay_port = 587 # 465 = implicit TLS
|
||||||
relay_user = "your-api-user"
|
relay_user = "your-api-user"
|
||||||
relay_password = "your-api-key"
|
relay_password = "your-api-key"
|
||||||
relay_starttls = true
|
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 端口 | 说明 |
|
| Protocol | Plaintext port | TLS port | Description |
|
||||||
|------|---------|---------|------|
|
|----------|----------------|----------|-------------|
|
||||||
| SMTP | 25 | 465 | 邮件发送 |
|
| SMTP | 25 | 465 | Mail sending |
|
||||||
| IMAP | 143 | 993 | 邮箱同步 |
|
| IMAP | 143 | 993 | Mailbox sync |
|
||||||
| POP3 | 110 | 995 | 邮件收取 |
|
| POP3 | 110 | 995 | Mail retrieval |
|
||||||
| Web | 8080 | — | Web 界面(可配 Unix socket) |
|
| Web | 8080 | — | Web UI (Unix socket supported) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 目录结构
|
## Directory Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
mailgo/
|
mailgo/
|
||||||
├── main.go # 程序入口
|
├── main.go # program entry
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── config.go # 配置加载与合并
|
│ ├── config.go # config loading and merging
|
||||||
│ └── defaults.go # 默认常量
|
│ └── defaults.go # default constants
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── db/
|
│ ├── db/
|
||||||
│ │ ├── db.go # 数据库初始化(SQLite/MySQL)
|
│ │ ├── db.go # database initialization (SQLite/MySQL)
|
||||||
│ │ └── models.go # GORM 模型定义
|
│ │ └── models.go # GORM model definitions
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ ├── stores.go # Store 聚合器
|
│ │ ├── stores.go # Store aggregator
|
||||||
│ │ ├── user_store.go # 用户数据操作
|
│ │ ├── user_store.go # user data operations
|
||||||
│ │ ├── mail_store.go # 邮件数据操作
|
│ │ ├── mail_store.go # mail data operations
|
||||||
│ │ ├── domain_store.go # 域名数据操作
|
│ │ ├── mailbox_store.go # folder (IMAP mailbox) data operations
|
||||||
│ │ ├── attachment_store.go # 附件数据操作
|
│ │ ├── domain_store.go # domain data operations
|
||||||
│ │ ├── outbound_store.go # 外发队列数据操作
|
│ │ ├── attachment_store.go # attachment data operations
|
||||||
│ │ ├── ban_store.go # 封禁数据操作
|
│ │ ├── outbound_store.go # outbound queue data operations
|
||||||
│ │ └── protocol_log_store.go # 协议调用日志数据操作
|
│ │ ├── ban_store.go # ban data operations
|
||||||
│ ├── smtp_server/server.go # SMTP 服务
|
│ │ └── protocol_log_store.go # protocol call log data operations
|
||||||
|
│ ├── smtp_server/server.go # SMTP server
|
||||||
│ ├── outbound/
|
│ ├── outbound/
|
||||||
│ │ ├── mailer.go # MX 查询与 SMTP 出站客户端
|
│ │ ├── mailer.go # MX lookup and SMTP outbound client
|
||||||
│ │ ├── manager.go # 外发队列、重试、限速、退信
|
│ │ ├── manager.go # outbound queue, retry, rate limiting, bounces
|
||||||
│ │ └── sign.go # DKIM 签名
|
│ │ └── sign.go # DKIM signing
|
||||||
│ ├── imap_server/
|
│ ├── imap_server/
|
||||||
│ │ ├── server.go # IMAP 服务(监听器/能力/跨会话推送)
|
│ │ ├── server.go # IMAP server (listener/capabilities/cross-session push)
|
||||||
│ │ └── session.go # IMAP Session 实现(SELECT/FETCH/STORE/EXPUNGE)
|
│ │ ├── service.go # MailboxService (shared by IMAP sessions and Web)
|
||||||
│ ├── pop3_server/server.go # POP3 服务
|
│ │ └── session.go # IMAP Session implementation (SELECT/FETCH/STORE/EXPUNGE)
|
||||||
│ ├── connhub/hub.go # 协议连接注册中心(当前连接监控)
|
│ ├── pop3_server/server.go # POP3 server
|
||||||
│ ├── storage/attachment.go # 附件文件存储
|
│ ├── connhub/hub.go # protocol connection registry (live connection monitoring)
|
||||||
│ ├── dkim/keys.go # DKIM 密钥生成
|
│ ├── storage/attachment.go # attachment file storage
|
||||||
|
│ ├── dkim/keys.go # DKIM key generation
|
||||||
│ ├── auth/
|
│ ├── auth/
|
||||||
│ │ ├── provider.go # 认证接口
|
│ │ ├── provider.go # auth interface
|
||||||
│ │ ├── oauth2.go # OAuth2 认证
|
│ │ ├── oauth2.go # OAuth2 authentication
|
||||||
│ │ └── ldap.go # LDAP 认证
|
│ │ └── ldap.go # LDAP authentication
|
||||||
│ └── web/
|
│ └── web/
|
||||||
│ ├── server.go # Web 路由与模板加载
|
│ ├── server.go # web routing and template loading
|
||||||
│ ├── handlers/
|
│ ├── handlers/
|
||||||
│ │ ├── auth.go # 登录/登出/OAuth2/LDAP
|
│ │ ├── auth.go # login/logout/OAuth2/LDAP
|
||||||
│ │ ├── mail.go # 邮箱操作
|
│ │ ├── mail.go # mailbox operations
|
||||||
│ │ └── admin.go # 管理后台
|
│ │ └── admin.go # admin console
|
||||||
│ ├── middleware/
|
│ ├── middleware/
|
||||||
│ │ ├── auth.go # 会话认证
|
│ │ ├── auth.go # session authentication
|
||||||
│ │ ├── admin.go # 管理员权限
|
│ │ ├── admin.go # admin authorization
|
||||||
│ │ └── ban.go # IP 封禁检查
|
│ │ └── ban.go # IP ban check
|
||||||
│ └── templates/ # HTML 模板
|
│ └── templates/ # HTML templates
|
||||||
│ ├── *.html # 用户页面
|
│ ├── *.html # user pages
|
||||||
│ └── admin/*.html # 管理页面
|
│ └── admin/*.html # admin pages
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
### 运行时数据目录(Linux)
|
### Runtime Data Directories (Linux)
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| Path | Description |
|
||||||
|------|------|
|
|------|-------------|
|
||||||
| `/etc/mail_go/` | 配置文件 |
|
| `/etc/mail_go/` | config file |
|
||||||
| `/srv/mail_go/` | 数据库 + 附件存储 |
|
| `/srv/mail_go/` | database + attachment storage |
|
||||||
|
|
||||||
### 运行时数据目录(Windows 调试)
|
### Runtime Data Directories (Windows Debug)
|
||||||
|
|
||||||
| 路径 | 说明 |
|
| Path | Description |
|
||||||
|------|------|
|
|------|-------------|
|
||||||
| `./win/etc/mail_go/` | 配置文件 |
|
| `./win/etc/mail_go/` | config file |
|
||||||
| `./win/srv/mail_go/` | 数据库 + 附件存储 |
|
| `./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
|
```ini
|
||||||
[Unit]
|
[Unit]
|
||||||
@@ -466,7 +493,7 @@ Restart=on-failure
|
|||||||
RestartSec=5
|
RestartSec=5
|
||||||
LimitNOFILE=65536
|
LimitNOFILE=65536
|
||||||
|
|
||||||
# 安全加固
|
# Security hardening
|
||||||
NoNewPrivileges=true
|
NoNewPrivileges=true
|
||||||
ProtectSystem=strict
|
ProtectSystem=strict
|
||||||
ProtectHome=true
|
ProtectHome=true
|
||||||
@@ -476,68 +503,68 @@ ReadWritePaths=/srv/mail_go /run/mail_go /etc/mail_go
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
### 启动服务
|
### Starting the Service
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 创建系统用户
|
# Create the system user
|
||||||
sudo useradd -r -s /sbin/nologin -d /srv/mail_go mailgo
|
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
|
sudo chown -R mailgo:mailgo /srv/mail_go /etc/mail_go
|
||||||
|
|
||||||
# 启用并启动
|
# Enable and start
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable mailgo
|
sudo systemctl enable mailgo
|
||||||
sudo systemctl start mailgo
|
sudo systemctl start mailgo
|
||||||
|
|
||||||
# 查看日志
|
# View logs
|
||||||
sudo journalctl -u mailgo -f
|
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):
|
||||||
|
|
||||||
| 记录类型 | 名称 | 值 | 说明 |
|
| Type | Name | Value | Description |
|
||||||
|---------|------|-----|------|
|
|------|------|-------|-------------|
|
||||||
| A | mail | 服务器 IP | 邮件服务器地址 |
|
| A | mail | server IP | mail server address |
|
||||||
| MX | @ | mail.example.com | 邮件路由 |
|
| MX | @ | mail.example.com | mail routing |
|
||||||
| TXT | @ | `v=spf1 mx ~all` | SPF 反垃圾 |
|
| TXT | @ | `v=spf1 mx ~all` | SPF anti-spam |
|
||||||
| TXT | default._domainkey | DKIM 公钥(后台自动生成) | DKIM 签名验证 |
|
| 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 策略 |
|
| TXT | _dmarc | `v=DMARC1; p=none; rua=mailto:admin@example.com` | DMARC policy |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## Tech Stack
|
||||||
|
|
||||||
| 组件 | 技术 |
|
| Component | Technology |
|
||||||
|------|------|
|
|-----------|------------|
|
||||||
| 语言 | Go 1.25+ |
|
| Language | Go 1.25+ |
|
||||||
| Web 框架 | Gin |
|
| Web framework | Gin |
|
||||||
| 模板引擎 | html/template |
|
| Template engine | html/template |
|
||||||
| ORM | GORM |
|
| ORM | GORM |
|
||||||
| 数据库 | SQLite(默认)/ MySQL |
|
| Database | SQLite (default) / MySQL |
|
||||||
| 配置格式 | TOML |
|
| Config format | TOML |
|
||||||
| SMTP | github.com/emersion/go-smtp |
|
| SMTP | github.com/emersion/go-smtp |
|
||||||
| IMAP | github.com/emersion/go-imap/v2 |
|
| IMAP | github.com/emersion/go-imap/v2 |
|
||||||
| POP3 | 手工实现 TCP 协议 |
|
| POP3 | hand-implemented TCP protocol |
|
||||||
| 密码哈希 | golang.org/x/crypto/bcrypt |
|
| Password hashing | golang.org/x/crypto/bcrypt |
|
||||||
| 富文本 | Quill.js (CDN) |
|
| Rich text | Quill.js (embedded via go:embed, works offline) |
|
||||||
| OAuth2 | golang.org/x/oauth2 |
|
| OAuth2 | golang.org/x/oauth2 |
|
||||||
| LDAP | github.com/go-ldap/ldap/v3 |
|
| LDAP | github.com/go-ldap/ldap/v3 |
|
||||||
| DKIM | RSA 2048 自动生成 |
|
| DKIM | RSA 2048 auto-generated |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 默认账户
|
## Default Account
|
||||||
|
|
||||||
| 角色 | 邮箱 | 密码 | 默认配额 |
|
| Role | Email | Initial password | Default quota |
|
||||||
|------|------|------|---------|
|
|------|-------|------------------|---------------|
|
||||||
| 管理员 | admin@example.com | admin | 5 GB |
|
| 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
|
## 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
|
||||||
+32
-29
@@ -6,20 +6,23 @@ import (
|
|||||||
|
|
||||||
// User represents a mail user in the system.
|
// User represents a mail user in the system.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Username string `gorm:"size:64;not null" json:"username"`
|
Username string `gorm:"size:64;not null" json:"username"`
|
||||||
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
PasswordHash string `gorm:"size:255;not null" json:"-"`
|
||||||
DomainID uint `gorm:"index" json:"domain_id"`
|
DomainID uint `gorm:"index" json:"domain_id"`
|
||||||
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
|
Domain Domain `gorm:"foreignKey:DomainID" json:"domain"`
|
||||||
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
|
QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"`
|
||||||
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
UsedBytes int64 `gorm:"default:0" json:"used_bytes"`
|
||||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||||
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
IsAdmin bool `gorm:"default:false" json:"is_admin"`
|
||||||
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
|
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
|
||||||
// 用户)在首次登录后必须修改密码。
|
// 用户)在首次登录后必须修改密码。
|
||||||
MustChangePassword bool `gorm:"default:false" json:"-"`
|
MustChangePassword bool `gorm:"default:false" json:"-"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
// Language 界面语言偏好:auto(跟随浏览器 Accept-Language)| en | zh | ja。
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
// 默认 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.
|
// TableName specifies the table name for User.
|
||||||
@@ -51,20 +54,20 @@ func (Domain) TableName() string {
|
|||||||
|
|
||||||
// Message represents an email message in the system.
|
// Message represents an email message in the system.
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||||
User User `gorm:"foreignKey:UserID" json:"user"`
|
User User `gorm:"foreignKey:UserID" json:"user"`
|
||||||
MessageID string `gorm:"size:255;index" json:"message_id"`
|
MessageID string `gorm:"size:255;index" json:"message_id"`
|
||||||
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
|
Folder string `gorm:"size:64;default:INBOX;index" json:"folder"`
|
||||||
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
FromAddr string `gorm:"size:512;not null" json:"from_addr"`
|
||||||
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
ToAddr string `gorm:"size:2048;not null" json:"to_addr"`
|
||||||
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
CcAddr string `gorm:"size:2048" json:"cc_addr"`
|
||||||
Subject string `gorm:"size:1024" json:"subject"`
|
Subject string `gorm:"size:1024" json:"subject"`
|
||||||
TextBody string `gorm:"type:mediumtext" json:"text_body"`
|
TextBody string `gorm:"type:mediumtext" json:"text_body"`
|
||||||
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
|
HtmlBody string `gorm:"type:mediumtext" json:"html_body"`
|
||||||
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
RawData string `gorm:"type:mediumtext" json:"raw_data"`
|
||||||
IsRead bool `gorm:"default:false" json:"is_read"`
|
IsRead bool `gorm:"default:false" json:"is_read"`
|
||||||
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
IsFlagged bool `gorm:"default:false" json:"is_flagged"`
|
||||||
// IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入,
|
// IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入,
|
||||||
// EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话
|
// EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话
|
||||||
// (imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端
|
// (imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端
|
||||||
@@ -118,9 +121,9 @@ type BanEntry struct {
|
|||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
|
// IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用),
|
||||||
// 防止并发写入产生重复行导致计数与档位读写错位。
|
// 防止并发写入产生重复行导致计数与档位读写错位。
|
||||||
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
|
IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"`
|
||||||
Reason string `gorm:"size:255" json:"reason"`
|
Reason string `gorm:"size:255" json:"reason"`
|
||||||
FailCount int `gorm:"default:0" json:"fail_count"`
|
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||||
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
|
// BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。
|
||||||
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
|
// 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。
|
||||||
// 成功登录或管理员解封会删除记录,次数随之清零。
|
// 成功登录或管理员解封会删除记录,次数随之清零。
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,27 +159,58 @@ func TestSeqStoreServerIssued(t *testing.T) {
|
|||||||
assertReadState(t, stores, ids[2], true)
|
assertReadState(t, stores, ids[2], true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSeqStoreClientSelfNumbered 复现风险场景:客户端不信任服务器序号,
|
// TestSeqOrderArrival 验证序号按到达顺序(id ASC,最早 = seq 1)分配:
|
||||||
// 按自己的视图(日期倒序,最新在前)自行编号后发 seq 式 STORE。
|
// 与主流服务器行为一致——新邮件永远追加到末尾(seq = 新 EXISTS 数),
|
||||||
// 服务器规范排序必须与常见客户端视图一致(date DESC, id DESC),
|
// 既不位移既有邮件序号,也能被 seq 增量同步(seq 4)正确获取;
|
||||||
// 否则会把另一封邮件标为已读、目标邮件永远未读。
|
// INTERNALDATE 返回到达时间(CreatedAt)而非 Date 头。
|
||||||
func TestSeqStoreClientSelfNumbered(t *testing.T) {
|
func TestSeqOrderArrival(t *testing.T) {
|
||||||
stores, addr := startIntegrationServer(t)
|
stores, addr := startIntegrationServer(t)
|
||||||
ids := seedMailbox(t, stores, 1, 3) // 3 封,日期递增,最新的是 ids[2]
|
ids := seedMailbox(t, stores, 1, 3)
|
||||||
|
|
||||||
c := loginAndSelect(t, addr)
|
c := loginAndSelect(t, addr)
|
||||||
|
|
||||||
// 客户端按日期倒序视图:最新一封 = seq 1
|
msgs, err := c.Fetch(imap.SeqSetNum(1, 2, 3), &imap.FetchOptions{UID: true}).Collect()
|
||||||
cmd := c.Store(imap.SeqSetNum(1), &imap.StoreFlags{
|
if err != nil {
|
||||||
Op: imap.StoreFlagsAdd,
|
t.Fatalf("fetch: %v", err)
|
||||||
Flags: []imap.Flag{imap.FlagSeen},
|
}
|
||||||
}, nil)
|
seqOf := map[imap.UID]uint32{}
|
||||||
if _, err := cmd.Collect(); err != nil {
|
for _, m := range msgs {
|
||||||
t.Fatalf("store: %v", err)
|
seqOf[m.UID] = m.SeqNum
|
||||||
|
}
|
||||||
|
if seqOf[imap.UID(ids[0])] != 1 || seqOf[imap.UID(ids[1])] != 2 || seqOf[imap.UID(ids[2])] != 3 {
|
||||||
|
t.Fatalf("seq mapping = %v, want ids[0]=1 ids[1]=2 ids[2]=3", seqOf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端意图是标记最新一封(ids[2])为已读
|
// 新邮件到达(Date 头较旧):仍追加到末尾,不移位既有邮件
|
||||||
assertReadState(t, stores, ids[2], true)
|
late := &db.Message{
|
||||||
|
UserID: 1,
|
||||||
|
Folder: "INBOX",
|
||||||
|
FromAddr: "x@y",
|
||||||
|
ToAddr: "alice@example.com",
|
||||||
|
Subject: "late",
|
||||||
|
Date: time.Now().Add(-24 * time.Hour),
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := stores.Mails.Create(late); err != nil {
|
||||||
|
t.Fatalf("create late message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, err = c.Fetch(imap.SeqSetNum(4), &imap.FetchOptions{UID: true, InternalDate: true}).Collect()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetch seq 4: %v", err)
|
||||||
|
}
|
||||||
|
if len(msgs) != 1 || msgs[0].UID != imap.UID(late.ID) {
|
||||||
|
t.Fatalf("seq 4 = %v, want 新邮件 uid=%d", msgs, late.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// INTERNALDATE = 到达时间(CreatedAt),而不是 Date 头(协议格式仅到秒)
|
||||||
|
stored, err := stores.Mails.GetByID(late.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get late msg: %v", err)
|
||||||
|
}
|
||||||
|
if !msgs[0].InternalDate.Equal(stored.CreatedAt.Truncate(time.Second)) {
|
||||||
|
t.Fatalf("internaldate = %v, want CreatedAt %v", msgs[0].InternalDate, stored.CreatedAt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
// TestFetchBodyMalformedMIME 回归:消息包含无法解析的 MIME(base64 编码的
|
||||||
|
|||||||
@@ -497,7 +497,13 @@ func (s *imapSession) Fetch(w *imapserver.FetchWriter, numSet imap.NumSet, optio
|
|||||||
fw.WriteRFC822Size(int64(len(raw)))
|
fw.WriteRFC822Size(int64(len(raw)))
|
||||||
}
|
}
|
||||||
if options.InternalDate {
|
if options.InternalDate {
|
||||||
fw.WriteInternalDate(msg.Date)
|
// INTERNALDATE 是服务器接收时间(RFC 3501 §2.3.4),不是 Date
|
||||||
|
// 头;使用 CreatedAt(到达时间),旧数据为零时降级为 Date。
|
||||||
|
arrival := msg.CreatedAt
|
||||||
|
if arrival.IsZero() {
|
||||||
|
arrival = msg.Date
|
||||||
|
}
|
||||||
|
fw.WriteInternalDate(arrival)
|
||||||
}
|
}
|
||||||
if options.Envelope {
|
if options.Envelope {
|
||||||
var env *imap.Envelope
|
var env *imap.Envelope
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ type MailStore interface {
|
|||||||
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
// SetDeletedStates 批量设置多封邮件的 \Deleted 标记(单条 UPDATE ... IN)。
|
||||||
SetDeletedStates(ids []uint, deleted bool) error
|
SetDeletedStates(ids []uint, deleted bool) error
|
||||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||||
// (按 date DESC, id DESC 排序,与全量列表一致,序号映射全链路相同)。
|
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||||
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error)
|
||||||
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
// DeleteMany 批量硬删除多封邮件(单条 DELETE ... IN)。
|
||||||
DeleteMany(ids []uint) error
|
DeleteMany(ids []uint) error
|
||||||
@@ -93,7 +93,7 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si
|
|||||||
}
|
}
|
||||||
|
|
||||||
offset := (page - 1) * size
|
offset := (page - 1) * size
|
||||||
if err := query.Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
if err := query.Order("date DESC, id DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
return messages, total, nil
|
return messages, total, nil
|
||||||
@@ -145,11 +145,12 @@ func (s *mailStoreGorm) SetDeletedStates(ids []uint, deleted bool) error {
|
|||||||
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
return s.db.Model(&db.Message{}).Where("id IN ?", ids).Update("is_deleted", deleted).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件。
|
// ListDeletedByUserAndFolder 列出某文件夹中所有已标记 \Deleted 的邮件
|
||||||
|
// (按 id ASC 排序,与全量列表一致,序号映射全链路相同)。
|
||||||
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
func (s *mailStoreGorm) ListDeletedByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
if err := s.db.Where("user_id = ? AND folder = ? AND is_deleted = ?", userID, folder, true).
|
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 {
|
Order("id ASC").Find(&messages).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
@@ -180,14 +181,16 @@ func (s *mailStoreGorm) CountUnread(userID uint, folder string) (int64, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
// ListAllByUserAndFolder retrieves all messages for a user in a folder without pagination.
|
||||||
// 按 date DESC, id DESC 排序(最新在前):与主流邮件客户端(Thunderbird、
|
// 按 id ASC(到达顺序,最早在前)排序:新邮件永远获得最大序号(seq =
|
||||||
// 手机客户端等)默认视图一致,客户端自行按日期编号的 seq 式 STORE 不会
|
// EXISTS 数),与主流服务器(Dovecot/Courier)行为一致,依赖「新邮件 =
|
||||||
// 错位标错邮件。所有 IMAP 序号相关路径(Status/ListMessages/推送/seqOf)
|
// seq N+1」做增量同步的客户端不会漏收或标错邮件;新邮件到达不会使既有
|
||||||
// 共用本排序,保证序号全链路一致。
|
// 邮件序号位移(只有 EXPUNGE 才会,属正常行为)。所有 IMAP 序号相关路径
|
||||||
|
// (Status/Fetch/Search/Store/Copy/Move/Expunge/推送/seqOf)共用本排序,
|
||||||
|
// 保证序号全链路一致。INTERNALDATE 使用 CreatedAt(到达时间),与排序一致。
|
||||||
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
func (s *mailStoreGorm) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) {
|
||||||
var messages []db.Message
|
var messages []db.Message
|
||||||
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
if err := s.db.Where("user_id = ? AND folder = ?", userID, folder).
|
||||||
Order("date DESC, id DESC").Find(&messages).Error; err != nil {
|
Order("id ASC").Find(&messages).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return messages, nil
|
return messages, nil
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ type UserStore interface {
|
|||||||
ListAll(page, size int) ([]db.User, int64, error)
|
ListAll(page, size int) ([]db.User, int64, error)
|
||||||
UpdateUsedBytes(id uint, delta int64) error
|
UpdateUsedBytes(id uint, delta int64) error
|
||||||
UpdatePassword(userID uint, hashedPassword string) error
|
UpdatePassword(userID uint, hashedPassword string) error
|
||||||
|
// UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。
|
||||||
|
UpdateLanguage(userID uint, lang string) error
|
||||||
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
|
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
|
||||||
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
|
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
|
||||||
TryReserveQuota(userID uint, delta int64) (bool, error)
|
TryReserveQuota(userID uint, delta int64) (bool, error)
|
||||||
@@ -204,11 +206,22 @@ func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error)
|
|||||||
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error {
|
||||||
return s.db.Model(&db.User{}).Where("id = ?", userID).
|
return s.db.Model(&db.User{}).Where("id = ?", userID).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
"password_hash": hashedPassword,
|
"password_hash": hashedPassword,
|
||||||
"must_change_password": false,
|
"must_change_password": false,
|
||||||
}).Error
|
}).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.
|
// ListAll retrieves a paginated list of all users across all domains.
|
||||||
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
|
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
|
||||||
var users []db.User
|
var users []db.User
|
||||||
|
|||||||
+114
-106
@@ -16,6 +16,7 @@ import (
|
|||||||
"mail_go/internal/connhub"
|
"mail_go/internal/connhub"
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/dkim"
|
"mail_go/internal/dkim"
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
@@ -69,13 +70,13 @@ const manualBanDuration = 180 * 24 * time.Hour
|
|||||||
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的连接ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的连接ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
conn, ok := h.hub.Get(id)
|
conn, ok := h.hub.Get(id)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.String(http.StatusNotFound, "连接不存在或已断开")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "连接不存在或已断开"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +84,7 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
|
|||||||
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
|
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
|
||||||
// 避免与阶段性封禁的计数/档位记录错位。
|
// 避免与阶段性封禁的计数/档位记录错位。
|
||||||
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
|
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
|
||||||
c.String(http.StatusInternalServerError, "封禁失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "封禁失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_connections", gin.H{
|
c.HTML(200, "admin_connections", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"conns": conns,
|
"conns": conns,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -114,7 +115,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
|
|||||||
"pop3Count": pop3Count,
|
"pop3Count": pop3Count,
|
||||||
"now": time.Now(),
|
"now": time.Now(),
|
||||||
"activeFolder": "connections",
|
"activeFolder": "connections",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard renders the admin dashboard with summary statistics.
|
// Dashboard renders the admin dashboard with summary statistics.
|
||||||
@@ -153,7 +154,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_dashboard", gin.H{
|
c.HTML(200, "admin_dashboard", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"domainCount": domainCount,
|
"domainCount": domainCount,
|
||||||
"userCount": userCount,
|
"userCount": userCount,
|
||||||
@@ -171,7 +172,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
|
|||||||
"weekSent": weekSent,
|
"weekSent": weekSent,
|
||||||
"banCount": banCount,
|
"banCount": banCount,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "admin",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListDomains renders the domain list page.
|
// ListDomains renders the domain list page.
|
||||||
@@ -179,7 +180,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
|
|||||||
page := getPageParam(c, "page", 1)
|
page := getPageParam(c, "page", 1)
|
||||||
domains, total, err := h.stores.Domains.List(page, 20)
|
domains, total, err := h.stores.Domains.List(page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载域名列表失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +194,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
|
|||||||
totalPages = 0
|
totalPages = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "admin_domains", gin.H{
|
c.HTML(200, "admin_domains", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -201,20 +202,20 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
|
|||||||
"pageSize": 20,
|
"pageSize": 20,
|
||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDomain renders the new domain form page.
|
// NewDomain renders the new domain form page.
|
||||||
func (h *AdminHandler) NewDomain(c *gin.Context) {
|
func (h *AdminHandler) NewDomain(c *gin.Context) {
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_domain_form", gin.H{
|
c.HTML(200, "admin_domain_form", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": &db.Domain{},
|
"domain": &db.Domain{},
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDomain processes the new domain form submission.
|
// CreateDomain processes the new domain form submission.
|
||||||
@@ -227,10 +228,10 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
|
|
||||||
if name == "" {
|
if name == "" {
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"error": "请输入域名",
|
"error": i18n.T(langOf(c), "请输入域名"),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": &db.Domain{
|
"domain": &db.Domain{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -239,7 +240,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
Pop3Port: pop3Port,
|
Pop3Port: pop3Port,
|
||||||
TlsEnabled: tlsEnabled,
|
TlsEnabled: tlsEnabled,
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,13 +264,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
|
|
||||||
if err := h.stores.Domains.Create(domain); err != nil {
|
if err := h.stores.Domains.Create(domain); err != nil {
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"error": fmt.Sprintf("创建域名失败: %v", err),
|
"error": i18n.TF(langOf(c), "创建域名失败: %v", err),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,13 +281,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
func (h *AdminHandler) EditDomain(c *gin.Context) {
|
func (h *AdminHandler) EditDomain(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "域名不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,11 +298,11 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
|
|||||||
caddyMsg = c.Query("caddy_err")
|
caddyMsg = c.Query("caddy_err")
|
||||||
caddyMsgType = "error"
|
caddyMsgType = "error"
|
||||||
} else if c.Query("caddy_ok") == "1" {
|
} else if c.Query("caddy_ok") == "1" {
|
||||||
caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。"
|
caddyMsg = i18n.T(langOf(c), "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。")
|
||||||
caddyMsgType = "success"
|
caddyMsgType = "success"
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "admin_domain_form", gin.H{
|
c.HTML(200, "admin_domain_form", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"error": "",
|
"error": "",
|
||||||
@@ -311,20 +312,20 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
|
|||||||
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
|
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
|
||||||
"caddyMsg": caddyMsg,
|
"caddyMsg": caddyMsg,
|
||||||
"caddyMsgType": caddyMsgType,
|
"caddyMsgType": caddyMsgType,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDomain 处理编辑域名表单提交
|
// UpdateDomain 处理编辑域名表单提交
|
||||||
func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "域名不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +336,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
|||||||
|
|
||||||
tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key"))
|
tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key"))
|
||||||
tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert"))
|
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)
|
h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -351,7 +352,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Domains.Update(domain); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,43 +365,45 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
|||||||
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
|
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "域名不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lang := langOf(c)
|
||||||
editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID)
|
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)
|
log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg)
|
||||||
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
|
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
|
||||||
}
|
}
|
||||||
|
|
||||||
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
|
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err))
|
fail("从 Caddy 获取证书失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 保存到域名 TLS 目录(与手动上传证书的位置一致)
|
// 保存到域名 TLS 目录(与手动上传证书的位置一致)
|
||||||
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
|
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
|
||||||
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
|
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
|
||||||
fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err))
|
fail("创建 TLS 证书目录失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
certPath := filepath.Join(domainTLSDir, "cert.pem")
|
certPath := filepath.Join(domainTLSDir, "cert.pem")
|
||||||
keyPath := filepath.Join(domainTLSDir, "key.pem")
|
keyPath := filepath.Join(domainTLSDir, "key.pem")
|
||||||
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
|
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
|
||||||
fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err))
|
fail("保存 TLS 公钥证书失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
|
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
|
||||||
fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err))
|
fail("保存 TLS 私钥失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,7 +411,7 @@ func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
|
|||||||
domain.TlsKeyPath = keyPath
|
domain.TlsKeyPath = keyPath
|
||||||
domain.TlsEnabled = true
|
domain.TlsEnabled = true
|
||||||
if err := h.stores.Domains.Update(domain); err != nil {
|
if err := h.stores.Domains.Update(domain); err != nil {
|
||||||
fail(fmt.Sprintf("更新域名记录失败: %v", err))
|
fail("更新域名记录失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,7 +454,7 @@ func normalizePEM(s string) string {
|
|||||||
return s
|
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))
|
publicCert = normalizePEM(strings.TrimSpace(publicCert))
|
||||||
privateKey = normalizePEM(strings.TrimSpace(privateKey))
|
privateKey = normalizePEM(strings.TrimSpace(privateKey))
|
||||||
@@ -465,31 +468,31 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv
|
|||||||
if hasExistingCert {
|
if hasExistingCert {
|
||||||
return nil
|
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 {
|
if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if publicCert == "" || privateKey == "" {
|
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 {
|
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))
|
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
|
||||||
if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
|
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")
|
certPath := filepath.Join(domainTLSDir, "cert.pem")
|
||||||
keyPath := filepath.Join(domainTLSDir, "key.pem")
|
keyPath := filepath.Join(domainTLSDir, "key.pem")
|
||||||
if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil {
|
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 {
|
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
|
domain.TlsCertPath = certPath
|
||||||
@@ -503,7 +506,7 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
|
|||||||
tlsPublicCert = readTLSCert(domain.TlsCertPath)
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"error": message,
|
"error": message,
|
||||||
@@ -511,19 +514,19 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
|
|||||||
"domain": domain,
|
"domain": domain,
|
||||||
"tlsPublicCert": tlsPublicCert,
|
"tlsPublicCert": tlsPublicCert,
|
||||||
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
|
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDomain removes a domain by ID.
|
// DeleteDomain removes a domain by ID.
|
||||||
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
|
func (h *AdminHandler) DeleteDomain(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Domains.Delete(uint(id)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -534,24 +537,24 @@ func (h *AdminHandler) DeleteDomain(c *gin.Context) {
|
|||||||
func (h *AdminHandler) DNSHint(c *gin.Context) {
|
func (h *AdminHandler) DNSHint(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的域名ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domain, err := h.stores.Domains.GetByID(uint(id))
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "域名不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_dns_hint", gin.H{
|
c.HTML(200, "admin_dns_hint", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "domains",
|
"activeFolder": "domains",
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
|
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListUsers renders the user list page.
|
// ListUsers renders the user list page.
|
||||||
@@ -560,7 +563,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
|
|
||||||
users, total, err := h.stores.Users.ListAll(page, 20)
|
users, total, err := h.stores.Users.ListAll(page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载用户列表失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -577,7 +580,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
totalPages = 0
|
totalPages = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "admin_users", gin.H{
|
c.HTML(200, "admin_users", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"users": users,
|
"users": users,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -586,7 +589,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
"pageSize": 20,
|
"pageSize": 20,
|
||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUser renders the new user form page.
|
// NewUser renders the new user form page.
|
||||||
@@ -595,14 +598,14 @@ func (h *AdminHandler) NewUser(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_user_form", gin.H{
|
c.HTML(200, "admin_user_form", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": &db.User{},
|
"user": &db.User{},
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser processes the new user form submission.
|
// CreateUser processes the new user form submission.
|
||||||
@@ -616,10 +619,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
if username == "" || password == "" || domainID == 0 {
|
if username == "" || password == "" || domainID == 0 {
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": "请填写所有必填字段",
|
"error": i18n.T(langOf(c), "请填写所有必填字段"),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": &db.User{
|
"user": &db.User{
|
||||||
@@ -627,7 +630,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
DomainID: domainID,
|
DomainID: domainID,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -636,10 +639,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": "密码加密失败",
|
"error": i18n.T(langOf(c), "密码加密失败"),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": &db.User{
|
"user": &db.User{
|
||||||
@@ -647,7 +650,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
DomainID: domainID,
|
DomainID: domainID,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
},
|
},
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,19 +664,20 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
UsedBytes: 0,
|
UsedBytes: 0,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
|
Language: normLang(c.PostForm("language")),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Users.Create(user); err != nil {
|
if err := h.stores.Users.Create(user); err != nil {
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": fmt.Sprintf("创建用户失败: %v", err),
|
"error": i18n.TF(langOf(c), "创建用户失败: %v", err),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": user,
|
"user": user,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -684,18 +688,18 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
if currentUser.(*db.User).ID == uint(id) {
|
if currentUser.(*db.User).ID == uint(id) {
|
||||||
c.String(http.StatusBadRequest, "不能删除自己的账户")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "不能删除自己的账户"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Users.Delete(uint(id)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,40 +710,40 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
|
|||||||
func (h *AdminHandler) EditUser(c *gin.Context) {
|
func (h *AdminHandler) EditUser(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.stores.Users.GetByID(uint(id))
|
user, err := h.stores.Users.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "用户不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_user_form", gin.H{
|
c.HTML(200, "admin_user_form", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": user,
|
"user": user,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser processes the edit user form submission.
|
// UpdateUser processes the edit user form submission.
|
||||||
func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的用户ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.stores.Users.GetByID(uint(id))
|
user, err := h.stores.Users.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "用户不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +761,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
|
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
|
||||||
user.IsActive = isActive
|
user.IsActive = isActive
|
||||||
user.IsAdmin = isAdmin
|
user.IsAdmin = isAdmin
|
||||||
|
// 界面语言偏好(表单未提交时保持原值)
|
||||||
|
if lang := c.PostForm("language"); lang != "" {
|
||||||
|
user.Language = normLang(lang)
|
||||||
|
}
|
||||||
|
|
||||||
// Update password only if a new one is provided
|
// Update password only if a new one is provided
|
||||||
if password != "" {
|
if password != "" {
|
||||||
@@ -764,14 +772,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": "密码加密失败",
|
"error": i18n.T(langOf(c), "密码加密失败"),
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": user,
|
"user": user,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
user.PasswordHash = string(hashedPassword)
|
user.PasswordHash = string(hashedPassword)
|
||||||
@@ -782,14 +790,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
if err := h.stores.Users.Update(user); err != nil {
|
if err := h.stores.Users.Update(user); err != nil {
|
||||||
domains, _, _ := h.stores.Domains.List(1, 1000)
|
domains, _, _ := h.stores.Domains.List(1, 1000)
|
||||||
currentUser, _ := c.Get("currentUser")
|
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,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "users",
|
"activeFolder": "users",
|
||||||
"error": fmt.Sprintf("更新用户失败: %v", err),
|
"error": i18n.TF(langOf(c), "更新用户失败: %v", err),
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
"user": user,
|
"user": user,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -802,7 +810,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
|||||||
|
|
||||||
bans, total, err := h.stores.Bans.List(page, 20)
|
bans, total, err := h.stores.Bans.List(page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载黑名单失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -823,7 +831,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
|||||||
totalPages = 0
|
totalPages = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "admin_bans", gin.H{
|
c.HTML(200, "admin_bans", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"rows": rows,
|
"rows": rows,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -831,7 +839,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
|
|||||||
"pageSize": 20,
|
"pageSize": 20,
|
||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"activeFolder": "bans",
|
"activeFolder": "bans",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
|
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。
|
||||||
@@ -844,12 +852,12 @@ type banRow struct {
|
|||||||
func (h *AdminHandler) UnbanIP(c *gin.Context) {
|
func (h *AdminHandler) UnbanIP(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的记录ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的记录ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Bans.Delete(uint(id)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -892,7 +900,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
|
|||||||
|
|
||||||
logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter)
|
logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载协议日志失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -928,7 +936,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
|
|||||||
"to": url.QueryEscape(c.Query("to")),
|
"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,
|
"currentUser": currentUser,
|
||||||
"logs": logs,
|
"logs": logs,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -940,7 +948,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
|
|||||||
"allStats": normStats(allStats),
|
"allStats": normStats(allStats),
|
||||||
"keepDays": h.protocolLogKeepDays,
|
"keepDays": h.protocolLogKeepDays,
|
||||||
"activeFolder": "protocol-logs",
|
"activeFolder": "protocol-logs",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。
|
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。
|
||||||
@@ -978,7 +986,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -992,7 +1000,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
|
|||||||
totalPages = 0
|
totalPages = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "admin_mails", gin.H{
|
c.HTML(200, "admin_mails", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -1001,20 +1009,20 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
|
|||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
"activeFolder": "mails",
|
"activeFolder": "mails",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminViewMail renders the detail view of a specific mail for admin.
|
// AdminViewMail renders the detail view of a specific mail for admin.
|
||||||
func (h *AdminHandler) AdminViewMail(c *gin.Context) {
|
func (h *AdminHandler) AdminViewMail(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "邮件不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1023,31 +1031,31 @@ func (h *AdminHandler) AdminViewMail(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_mail_view", gin.H{
|
c.HTML(200, "admin_mail_view", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"activeFolder": "mails",
|
"activeFolder": "mails",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check).
|
// AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check).
|
||||||
func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
|
func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的附件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := h.stores.Attachments.GetByID(uint(id))
|
att, err := h.stores.Attachments.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "附件不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.storage.Read(att.FilePath)
|
data, err := h.storage.Read(att.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "读取附件失败")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1062,7 +1070,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
|
|||||||
|
|
||||||
items, total, err := h.stores.Outbound.List(page, 20, status)
|
items, total, err := h.stores.Outbound.List(page, 20, status)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载外发队列失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1088,7 +1096,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_outbound", gin.H{
|
c.HTML(200, "admin_outbound", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -1099,23 +1107,23 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
|
|||||||
"statCounts": statCounts,
|
"statCounts": statCounts,
|
||||||
"statusText": outbound.StatusText,
|
"statusText": outbound.StatusText,
|
||||||
"activeFolder": "outbound",
|
"activeFolder": "outbound",
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RetryOutbound resets an outbound queue item for immediate redelivery.
|
// RetryOutbound resets an outbound queue item for immediate redelivery.
|
||||||
func (h *AdminHandler) RetryOutbound(c *gin.Context) {
|
func (h *AdminHandler) RetryOutbound(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.outbound == nil {
|
if h.outbound == nil {
|
||||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.outbound.Retry(uint(id)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1126,16 +1134,16 @@ func (h *AdminHandler) RetryOutbound(c *gin.Context) {
|
|||||||
func (h *AdminHandler) CancelOutbound(c *gin.Context) {
|
func (h *AdminHandler) CancelOutbound(c *gin.Context) {
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的队列ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.outbound == nil {
|
if h.outbound == nil {
|
||||||
c.String(http.StatusInternalServerError, "外发服务不可用")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.outbound.Cancel(uint(id)); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) {
|
|||||||
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
|
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
|
||||||
|
|
||||||
submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交
|
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)
|
t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) {
|
|||||||
h := &AdminHandler{tlsDir: t.TempDir()}
|
h := &AdminHandler{tlsDir: t.TempDir()}
|
||||||
domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true}
|
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)
|
t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) {
|
|||||||
h := &AdminHandler{tlsDir: t.TempDir()}
|
h := &AdminHandler{tlsDir: t.TempDir()}
|
||||||
domain := writeExistingCert(t, h.tlsDir, certA, keyA)
|
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(), "必须同时填写") {
|
if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
|
||||||
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
|
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
"mail_go/internal/auth"
|
"mail_go/internal/auth"
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -37,12 +38,12 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
|||||||
c.Redirect(302, "/inbox")
|
c.Redirect(302, "/inbox")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "",
|
"error": "",
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoLogin processes the login form submission.
|
// DoLogin processes the login form submission.
|
||||||
@@ -54,7 +55,7 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
// Check if IP is banned
|
// Check if IP is banned
|
||||||
banned, entry := h.stores.Bans.IsBanned(ip)
|
banned, entry := h.stores.Bans.IsBanned(ip)
|
||||||
if banned {
|
if banned {
|
||||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,12 +63,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
|
|
||||||
if email == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "请输入邮箱和密码",
|
"error": i18n.T(langOf(c), "请输入邮箱和密码"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,17 +80,17 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
|
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
|
||||||
if banned {
|
if banned {
|
||||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := h.banCfg.MaxFailAttempts - failCount
|
remaining := h.banCfg.MaxFailAttempts - failCount
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining),
|
"error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,12 +106,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
session.Set("loginAt", time.Now().Unix())
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
// Check if IP is banned
|
// Check if IP is banned
|
||||||
banned, entry := h.stores.Bans.IsBanned(ip)
|
banned, entry := h.stores.Bans.IsBanned(ip)
|
||||||
if banned {
|
if banned {
|
||||||
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,12 +133,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
|
|
||||||
if username == "" || password == "" {
|
if username == "" || password == "" {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "请输入LDAP用户名和密码",
|
"error": i18n.T(langOf(c), "请输入LDAP用户名和密码"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,39 +154,39 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
|
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
|
||||||
if banned {
|
if banned {
|
||||||
entry, _ := h.stores.Bans.GetByIP(ip)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
remaining := h.banCfg.MaxFailAttempts - failCount
|
remaining := h.banCfg.MaxFailAttempts - failCount
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining),
|
"error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up or auto-create user by email
|
// Look up or auto-create user by email
|
||||||
user, err := h.stores.Users.GetByEmail(email)
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "LDAP 账号未接入本系统,请联系管理员",
|
"error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.IsActive {
|
if !user.IsActive {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "用户已被禁用",
|
"error": i18n.T(langOf(c), "用户已被禁用"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,12 +202,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
|||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
session.Set("loginAt", time.Now().Unix())
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +247,7 @@ func (h *AuthHandler) oauth2LoginVars() gin.H {
|
|||||||
// OAuth2Start redirects to the OAuth2 provider's authorization page.
|
// OAuth2Start redirects to the OAuth2 provider's authorization page.
|
||||||
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
||||||
if !h.authCfg.OAuth2Enabled {
|
if !h.authCfg.OAuth2Enabled {
|
||||||
c.String(http.StatusBadRequest, "OAuth2 未启用")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +255,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
|||||||
state, err := randomOAuth2State()
|
state, err := randomOAuth2State()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("生成 OAuth2 state 失败: %v", err)
|
log.Printf("生成 OAuth2 state 失败: %v", err)
|
||||||
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
|
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
|
||||||
@@ -264,7 +265,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
|||||||
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
|
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
|
||||||
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
||||||
if !h.authCfg.OAuth2Enabled {
|
if !h.authCfg.OAuth2Enabled {
|
||||||
c.String(http.StatusBadRequest, "OAuth2 未启用")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,11 +275,11 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
reqState := c.Query("state")
|
reqState := c.Query("state")
|
||||||
if cookieErr != nil || reqState == "" ||
|
if cookieErr != nil || reqState == "" ||
|
||||||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
|
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 := h.oauth2LoginVars()
|
||||||
v["error"] = "OAuth2 state 校验失败,请重新发起登录"
|
v["error"] = i18n.T(langOf(c), "OAuth2 state 校验失败,请重新发起登录")
|
||||||
return v
|
return v
|
||||||
}())
|
}()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// state 一次性使用:无论后续成败都立即失效
|
// state 一次性使用:无论后续成败都立即失效
|
||||||
@@ -286,12 +287,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
|
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
if code == "" {
|
if code == "" {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "OAuth2 授权码缺失",
|
"error": i18n.T(langOf(c), "OAuth2 授权码缺失"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,34 +300,34 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
email, err := provider.HandleCallback(code)
|
email, err := provider.HandleCallback(code)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("OAuth2 回调失败: %v", err)
|
log.Printf("OAuth2 回调失败: %v", err)
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "OAuth2 认证失败,请重试或联系管理员",
|
"error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up user by email
|
// Look up user by email
|
||||||
user, err := h.stores.Users.GetByEmail(email)
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "OAuth2 账号未接入本系统,请联系管理员",
|
"error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !user.IsActive {
|
if !user.IsActive {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "用户已被禁用",
|
"error": i18n.T(langOf(c), "用户已被禁用"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,12 +340,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
|||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
session.Set("loginAt", time.Now().Unix())
|
session.Set("loginAt", time.Now().Unix())
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", withLang(c, gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": i18n.T(langOf(c), "会话保存失败,请重试"),
|
||||||
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
"ldapEnabled": h.authCfg.LDAPEnabled,
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
"oauth2Provider": h.authCfg.OAuth2Provider,
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
}))
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/imap_server"
|
"mail_go/internal/imap_server"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
"mail_go/internal/storage"
|
"mail_go/internal/storage"
|
||||||
@@ -123,14 +124,14 @@ func (h *MailHandler) Folder(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
name, ok := h.svc.Canonical(userID, c.Param("name"))
|
name, ok := h.svc.Canonical(userID, c.Param("name"))
|
||||||
if !ok {
|
if !ok {
|
||||||
c.String(http.StatusNotFound, "邮箱不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
page := getPageParam(c, "page", 1)
|
page := getPageParam(c, "page", 1)
|
||||||
|
|
||||||
messages, total, err := h.svc.Messages(userID, name, page, 20)
|
messages, total, err := h.svc.Messages(userID, name, page, 20)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +144,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(200, "folder", gin.H{
|
c.HTML(200, "folder", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"messages": messages,
|
"messages": messages,
|
||||||
"total": total,
|
"total": total,
|
||||||
@@ -154,7 +155,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
|
|||||||
"activeFolder": name,
|
"activeFolder": name,
|
||||||
"isTrash": name == "Trash",
|
"isTrash": name == "Trash",
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// View renders the email detail page for a specific message.
|
// View renders the email detail page for a specific message.
|
||||||
@@ -162,19 +163,19 @@ func (h *MailHandler) View(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "邮件不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the message belongs to the current user
|
// Verify the message belongs to the current user
|
||||||
if msg.UserID != userID {
|
if msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,14 +192,14 @@ func (h *MailHandler) View(c *gin.Context) {
|
|||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "view", gin.H{
|
c.HTML(200, "view", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"message": msg,
|
"message": msg,
|
||||||
"attachments": attachments,
|
"attachments": attachments,
|
||||||
"activeFolder": msg.Folder,
|
"activeFolder": msg.Folder,
|
||||||
"inTrash": msg.Folder == "Trash",
|
"inTrash": msg.Folder == "Trash",
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compose renders the email composition page.
|
// Compose renders the email composition page.
|
||||||
@@ -215,7 +216,7 @@ func (h *MailHandler) Compose(c *gin.Context) {
|
|||||||
quotaBytes = user.QuotaBytes
|
quotaBytes = user.QuotaBytes
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(200, "compose", gin.H{
|
c.HTML(200, "compose", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "compose",
|
"activeFolder": "compose",
|
||||||
"error": "",
|
"error": "",
|
||||||
@@ -225,12 +226,12 @@ func (h *MailHandler) Compose(c *gin.Context) {
|
|||||||
"usedBytes": usedBytes,
|
"usedBytes": usedBytes,
|
||||||
"quotaBytes": quotaBytes,
|
"quotaBytes": quotaBytes,
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// composeData builds the shared template context for the compose page.
|
// composeData builds the shared template context for the compose page.
|
||||||
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
|
func (h *MailHandler) composeData(c *gin.Context, userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
|
||||||
return gin.H{
|
return withLang(c, gin.H{
|
||||||
"currentUser": user,
|
"currentUser": user,
|
||||||
"activeFolder": "compose",
|
"activeFolder": "compose",
|
||||||
"error": errMsg,
|
"error": errMsg,
|
||||||
@@ -241,7 +242,7 @@ func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subjec
|
|||||||
"usedBytes": user.UsedBytes,
|
"usedBytes": user.UsedBytes,
|
||||||
"quotaBytes": user.QuotaBytes,
|
"quotaBytes": user.QuotaBytes,
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// DoSend processes the email composition form, sends the email via SMTP,
|
// DoSend processes the email composition form, sends the email via SMTP,
|
||||||
@@ -258,7 +259,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
cc := c.PostForm("cc")
|
cc := c.PostForm("cc")
|
||||||
|
|
||||||
if to == "" {
|
if to == "" {
|
||||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody))
|
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "请输入收件人"), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,7 +277,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
|
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody))
|
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "配额检查失败,请稍后重试"), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !reserved {
|
if !reserved {
|
||||||
@@ -285,10 +286,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
if user != nil {
|
if user != nil {
|
||||||
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
|
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
|
||||||
}
|
}
|
||||||
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
c.HTML(http.StatusBadRequest, "compose", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "compose",
|
"activeFolder": "compose",
|
||||||
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
|
"error": i18n.TF(langOf(c), "附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
|
||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"cc": cc,
|
"cc": cc,
|
||||||
@@ -296,7 +297,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
"usedBytes": usedBytes,
|
"usedBytes": usedBytes,
|
||||||
"quotaBytes": quotaBytes,
|
"quotaBytes": quotaBytes,
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
})
|
}))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,16 +356,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
if len(externalRecipients) > 0 {
|
if len(externalRecipients) > 0 {
|
||||||
ob := h.outbound
|
ob := h.outbound
|
||||||
if ob == nil || !ob.Enabled() {
|
if ob == nil || !ob.Enabled() {
|
||||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody))
|
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "外部投递未启用"), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
|
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
|
||||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
|
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, rcpt := range externalRecipients {
|
for _, rcpt := range externalRecipients {
|
||||||
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
|
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
|
||||||
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
|
c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -386,7 +387,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
IsRead: false,
|
IsRead: false,
|
||||||
}
|
}
|
||||||
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
|
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
|
||||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
|
||||||
@@ -412,7 +413,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if createErr := h.stores.Mails.Create(msg); createErr != nil {
|
if createErr := h.stores.Mails.Create(msg); createErr != nil {
|
||||||
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,13 +580,13 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil || msg.UserID != userID {
|
if err != nil || msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userEmail := userEmailOf(c)
|
userEmail := userEmailOf(c)
|
||||||
@@ -602,7 +603,7 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
|||||||
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
|
seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
|
||||||
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
|
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
|
||||||
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
|
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
|
||||||
c.String(http.StatusInternalServerError, "删除失败")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "删除失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.pusher != nil && userEmail != "" {
|
if h.pusher != nil && userEmail != "" {
|
||||||
@@ -624,13 +625,13 @@ func (h *MailHandler) Restore(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil || msg.UserID != userID {
|
if err != nil || msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if msg.Folder != "Trash" {
|
if msg.Folder != "Trash" {
|
||||||
@@ -641,7 +642,7 @@ func (h *MailHandler) Restore(c *gin.Context) {
|
|||||||
seq := h.seqOfFolder(userID, "Trash", msg.ID)
|
seq := h.seqOfFolder(userID, "Trash", msg.ID)
|
||||||
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
|
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
|
||||||
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
|
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
|
||||||
c.String(http.StatusInternalServerError, "恢复失败")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "恢复失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if h.pusher != nil {
|
if h.pusher != nil {
|
||||||
@@ -658,13 +659,13 @@ func (h *MailHandler) Purge(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil || msg.UserID != userID {
|
if err != nil || msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -688,13 +689,13 @@ func (h *MailHandler) EmptyFolder(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
name, ok := h.svc.Canonical(userID, c.Param("name"))
|
name, ok := h.svc.Canonical(userID, c.Param("name"))
|
||||||
if !ok {
|
if !ok {
|
||||||
c.String(http.StatusNotFound, "邮箱不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
|
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err)
|
c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "清空文件夹失败: %v", err))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seqs := make([]uint32, 0, len(msgs))
|
seqs := make([]uint32, 0, len(msgs))
|
||||||
@@ -715,13 +716,13 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的邮件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg, err := h.stores.Mails.GetByID(uint(id))
|
msg, err := h.stores.Mails.GetByID(uint(id))
|
||||||
if err != nil || msg.UserID != userID {
|
if err != nil || msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,26 +753,26 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
|
|||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusBadRequest, "无效的附件ID")
|
c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
att, err := h.stores.Attachments.GetByID(uint(id))
|
att, err := h.stores.Attachments.GetByID(uint(id))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusNotFound, "附件不存在")
|
c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the message belongs to the current user
|
// Verify the message belongs to the current user
|
||||||
msg, err := h.stores.Mails.GetByID(att.MessageID)
|
msg, err := h.stores.Mails.GetByID(att.MessageID)
|
||||||
if err != nil || msg.UserID != userID {
|
if err != nil || msg.UserID != userID {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := h.storage.Read(att.FilePath)
|
data, err := h.storage.Read(att.FilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.String(http.StatusInternalServerError, "读取附件失败")
|
c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -797,65 +798,76 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
|
|||||||
func (h *MailHandler) Settings(c *gin.Context) {
|
func (h *MailHandler) Settings(c *gin.Context) {
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
c.HTML(200, "settings", gin.H{
|
c.HTML(200, "settings", withLang(c, gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "settings",
|
"activeFolder": "settings",
|
||||||
"error": "",
|
"error": "",
|
||||||
"success": "",
|
"success": "",
|
||||||
"mustChange": c.Query("force") == "1",
|
"mustChange": c.Query("force") == "1",
|
||||||
"folders": h.foldersFor(userID),
|
"folders": h.foldersFor(userID),
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingsData builds the shared template context for the settings page.
|
// settingsData builds the shared template context for the settings page.
|
||||||
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H {
|
func (h *MailHandler) settingsData(c *gin.Context, userID uint, user *db.User, errMsg, success string) gin.H {
|
||||||
return gin.H{
|
return withLang(c, gin.H{
|
||||||
"currentUser": user,
|
"currentUser": user,
|
||||||
"activeFolder": "settings",
|
"activeFolder": "settings",
|
||||||
"error": errMsg,
|
"error": errMsg,
|
||||||
"success": success,
|
"success": success,
|
||||||
"folders": h.foldersFor(userID),
|
"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) {
|
func (h *MailHandler) UpdateSettings(c *gin.Context) {
|
||||||
userID := c.GetUint("userID")
|
userID := c.GetUint("userID")
|
||||||
currentUserVal, _ := c.Get("currentUser")
|
currentUserVal, _ := c.Get("currentUser")
|
||||||
currentUser := currentUserVal.(*db.User)
|
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")
|
oldPassword := c.PostForm("old_password")
|
||||||
newPassword := c.PostForm("new_password")
|
newPassword := c.PostForm("new_password")
|
||||||
confirmPassword := c.PostForm("confirm_password")
|
confirmPassword := c.PostForm("confirm_password")
|
||||||
|
|
||||||
// Verify old password
|
// Verify old password
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
|
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
|
||||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", ""))
|
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "当前密码不正确"), ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if newPassword == "" {
|
if newPassword == "" {
|
||||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", ""))
|
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "新密码不能为空"), ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if newPassword != confirmPassword {
|
if newPassword != confirmPassword {
|
||||||
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", ""))
|
c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "两次输入的密码不一致"), ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", ""))
|
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码加密失败"), ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
|
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
|
||||||
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", ""))
|
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码更新失败"), ""))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功"))
|
c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "密码修改成功")))
|
||||||
}
|
}
|
||||||
|
|
||||||
// formatBytes converts a file size in bytes to a human-readable string.
|
// formatBytes converts a file size in bytes to a human-readable string.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -25,13 +26,19 @@ import (
|
|||||||
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
|
// templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。
|
||||||
func testTemplateFuncs() template.FuncMap {
|
func testTemplateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"add": func(a, b int) int { return a + b },
|
"add": func(a, b int) int { return a + b },
|
||||||
"sub": 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 },
|
"mul": func(a, b int) int { return a * b },
|
||||||
"div": 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 },
|
"mod": func(a, b int) int { return a % b },
|
||||||
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(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 },
|
"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" },
|
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
|
||||||
"jsonify": func(v interface{}) template.JS {
|
"jsonify": func(v interface{}) template.JS {
|
||||||
b, _ := json.Marshal(v)
|
b, _ := json.Marshal(v)
|
||||||
@@ -45,13 +52,16 @@ func testTemplateFuncs() template.FuncMap {
|
|||||||
"mailEmail": func(s string) string { return s },
|
"mailEmail": func(s string) string { return s },
|
||||||
"initial": func(s string) string { return "?" },
|
"initial": func(s string) string { return "?" },
|
||||||
"truncate": func(s string, n int) string { return s },
|
"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 },
|
"localTime": func(t time.Time) time.Time { return t },
|
||||||
"time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") },
|
"time12": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04:05") },
|
||||||
"time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") },
|
"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" },
|
"avatarStyle": func(s string) string { return "background:#eee;color:#333" },
|
||||||
"urlPath": func(s string) string { return url.PathEscape(s) },
|
"urlPath": func(s string) string { return url.PathEscape(s) },
|
||||||
"folderLabel": func(s string) string { return s },
|
"folderLabel": func(lang, s string) string { return s },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,7 +76,7 @@ func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.R
|
|||||||
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
|
c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil)
|
||||||
|
|
||||||
authCfg := config.AuthConfig{
|
authCfg := config.AuthConfig{
|
||||||
OAuth2Enabled: true,
|
OAuth2Enabled: true,
|
||||||
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
|
// 使用本地拒绝连接的地址作为 provider,token 交换快速失败,
|
||||||
// 测试不依赖外部网络。
|
// 测试不依赖外部网络。
|
||||||
OAuth2Provider: "127.0.0.1:1",
|
OAuth2Provider: "127.0.0.1:1",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
"mail_go/internal/i18n"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -12,16 +13,22 @@ import (
|
|||||||
// Must be used after AuthMiddleware so that "currentUser" is available.
|
// Must be used after AuthMiddleware so that "currentUser" is available.
|
||||||
func AdminMiddleware() gin.HandlerFunc {
|
func AdminMiddleware() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
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")
|
userVal, exists := c.Get("currentUser")
|
||||||
if !exists {
|
if !exists {
|
||||||
c.String(http.StatusForbidden, "禁止访问")
|
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问"))
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, ok := userVal.(*db.User)
|
user, ok := userVal.(*db.User)
|
||||||
if !ok || !user.IsAdmin {
|
if !ok || !user.IsAdmin {
|
||||||
c.String(http.StatusForbidden, "禁止访问:需要管理员权限")
|
c.String(http.StatusForbidden, i18n.T(lang, "禁止访问:需要管理员权限"))
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -100,6 +101,10 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解析界面语言:用户偏好 auto 时按浏览器 Accept-Language 选择,
|
||||||
|
// 最终兜底英语。模板数据与错误提示统一使用该值。
|
||||||
|
c.Set("lang", i18n.Resolve(user.Language, c.GetHeader("Accept-Language")))
|
||||||
|
|
||||||
c.Set("currentUser", user)
|
c.Set("currentUser", user)
|
||||||
c.Set("userID", id)
|
c.Set("userID", id)
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package middleware
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -15,8 +16,12 @@ func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
|
|||||||
ip := c.ClientIP()
|
ip := c.ClientIP()
|
||||||
banned, entry := stores.Bans.IsBanned(ip)
|
banned, entry := stores.Bans.IsBanned(ip)
|
||||||
if banned {
|
if banned {
|
||||||
|
// 封禁页无登录用户,按浏览器语言渲染
|
||||||
|
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
|
||||||
|
c.Set("lang", lang)
|
||||||
c.HTML(http.StatusForbidden, "banned", gin.H{
|
c.HTML(http.StatusForbidden, "banned", gin.H{
|
||||||
"entry": entry,
|
"entry": entry,
|
||||||
|
"Lang": lang,
|
||||||
})
|
})
|
||||||
c.Abort()
|
c.Abort()
|
||||||
return
|
return
|
||||||
|
|||||||
+11
-11
@@ -59,24 +59,24 @@ func TestRenderAllPages(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
data ginH
|
data ginH
|
||||||
}{
|
}{
|
||||||
{"login", ginH{"error": ""}},
|
{"login", ginH{"Lang": "zh", "error": ""}},
|
||||||
{"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
|
{"banned", ginH{"Lang": "zh", "entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
|
||||||
{"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
|
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
|
||||||
{"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
|
{"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
|
||||||
{"view", ginH{
|
{"view", ginH{"Lang": "zh",
|
||||||
"currentUser": user, "activeFolder": "INBOX",
|
"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},
|
"message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false},
|
||||||
"attachments": attachments, "inTrash": false, "folders": folders,
|
"attachments": attachments, "inTrash": false, "folders": folders,
|
||||||
}},
|
}},
|
||||||
{"compose", ginH{
|
{"compose", ginH{"Lang": "zh",
|
||||||
"currentUser": user, "activeFolder": "compose", "error": "",
|
"currentUser": user, "activeFolder": "compose", "error": "",
|
||||||
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
|
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
|
||||||
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
|
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
|
||||||
"folders": folders,
|
"folders": folders,
|
||||||
}},
|
}},
|
||||||
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
|
{"settings", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
|
||||||
{"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
|
{"admin_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{
|
{"admin_bans", ginH{"Lang": "zh",
|
||||||
"currentUser": user, "activeFolder": "bans",
|
"currentUser": user, "activeFolder": "bans",
|
||||||
"rows": []struct {
|
"rows": []struct {
|
||||||
db.BanEntry
|
db.BanEntry
|
||||||
@@ -88,7 +88,7 @@ func TestRenderAllPages(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
|
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
|
||||||
}},
|
}},
|
||||||
{"admin_protocol_logs", ginH{
|
{"admin_protocol_logs", ginH{"Lang": "zh",
|
||||||
"currentUser": user, "activeFolder": "protocol-logs",
|
"currentUser": user, "activeFolder": "protocol-logs",
|
||||||
"logs": []db.ProtocolLog{
|
"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},
|
{ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM:<spam@evil.example> RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now},
|
||||||
@@ -109,7 +109,7 @@ func TestRenderAllPages(t *testing.T) {
|
|||||||
},
|
},
|
||||||
"keepDays": 30,
|
"keepDays": 30,
|
||||||
}},
|
}},
|
||||||
{"admin_connections", ginH{
|
{"admin_connections", ginH{"Lang": "zh",
|
||||||
"currentUser": user, "activeFolder": "connections",
|
"currentUser": user, "activeFolder": "connections",
|
||||||
"conns": []struct {
|
"conns": []struct {
|
||||||
ID uint64
|
ID uint64
|
||||||
|
|||||||
+49
-27
@@ -18,6 +18,7 @@ import (
|
|||||||
|
|
||||||
"mail_go/config"
|
"mail_go/config"
|
||||||
"mail_go/internal/connhub"
|
"mail_go/internal/connhub"
|
||||||
|
"mail_go/internal/i18n"
|
||||||
"mail_go/internal/imap_server"
|
"mail_go/internal/imap_server"
|
||||||
"mail_go/internal/mailutil"
|
"mail_go/internal/mailutil"
|
||||||
"mail_go/internal/outbound"
|
"mail_go/internal/outbound"
|
||||||
@@ -84,6 +85,21 @@ func templateFuncs() template.FuncMap {
|
|||||||
"domainName": func(domainID uint, domains []interface{}) string {
|
"domainName": func(domainID uint, domains []interface{}) string {
|
||||||
return fmt.Sprintf("Domain #%d", domainID)
|
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 字符串),
|
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
|
||||||
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
|
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义
|
||||||
// < > &(\u003c 等),无法逃出 </script>,杜绝 script 注入。
|
// < > &(\u003c 等),无法逃出 </script>,杜绝 script 注入。
|
||||||
@@ -108,12 +124,11 @@ func templateFuncs() template.FuncMap {
|
|||||||
"initial": initial,
|
"initial": initial,
|
||||||
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
|
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
|
||||||
"truncate": truncate,
|
"truncate": truncate,
|
||||||
// shortDate 邮件列表时间:统一 12 小时制(上午/下午)。
|
// shortDate 邮件列表时间:按界面语言输出 12 小时制。
|
||||||
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
|
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
|
||||||
// 更早显示「2026-08-20 下午 2:35」。
|
// 更早显示「2026-08-20 下午 2:35」。
|
||||||
"shortDate": shortDate,
|
"shortDate": shortDate,
|
||||||
// time12 完整时间(含秒),先转换为 Web 时区:
|
// time12 完整时间(含秒),先转换为 Web 时区。
|
||||||
// 「2026-08-20 下午 2:35:05」。
|
|
||||||
"time12": time12,
|
"time12": time12,
|
||||||
// time12m 同上但不含秒。
|
// time12m 同上但不含秒。
|
||||||
"time12m": time12m,
|
"time12m": time12m,
|
||||||
@@ -123,24 +138,30 @@ func templateFuncs() template.FuncMap {
|
|||||||
"avatarStyle": avatarStyle,
|
"avatarStyle": avatarStyle,
|
||||||
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
|
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
|
||||||
"urlPath": url.PathEscape,
|
"urlPath": url.PathEscape,
|
||||||
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。
|
// folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。
|
||||||
"folderLabel": func(name string) string {
|
"folderLabel": func(lang, name string) string {
|
||||||
switch name {
|
return folderLabel(lang, name)
|
||||||
case "INBOX":
|
|
||||||
return "收件箱"
|
|
||||||
case "Sent":
|
|
||||||
return "已发送"
|
|
||||||
case "Drafts":
|
|
||||||
return "草稿箱"
|
|
||||||
case "Trash":
|
|
||||||
return "已删除"
|
|
||||||
default:
|
|
||||||
return name
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译,
|
||||||
|
// 自定义文件夹原样返回。
|
||||||
|
func folderLabel(lang, name string) string {
|
||||||
|
switch name {
|
||||||
|
case "INBOX":
|
||||||
|
return i18n.T(lang, "收件箱")
|
||||||
|
case "Sent":
|
||||||
|
return i18n.T(lang, "已发送")
|
||||||
|
case "Drafts":
|
||||||
|
return i18n.T(lang, "草稿箱")
|
||||||
|
case "Trash":
|
||||||
|
return i18n.T(lang, "已删除")
|
||||||
|
default:
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// mailName extracts the display name from an RFC 5322 address.
|
// mailName extracts the display name from an RFC 5322 address.
|
||||||
func mailName(s string) string {
|
func mailName(s string) string {
|
||||||
s = strings.TrimSpace(s)
|
s = strings.TrimSpace(s)
|
||||||
@@ -186,11 +207,12 @@ func truncate(s string, n int) string {
|
|||||||
return string(r[:n]) + "…"
|
return string(r[:n]) + "…"
|
||||||
}
|
}
|
||||||
|
|
||||||
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。
|
// periodOf 返回 12 小时制的时间段与小时:按界面语言输出
|
||||||
func periodOf(t time.Time) (string, int) {
|
// 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。
|
||||||
period := "上午"
|
func periodOf(lang string, t time.Time) (string, int) {
|
||||||
|
period := i18n.T(lang, "上午")
|
||||||
if t.Hour() >= 12 {
|
if t.Hour() >= 12 {
|
||||||
period = "下午"
|
period = i18n.T(lang, "下午")
|
||||||
}
|
}
|
||||||
h := t.Hour() % 12
|
h := t.Hour() % 12
|
||||||
if h == 0 {
|
if h == 0 {
|
||||||
@@ -202,10 +224,10 @@ func periodOf(t time.Time) (string, int) {
|
|||||||
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
|
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
|
||||||
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
|
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
|
||||||
// 更早 → 「2026-08-20 下午 2:35」。
|
// 更早 → 「2026-08-20 下午 2:35」。
|
||||||
func shortDate(t time.Time) string {
|
func shortDate(lang string, t time.Time) string {
|
||||||
t = inWebTZ(t)
|
t = inWebTZ(t)
|
||||||
now := time.Now().In(t.Location())
|
now := time.Now().In(t.Location())
|
||||||
period, h := periodOf(t)
|
period, h := periodOf(lang, t)
|
||||||
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
|
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
|
||||||
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
|
||||||
return clock
|
return clock
|
||||||
@@ -218,16 +240,16 @@ func shortDate(t time.Time) string {
|
|||||||
|
|
||||||
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
|
// time12 完整时间(12 小时制,先按 Web 配置时区转换):
|
||||||
// 「2026-08-20 下午 2:35:05」。
|
// 「2026-08-20 下午 2:35:05」。
|
||||||
func time12(t time.Time) string {
|
func time12(lang string, t time.Time) string {
|
||||||
t = inWebTZ(t)
|
t = inWebTZ(t)
|
||||||
period, h := periodOf(t)
|
period, h := periodOf(lang, t)
|
||||||
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
|
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
|
||||||
}
|
}
|
||||||
|
|
||||||
// time12m 完整时间(12 小时制,不含秒)。
|
// time12m 完整时间(12 小时制,不含秒)。
|
||||||
func time12m(t time.Time) string {
|
func time12m(lang string, t time.Time) string {
|
||||||
t = inWebTZ(t)
|
t = inWebTZ(t)
|
||||||
period, h := periodOf(t)
|
period, h := periodOf(lang, t)
|
||||||
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
|
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_bans"}}
|
{{define "admin_bans"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,32 +12,32 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
<h2>IP 黑名单</h2>
|
<h2>{{t .Lang "IP 黑名单"}}</h2>
|
||||||
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span>
|
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)"}}</span>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>IP 地址</th>
|
<th>{{t .Lang "IP 地址"}}</th>
|
||||||
<th>状态</th>
|
<th>{{t .Lang "状态"}}</th>
|
||||||
<th>封禁次数</th>
|
<th>{{t .Lang "封禁次数"}}</th>
|
||||||
<th>失败次数</th>
|
<th>{{t .Lang "失败次数"}}</th>
|
||||||
<th>原因</th>
|
<th>{{t .Lang "原因"}}</th>
|
||||||
<th>到期时间</th>
|
<th>{{t .Lang "到期时间"}}</th>
|
||||||
<th>操作</th>
|
<th>{{t .Lang "操作"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -45,21 +45,21 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>{{.IPAddress}}</td>
|
<td>{{.IPAddress}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span>
|
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">{{t $.Lang "封禁中"}}</span>
|
||||||
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}}
|
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "已过期"}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .BanCount}}
|
{{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}}
|
{{else}}—{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{.FailCount}}</td>
|
<td>{{.FailCount}}</td>
|
||||||
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td>
|
<td>{{if .Reason}}{{t $.Lang .Reason}}{{else}}—{{end}}</td>
|
||||||
<td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td>
|
<td>{{time12 $.Lang .ExpiresAt}}{{if not .Active}}{{t $.Lang "(已过期)"}}{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');">
|
onsubmit="return confirm('{{tf $.Lang "确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。" .IPAddress}}');">
|
||||||
<button type="submit" class="btn btn-primary btn-sm">解封</button>
|
<button type="submit" class="btn btn-primary btn-sm">{{t $.Lang "解封"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -67,17 +67,17 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{{if not .rows}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{if .totalPages}}
|
{{if .totalPages}}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{{if gt .page 1}}
|
{{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}}
|
{{end}}
|
||||||
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
|
<span>{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
|
||||||
{{if lt .page .totalPages}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_connections"}}
|
{{define "admin_connections"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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">
|
<meta http-equiv="refresh" content="5">
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
@@ -13,26 +13,26 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<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>
|
||||||
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span>
|
<span style="color:#7f8c8d;font-size:13px;">{{t .Lang "每 5 秒自动刷新"}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.total}}</h3>
|
<h3>{{.total}}</h3>
|
||||||
<p>当前连接总数</p>
|
<p>{{t .Lang "当前连接总数"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.smtpCount}}</h3>
|
<h3>{{.smtpCount}}</h3>
|
||||||
@@ -53,15 +53,15 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>协议</th>
|
<th>{{t .Lang "协议"}}</th>
|
||||||
<th>来源 IP</th>
|
<th>{{t .Lang "来源 IP"}}</th>
|
||||||
<th>端口</th>
|
<th>{{t .Lang "端口"}}</th>
|
||||||
<th>用户名</th>
|
<th>{{t .Lang "用户名"}}</th>
|
||||||
<th>TLS</th>
|
<th>TLS</th>
|
||||||
<th>连接时间</th>
|
<th>{{t .Lang "连接时间"}}</th>
|
||||||
<th>时长</th>
|
<th>{{t .Lang "时长"}}</th>
|
||||||
<th>最后活跃</th>
|
<th>{{t .Lang "最后活跃"}}</th>
|
||||||
<th>操作</th>
|
<th>{{t .Lang "操作"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -76,19 +76,19 @@
|
|||||||
<td>{{.IP}}</td>
|
<td>{{.IP}}</td>
|
||||||
<td>{{.Port}}</td>
|
<td>{{.Port}}</td>
|
||||||
<td>{{if .User}}{{.User}}{{else}}—{{end}}</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>{{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 .Connected}}</td>
|
<td>{{time12 $.Lang .Connected}}</td>
|
||||||
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
|
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
|
||||||
<td>{{time12 .LastActive}}</td>
|
<td>{{time12 $.Lang .LastActive}}</td>
|
||||||
<td>
|
<td>
|
||||||
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
|
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');">
|
onsubmit="return confirm('{{tf $.Lang "确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?" .IP}}');">
|
||||||
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button>
|
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "断开并封禁"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_dashboard"}}
|
{{define "admin_dashboard"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>管理后台 - MailGo</title>
|
<title>{{t .Lang "管理后台"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,74 +12,74 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
<h2 style="margin-bottom:24px;">{{t .Lang "管理后台"}}</h2>
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.domainCount}}</h3>
|
<h3>{{.domainCount}}</h3>
|
||||||
<p>域名数</p>
|
<p>{{t .Lang "域名数"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.userCount}}</h3>
|
<h3>{{.userCount}}</h3>
|
||||||
<p>用户数</p>
|
<p>{{t .Lang "用户数"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.totalMails}}</h3>
|
<h3>{{.totalMails}}</h3>
|
||||||
<p>邮件总数</p>
|
<p>{{t .Lang "邮件总数"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{.banCount}}</h3>
|
<h3>{{.banCount}}</h3>
|
||||||
<p>被封IP</p>
|
<p>{{t .Lang "被封IP"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>邮件分布</h3>
|
<h3>{{t .Lang "邮件分布"}}</h3>
|
||||||
<table style="margin-top:12px;">
|
<table style="margin-top:12px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>文件夹</th>
|
<th>{{t .Lang "文件夹"}}</th>
|
||||||
<th>邮件数</th>
|
<th>{{t .Lang "邮件数"}}</th>
|
||||||
<th>占用空间</th>
|
<th>{{t .Lang "占用空间"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
|
<tr><td>{{folderLabel .Lang "INBOX"}} (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
|
||||||
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
|
<tr><td>{{folderLabel .Lang "Sent"}} (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
|
||||||
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td>—</td></tr>
|
<tr><td>{{folderLabel .Lang "Drafts"}} (Drafts)</td><td>{{.draftsCount}}</td><td>—</td></tr>
|
||||||
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td>—</td></tr>
|
<tr><td>{{folderLabel .Lang "Trash"}} (Trash)</td><td>{{.trashCount}}</td><td>—</td></tr>
|
||||||
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
|
<tr style="font-weight:bold;"><td>{{t .Lang "合计"}}</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>收发统计</h3>
|
<h3>{{t .Lang "收发统计"}}</h3>
|
||||||
<table style="margin-top:12px;">
|
<table style="margin-top:12px;">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>时间段</th><th>收件</th><th>发件</th></tr>
|
<tr><th>{{t .Lang "时间段"}}</th><th>{{t .Lang "收件"}}</th><th>{{t .Lang "发件"}}</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
|
<tr><td>{{t .Lang "今日"}}</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
|
||||||
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
|
<tr><td>{{t .Lang "近 7 天"}}</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>快捷操作</h3>
|
<h3>{{t .Lang "快捷操作"}}</h3>
|
||||||
<p style="margin-top:12px;">
|
<p style="margin-top:12px;">
|
||||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</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;">新增用户</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;">查看所有邮件</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;">IP黑名单</a>
|
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "IP黑名单"}}</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_dns_hint"}}
|
{{define "admin_dns_hint"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,39 +12,39 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2>
|
<h2 style="margin-bottom:16px;">{{tf .Lang "DNS 配置提示 — %s" .domain.Name}}</h2>
|
||||||
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p>
|
<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>
|
<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>
|
<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}}
|
{{if .domain.DkimPublicKey}}
|
||||||
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
|
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div>
|
<div class="dns-record" style="color:#e67e22;">{{t .Lang "⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。"}}</div>
|
||||||
{{end}}
|
{{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 class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
|
||||||
|
|
||||||
<div style="margin-top:24px;">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_domain_form"}}
|
{{define "admin_domain_form"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,24 +12,24 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<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 .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}}
|
{{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}}">
|
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>域名</label>
|
<label>{{t .Lang "域名"}}</label>
|
||||||
{{if .isEdit}}
|
{{if .isEdit}}
|
||||||
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
|
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -37,45 +37,45 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>SMTP 端口</label>
|
<label>{{t .Lang "SMTP 端口"}}</label>
|
||||||
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
|
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>IMAP 端口</label>
|
<label>{{t .Lang "IMAP 端口"}}</label>
|
||||||
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
|
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>POP3 端口</label>
|
<label>{{t .Lang "POP3 端口"}}</label>
|
||||||
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
|
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
|
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
|
||||||
启用 TLS
|
{{t .Lang "启用 TLS"}}
|
||||||
</label>
|
</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>
|
</div>
|
||||||
{{if .isEdit}}
|
{{if .isEdit}}
|
||||||
<div id="tls_cert_fields" style="display:none;">
|
<div id="tls_cert_fields" style="display:none;">
|
||||||
<div class="form-group">
|
<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>
|
<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>
|
||||||
<div class="form-group">
|
<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>
|
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE----- ... -----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
|
||||||
{{if .tlsCertConfigured}}
|
{{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}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group" style="margin-top:4px;">
|
<div class="form-group" style="margin-top:4px;">
|
||||||
<label>从 Caddy 获取证书</label>
|
<label>{{t .Lang "从 Caddy 获取证书"}}</label>
|
||||||
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。</p>
|
<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;">🔒 从 Caddy 获取证书</button>
|
<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>
|
<span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span>
|
||||||
<script>
|
<script>
|
||||||
async function fetchCaddyCert() {
|
async function fetchCaddyCert() {
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
var msg = document.getElementById('caddy_fetch_msg');
|
var msg = document.getElementById('caddy_fetch_msg');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
var oldText = btn.textContent;
|
var oldText = btn.textContent;
|
||||||
btn.textContent = '获取中…';
|
btn.textContent = '{{t .Lang "获取中…"}}';
|
||||||
msg.textContent = '';
|
msg.textContent = '';
|
||||||
try {
|
try {
|
||||||
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
|
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
|
||||||
@@ -91,9 +91,9 @@
|
|||||||
window.location.href = resp.url;
|
window.location.href = resp.url;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
msg.textContent = '获取失败: ' + await resp.text();
|
msg.textContent = '{{t .Lang "获取失败"}}: ' + await resp.text();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
msg.textContent = '请求失败: ' + e;
|
msg.textContent = '{{t .Lang "请求失败"}}: ' + e;
|
||||||
}
|
}
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.textContent = oldText;
|
btn.textContent = oldText;
|
||||||
@@ -118,17 +118,17 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="regenerate_dkim">
|
<input type="checkbox" name="regenerate_dkim">
|
||||||
重新生成 DKIM 密钥
|
{{t .Lang "重新生成 DKIM 密钥"}}
|
||||||
</label>
|
</label>
|
||||||
{{if .domain.DkimPublicKey}}
|
{{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}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
<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;">取消</a>
|
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_domains"}}
|
{{define "admin_domains"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>域名管理 - MailGo</title>
|
<title>{{t .Lang "域名管理"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,35 +12,35 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
<h2>域名列表</h2>
|
<h2>{{t .Lang "域名列表"}}</h2>
|
||||||
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a>
|
<a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{if not .domains}}
|
{{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}}
|
{{else}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>域名</th>
|
<th>{{t $.Lang "域名"}}</th>
|
||||||
<th>SMTP</th>
|
<th>SMTP</th>
|
||||||
<th>IMAP</th>
|
<th>IMAP</th>
|
||||||
<th>POP3</th>
|
<th>POP3</th>
|
||||||
<th>TLS</th>
|
<th>TLS</th>
|
||||||
<th>操作</th>
|
<th>{{t $.Lang "操作"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -53,11 +53,11 @@
|
|||||||
<td>{{.Pop3Port}}</td>
|
<td>{{.Pop3Port}}</td>
|
||||||
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
|
||||||
<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>
|
<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;"
|
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
onsubmit="return confirm('{{tf $.Lang "确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!" .Name}}');">
|
||||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -69,11 +69,11 @@
|
|||||||
{{if .totalPages}}
|
{{if .totalPages}}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{{if gt .page 1}}
|
{{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}}
|
{{end}}
|
||||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
|
||||||
{{if lt .page .totalPages}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_mail_view"}}
|
{{define "admin_mail_view"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
<style>
|
<style>
|
||||||
.mail-body-iframe {
|
.mail-body-iframe {
|
||||||
@@ -21,29 +21,29 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
|
||||||
<a href="/admin/mails" class="active">所有邮件</a>
|
<a href="/admin/mails" class="active">{{t .Lang "所有邮件"}}</a>
|
||||||
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="margin-bottom:16px;">
|
<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>
|
</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;">
|
<div class="mail-meta" style="margin-top:12px;">
|
||||||
<p><strong>发件人:</strong> {{decodeHeader .message.FromAddr}}</p>
|
<p><strong>{{t .Lang "发件人:"}}</strong> {{decodeHeader .message.FromAddr}}</p>
|
||||||
<p><strong>收件人:</strong> {{.message.ToAddr}}</p>
|
<p><strong>{{t .Lang "收件人:"}}</strong> {{.message.ToAddr}}</p>
|
||||||
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}}
|
{{if .message.CcAddr}}<p><strong>{{t .Lang "抄送:"}}</strong> {{.message.CcAddr}}</p>{{end}}
|
||||||
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
|
<p><strong>{{t .Lang "所属用户:"}}</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
|
||||||
<p><strong>文件夹:</strong> {{.message.Folder}}</p>
|
<p><strong>{{t .Lang "文件夹:"}}</strong> {{.message.Folder}}</p>
|
||||||
<p><strong>时间:</strong> {{time12 .message.Date}}</p>
|
<p><strong>{{t .Lang "时间:"}}</strong> {{time12 .Lang .message.Date}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="mail-body">
|
<div class="mail-body">
|
||||||
{{if .message.HtmlBody}}
|
{{if .message.HtmlBody}}
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{if .attachments}}
|
{{if .attachments}}
|
||||||
<div class="attachment-list">
|
<div class="attachment-list">
|
||||||
<h4 style="margin-bottom:8px;">附件</h4>
|
<h4 style="margin-bottom:8px;">{{t .Lang "附件列表"}}</h4>
|
||||||
{{range .attachments}}
|
{{range .attachments}}
|
||||||
<div class="attachment-item">
|
<div class="attachment-item">
|
||||||
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
|
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_mails"}}
|
{{define "admin_mails"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>所有邮件 - MailGo</title>
|
<title>{{t .Lang "所有邮件"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,38 +12,38 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="margin-bottom:16px;">所有邮件</h2>
|
<h2 style="margin-bottom:16px;">{{t .Lang "所有邮件"}}</h2>
|
||||||
<div style="margin-bottom:16px;">
|
<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=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=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=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>
|
<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>
|
</div>
|
||||||
{{if not .messages}}
|
{{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}}
|
{{else}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>发件人</th>
|
<th>{{t $.Lang "发件人"}}</th>
|
||||||
<th>收件人</th>
|
<th>{{t $.Lang "收件人"}}</th>
|
||||||
<th>主题</th>
|
<th>{{t $.Lang "主题"}}</th>
|
||||||
<th>所属用户</th>
|
<th>{{t $.Lang "所属用户"}}</th>
|
||||||
<th>文件夹</th>
|
<th>{{t $.Lang "文件夹"}}</th>
|
||||||
<th>日期</th>
|
<th>{{t $.Lang "日期"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
<td>{{.Subject}}</td>
|
<td>{{.Subject}}</td>
|
||||||
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
|
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
|
||||||
<td>{{.Folder}}</td>
|
<td>{{.Folder}}</td>
|
||||||
<td>{{time12m .Date}}</td>
|
<td>{{time12m $.Lang .Date}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{end}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -64,11 +64,11 @@
|
|||||||
{{if .totalPages}}
|
{{if .totalPages}}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{{if gt .page 1}}
|
{{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}}
|
{{end}}
|
||||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
|
||||||
{{if lt .page .totalPages}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_outbound"}}
|
{{define "admin_outbound"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>外发队列 - MailGo</title>
|
<title>{{t .Lang "外发队列"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,60 +12,60 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 style="margin-bottom:24px;">外发队列</h2>
|
<h2 style="margin-bottom:24px;">{{t .Lang "外发队列"}}</h2>
|
||||||
|
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .statCounts "pending"}}</h3>
|
<h3>{{index .statCounts "pending"}}</h3>
|
||||||
<p>待发送</p>
|
<p>{{t .Lang "待发送"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .statCounts "deferred"}}</h3>
|
<h3>{{index .statCounts "deferred"}}</h3>
|
||||||
<p>等待重试</p>
|
<p>{{t .Lang "等待重试"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .statCounts "sent"}}</h3>
|
<h3>{{index .statCounts "sent"}}</h3>
|
||||||
<p>已送达</p>
|
<p>{{t .Lang "已送达"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .statCounts "failed"}}</h3>
|
<h3>{{index .statCounts "failed"}}</h3>
|
||||||
<p>失败</p>
|
<p>{{t .Lang "失败"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="margin-bottom:12px;">
|
<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" 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">待发送</a>
|
<a href="/admin/outbound?status=pending" class="btn btn-sm">{{t .Lang "待发送"}}</a>
|
||||||
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a>
|
<a href="/admin/outbound?status=deferred" class="btn btn-sm">{{t .Lang "等待重试"}}</a>
|
||||||
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a>
|
<a href="/admin/outbound?status=sent" class="btn btn-sm">{{t .Lang "已送达"}}</a>
|
||||||
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a>
|
<a href="/admin/outbound?status=failed" class="btn btn-sm">{{t .Lang "失败"}}</a>
|
||||||
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a>
|
<a href="/admin/outbound?status=canceled" class="btn btn-sm">{{t .Lang "已取消"}}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>发件人</th>
|
<th>{{t .Lang "发件人"}}</th>
|
||||||
<th>收件人</th>
|
<th>{{t .Lang "收件人"}}</th>
|
||||||
<th>状态</th>
|
<th>{{t .Lang "状态"}}</th>
|
||||||
<th>尝试次数</th>
|
<th>{{t .Lang "尝试次数"}}</th>
|
||||||
<th>下次重试</th>
|
<th>{{t .Lang "下次重试"}}</th>
|
||||||
<th>最后响应 / 错误</th>
|
<th>{{t .Lang "最后响应 / 错误"}}</th>
|
||||||
<th>创建时间</th>
|
<th>{{t .Lang "创建时间"}}</th>
|
||||||
<th>操作</th>
|
<th>{{t .Lang "操作"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -75,40 +75,40 @@
|
|||||||
<td>{{.FromAddr}}</td>
|
<td>{{.FromAddr}}</td>
|
||||||
<td>{{.ToAddr}}</td>
|
<td>{{.ToAddr}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span>
|
{{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;">{{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;">{{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">{{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;">{{call $.statusText .Status}}</span>{{end}}
|
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td>{{.Attempts}}</td>
|
<td>{{.Attempts}}</td>
|
||||||
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td>
|
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m $.Lang .NextAttemptAt}}{{else}}—{{end}}</td>
|
||||||
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
|
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
|
||||||
<td>{{time12m .CreatedAt}}</td>
|
<td>{{time12m $.Lang .CreatedAt}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
|
{{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;">
|
<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>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if or (eq .Status "pending") (eq .Status "deferred")}}
|
{{if or (eq .Status "pending") (eq .Status "deferred")}}
|
||||||
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');">
|
<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">取消</button>
|
<button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "取消"}}</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{{if gt .totalPages 1}}
|
{{if gt .totalPages 1}}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .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">第 {{.page}} / {{.totalPages}} 页</span>
|
<span class="current">{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
|
||||||
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}}
|
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">{{t $.Lang "下一页"}}</a>{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_protocol_logs"}}
|
{{define "admin_protocol_logs"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>协议日志 - MailGo</title>
|
<title>{{t .Lang "协议日志"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,41 +12,41 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<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;"
|
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
|
||||||
onsubmit="return confirm('确认清理 {{.keepDays}} 天前的协议日志?');">
|
onsubmit="return confirm('{{tf .Lang "确认清理 %d 天前的协议日志?" .keepDays}}');">
|
||||||
<button type="submit" class="btn btn-primary">清理旧日志</button>
|
<button type="submit" class="btn btn-primary">{{t .Lang "清理旧日志"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:24px;">
|
<div style="margin-bottom:24px;">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .todayStats "smtp" "fail"}}</h3>
|
<h3>{{index .todayStats "smtp" "fail"}}</h3>
|
||||||
<p>今日 SMTP 失败</p>
|
<p>{{t .Lang "今日 SMTP 失败"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .todayStats "imap" "fail"}}</h3>
|
<h3>{{index .todayStats "imap" "fail"}}</h3>
|
||||||
<p>今日 IMAP 失败</p>
|
<p>{{t .Lang "今日 IMAP 失败"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{index .todayStats "pop3" "fail"}}</h3>
|
<h3>{{index .todayStats "pop3" "fail"}}</h3>
|
||||||
<p>今日 POP3 失败</p>
|
<p>{{t .Lang "今日 POP3 失败"}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
|
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
|
||||||
<p>历史失败(全部)</p>
|
<p>{{t .Lang "历史失败(全部)"}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -54,45 +54,45 @@
|
|||||||
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
|
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
|
||||||
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
|
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
|
||||||
<select name="protocol" style="padding:6px 10px;">
|
<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="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option>
|
||||||
<option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</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>
|
<option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option>
|
||||||
</select>
|
</select>
|
||||||
<select name="success" style="padding:6px 10px;">
|
<select name="success" style="padding:6px 10px;">
|
||||||
<option value="">全部状态</option>
|
<option value="">{{t .Lang "全部状态"}}</option>
|
||||||
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>成功</option>
|
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>{{t .Lang "成功"}}</option>
|
||||||
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>失败</option>
|
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>{{t .Lang "失败"}}</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" name="ip" placeholder="来源 IP(模糊)" value="{{.filter.ip}}" 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="用户名(模糊)" value="{{.filter.username}}" 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;">
|
<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;">
|
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
|
||||||
<button type="submit" class="btn btn-sm btn-primary">筛选</button>
|
<button type="submit" class="btn btn-sm btn-primary">{{t .Lang "筛选"}}</button>
|
||||||
<a href="/admin/protocol-logs" class="btn btn-sm">重置</a>
|
<a href="/admin/protocol-logs" class="btn btn-sm">{{t .Lang "重置"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>时间</th>
|
<th>{{t .Lang "时间"}}</th>
|
||||||
<th>协议</th>
|
<th>{{t .Lang "协议"}}</th>
|
||||||
<th>端口</th>
|
<th>{{t .Lang "端口"}}</th>
|
||||||
<th>来源 IP</th>
|
<th>{{t .Lang "来源 IP"}}</th>
|
||||||
<th>用户名</th>
|
<th>{{t .Lang "用户名"}}</th>
|
||||||
<th>状态</th>
|
<th>{{t .Lang "状态"}}</th>
|
||||||
<th>失败原因</th>
|
<th>{{t .Lang "失败原因"}}</th>
|
||||||
<th>操作摘要</th>
|
<th>{{t .Lang "操作摘要"}}</th>
|
||||||
<th>消息数</th>
|
<th>{{t .Lang "消息数"}}</th>
|
||||||
<th>时长</th>
|
<th>{{t .Lang "时长"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{{range .logs}}
|
{{range .logs}}
|
||||||
<tr>
|
<tr>
|
||||||
<td style="white-space:nowrap;">{{time12 .CreatedAt}}</td>
|
<td style="white-space:nowrap;">{{time12 $.Lang .CreatedAt}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
|
{{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>
|
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
<td>{{.ClientIP}}</td>
|
<td>{{.ClientIP}}</td>
|
||||||
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
|
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
|
||||||
<td>
|
<td>
|
||||||
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">成功</span>
|
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang "成功"}}</span>
|
||||||
{{else}}<span class="badge badge-unread">失败</span>{{end}}
|
{{else}}<span class="badge badge-unread">{{t $.Lang "失败"}}</span>{{end}}
|
||||||
</td>
|
</td>
|
||||||
<td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{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>
|
<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>
|
<td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{{else}}
|
{{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}}
|
{{end}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
{{if gt .totalPages 1}}
|
{{if gt .totalPages 1}}
|
||||||
<div class="pagination">
|
<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}}
|
{{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">第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span>
|
<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}}">下一页</a>{{end}}
|
{{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>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_user_form"}}
|
{{define "admin_user_form"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,58 +12,67 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<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 .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
|
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>用户名</label>
|
<label>{{t .Lang "用户名"}}</label>
|
||||||
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
|
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label>
|
<label>{{if .isEdit}}{{t .Lang "新密码(留空则不修改)"}}{{else}}{{t .Lang "密码"}}{{end}}</label>
|
||||||
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码">
|
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="{{t .Lang "请输入密码"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<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>
|
<select name="domain_id" required>
|
||||||
<option value="">请选择域名</option>
|
<option value="">{{t .Lang "请选择域名"}}</option>
|
||||||
{{range .domains}}
|
{{range .domains}}
|
||||||
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
|
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
|
||||||
{{end}}
|
{{end}}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>配额 (GB)</label>
|
<label>{{t .Lang "配额 (GB)"}}</label>
|
||||||
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
|
<input type="number" name="quota_gb" min="1" value="5" placeholder="5">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
|
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
|
||||||
管理员
|
{{t .Lang "管理员"}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{if .isEdit}}
|
{{if .isEdit}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>
|
<label>
|
||||||
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
|
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
|
||||||
启用账户
|
{{t .Lang "启用账户"}}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button>
|
<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;">取消</a>
|
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "admin_users"}}
|
{{define "admin_users"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>用户管理 - MailGo</title>
|
<title>{{t .Lang "用户管理"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -12,36 +12,36 @@
|
|||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">{{t .Lang "返回邮箱"}}</a>
|
||||||
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</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}}>域名管理</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}}>用户管理</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}}>所有邮件</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}}>外发队列</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}}>协议日志</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}}>当前连接</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}}>IP黑名单</a>
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
<h2>用户列表</h2>
|
<h2>{{t .Lang "用户列表"}}</h2>
|
||||||
<a href="/admin/users/new" class="btn btn-primary">新增用户</a>
|
<a href="/admin/users/new" class="btn btn-primary">{{t .Lang "新增用户"}}</a>
|
||||||
</div>
|
</div>
|
||||||
{{if not .users}}
|
{{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}}
|
{{else}}
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>用户名</th>
|
<th>{{t $.Lang "用户名"}}</th>
|
||||||
<th>域名ID</th>
|
<th>{{t $.Lang "域名ID"}}</th>
|
||||||
<th>配额</th>
|
<th>{{t $.Lang "配额"}}</th>
|
||||||
<th>已用</th>
|
<th>{{t $.Lang "已用"}}</th>
|
||||||
<th>状态</th>
|
<th>{{t $.Lang "状态"}}</th>
|
||||||
<th>管理员</th>
|
<th>{{t $.Lang "管理员"}}</th>
|
||||||
<th>操作</th>
|
<th>{{t $.Lang "操作"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -55,10 +55,10 @@
|
|||||||
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
|
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
|
||||||
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
|
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
|
||||||
<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;"
|
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');">
|
onsubmit="return confirm('{{tf $.Lang "确定要删除用户 %s 吗?" .Username}}');">
|
||||||
<button type="submit" class="btn btn-danger btn-sm">删除</button>
|
<button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -70,11 +70,11 @@
|
|||||||
{{if .totalPages}}
|
{{if .totalPages}}
|
||||||
<div class="pagination">
|
<div class="pagination">
|
||||||
{{if gt .page 1}}
|
{{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}}
|
{{end}}
|
||||||
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
<span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
|
||||||
{{if lt .page .totalPages}}
|
{{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}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "banned"}}
|
{{define "banned"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>访问被禁止 - MailGo</title>
|
<title>{{t .Lang "访问被禁止"}} - MailGo</title>
|
||||||
<style>
|
<style>
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
@@ -43,34 +43,34 @@
|
|||||||
<body>
|
<body>
|
||||||
<div class="banned-card">
|
<div class="banned-card">
|
||||||
<div class="banned-icon">🚫</div>
|
<div class="banned-icon">🚫</div>
|
||||||
<h1>您的 IP 已被暂时禁止访问</h1>
|
<h1>{{t .Lang "您的 IP 已被暂时禁止访问"}}</h1>
|
||||||
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p>
|
<p>{{t .Lang "由于登录失败次数过多,您的 IP 地址已被暂时封禁。"}}</p>
|
||||||
{{if .entry}}
|
{{if .entry}}
|
||||||
<div class="detail">
|
<div class="detail">
|
||||||
<div class="detail-row">
|
<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>
|
<span class="detail-value">{{.entry.IPAddress}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">封禁档位</span>
|
<span class="detail-label">{{t .Lang "封禁档位"}}</span>
|
||||||
<span class="detail-value">{{if gt .entry.BanCount 3}}第 {{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span>
|
<span class="detail-value">{{if gt .entry.BanCount 3}}{{tf .Lang "第 %d 次封禁" (sub .entry.BanCount 3)}}{{else}}—{{end}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">原因</span>
|
<span class="detail-label">{{t .Lang "原因"}}</span>
|
||||||
<span class="detail-value">{{.entry.Reason}}</span>
|
<span class="detail-value">{{t .Lang .entry.Reason}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">失败次数</span>
|
<span class="detail-label">{{t .Lang "失败次数"}}</span>
|
||||||
<span class="detail-value">{{.entry.FailCount}} 次</span>
|
<span class="detail-value">{{tf .Lang "%d 次" .entry.FailCount}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">解封时间</span>
|
<span class="detail-label">{{t .Lang "解封时间"}}</span>
|
||||||
<span class="detail-value">{{time12 .entry.ExpiresAt}}</span>
|
<span class="detail-value">{{time12 .Lang .entry.ExpiresAt}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p>
|
<p style="font-size:13px;">{{t .Lang "请在解封时间后重试,或联系管理员。"}}</p>
|
||||||
<a href="/login" class="back-link">返回登录页</a>
|
<a href="/login" class="back-link">{{t .Lang "返回登录页"}}</a>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -433,22 +433,22 @@
|
|||||||
<div class="topbar-left">
|
<div class="topbar-left">
|
||||||
<a class="logo" href="/inbox">
|
<a class="logo" href="/inbox">
|
||||||
<span class="logo-icon">✉</span>
|
<span class="logo-icon">✉</span>
|
||||||
<span class="logo-text">MailGo<em>邮箱</em></span>
|
<span class="logo-text">MailGo<em>{{t .Lang "邮箱"}}</em></span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-search">
|
<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>
|
<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>
|
||||||
<div class="topbar-right">
|
<div class="topbar-right">
|
||||||
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">管理后台</a>{{end}}
|
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">{{t .Lang "管理后台"}}</a>{{end}}
|
||||||
<a class="topbar-link" href="/settings">设置</a>
|
<a class="topbar-link" href="/settings">{{t .Lang "设置"}}</a>
|
||||||
<span class="user-chip">
|
<span class="user-chip">
|
||||||
<span class="avatar avatar-sm" style="{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
|
<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 class="user-email" title="{{.currentUser.Username}}@{{.currentUser.Domain.Name}}">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
|
||||||
</span>
|
</span>
|
||||||
<form method="POST" action="/logout" class="logout-form">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -461,7 +461,7 @@
|
|||||||
<aside class="mail-sidebar">
|
<aside class="mail-sidebar">
|
||||||
<a href="/compose" class="compose-btn">
|
<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>
|
<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>
|
</a>
|
||||||
<nav class="folder-nav">
|
<nav class="folder-nav">
|
||||||
{{range .folders}}
|
{{range .folders}}
|
||||||
@@ -477,7 +477,7 @@
|
|||||||
{{else}}
|
{{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>
|
<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}}
|
{{end}}
|
||||||
{{folderLabel .Name}}
|
{{folderLabel $.Lang .Name}}
|
||||||
{{if eq .Name "INBOX"}}
|
{{if eq .Name "INBOX"}}
|
||||||
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
|
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
|
||||||
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
|
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
{{if .currentUser.IsAdmin}}
|
{{if .currentUser.IsAdmin}}
|
||||||
<a class="folder" href="/admin">
|
<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>
|
<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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -541,7 +541,7 @@
|
|||||||
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
|
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
|
||||||
.map(function (r) { return r.dataset.id; });
|
.map(function (r) { return r.dataset.id; });
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return;
|
if (!confirm('{{t .Lang "确定要删除选中的 %s 封邮件吗?"}}'.replace('%s', ids.length))) return;
|
||||||
var done = 0;
|
var done = 0;
|
||||||
ids.forEach(function (id) {
|
ids.forEach(function (id) {
|
||||||
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
|
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "compose"}}
|
{{define "compose"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>写信 - MailGo</title>
|
<title>{{t .Lang "写信"}} - MailGo</title>
|
||||||
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
@@ -16,44 +16,44 @@
|
|||||||
<div class="compose-toolbar">
|
<div class="compose-toolbar">
|
||||||
<button type="submit" form="compose-form" class="btn btn-primary">
|
<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>
|
<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>
|
</button>
|
||||||
<label class="tb-btn" style="cursor:pointer;">
|
<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>
|
<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;">
|
<input type="file" name="attachments" id="attach-input" multiple style="display:none;">
|
||||||
</label>
|
</label>
|
||||||
<a href="/inbox" class="tb-btn">取消</a>
|
<a href="/inbox" class="tb-btn">{{t .Lang "取消"}}</a>
|
||||||
<div class="toolbar-spacer"></div>
|
<div class="toolbar-spacer"></div>
|
||||||
<span class="page-info">撰写新邮件</span>
|
<span class="page-info">{{t .Lang "撰写新邮件"}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .error}}<div class="alert alert-error" style="margin:12px 18px 0;">{{.error}}</div>{{end}}
|
{{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">
|
<form id="compose-form" class="compose-form" method="POST" action="/compose" enctype="multipart/form-data">
|
||||||
<div class="compose-field">
|
<div class="compose-field">
|
||||||
<label>收件人</label>
|
<label>{{t .Lang "收件人"}}</label>
|
||||||
<input type="email" name="to" required value="{{.to}}" placeholder="输入收件人邮箱地址">
|
<input type="email" name="to" required value="{{.to}}" placeholder="{{t .Lang "输入收件人邮箱地址"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-field">
|
<div class="compose-field">
|
||||||
<label>抄送</label>
|
<label>{{t .Lang "抄送"}}</label>
|
||||||
<input type="text" name="cc" value="{{.cc}}" placeholder="多个地址用逗号分隔(可选)">
|
<input type="text" name="cc" value="{{.cc}}" placeholder="{{t .Lang "多个地址用逗号分隔(可选)"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="compose-field">
|
<div class="compose-field">
|
||||||
<label>主题</label>
|
<label>{{t .Lang "主题"}}</label>
|
||||||
<input type="text" name="subject" value="{{.subject}}" placeholder="输入邮件主题">
|
<input type="text" name="subject" value="{{.subject}}" placeholder="{{t .Lang "输入邮件主题"}}">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div>
|
<div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div>
|
||||||
|
|
||||||
<div class="editor-wrap">
|
<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="body" id="body-hidden">
|
||||||
<input type="hidden" name="html_body" id="html-body-hidden">
|
<input type="hidden" name="html_body" id="html-body-hidden">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="compose-footer">
|
<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 class="quota-bar" id="quota-bar" data-used="{{.usedBytes}}" data-quota="{{.quotaBytes}}"><i></i></span>
|
||||||
<span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span>
|
<span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
<script>
|
<script>
|
||||||
var quill = new Quill('#editor', {
|
var quill = new Quill('#editor', {
|
||||||
theme: 'snow',
|
theme: 'snow',
|
||||||
placeholder: document.getElementById('editor').dataset.placeholder || '请输入邮件内容...',
|
placeholder: document.getElementById('editor').dataset.placeholder || '{{t .Lang "请输入邮件内容..."}}',
|
||||||
modules: {
|
modules: {
|
||||||
toolbar: [
|
toolbar: [
|
||||||
[{ 'header': [1, 2, 3, false] }],
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
var chip = document.createElement('span');
|
var chip = document.createElement('span');
|
||||||
chip.className = 'attach-chip';
|
chip.className = 'attach-chip';
|
||||||
chip.innerHTML = '📎 ' + f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)' +
|
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);
|
chipsBox.appendChild(chip);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "folder"}}
|
{{define "folder"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>{{folderLabel .folder}} - MailGo</title>
|
<title>{{folderLabel .Lang .folder}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body class="page-list">
|
<body class="page-list">
|
||||||
@@ -13,31 +13,31 @@
|
|||||||
{{template "sidebar" .}}
|
{{template "sidebar" .}}
|
||||||
<main class="mail-main">
|
<main class="mail-main">
|
||||||
<div class="list-toolbar">
|
<div class="list-toolbar">
|
||||||
<label class="check-all" title="全选/取消全选">
|
<label class="check-all" title="{{t .Lang "全选/取消全选"}}">
|
||||||
<input type="checkbox" id="select-all">
|
<input type="checkbox" id="select-all">
|
||||||
全选
|
{{t .Lang "全选"}}
|
||||||
</label>
|
</label>
|
||||||
<button type="button" class="tb-btn" id="btn-refresh" title="刷新">
|
<button type="button" class="tb-btn" id="btn-refresh" title="{{t .Lang "刷新"}}">
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
|
<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>
|
||||||
<button type="button" class="tb-btn danger" id="btn-delete" disabled>
|
<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>
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
{{if .isTrash}}彻底删除{{else}}删除{{end}}
|
{{if .isTrash}}{{t $.Lang "彻底删除"}}{{else}}{{t $.Lang "删除"}}{{end}}
|
||||||
</button>
|
</button>
|
||||||
{{if .isTrash}}
|
{{if .isTrash}}
|
||||||
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
|
<form method="POST" action="/folder/Trash/empty" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');">
|
onsubmit="return confirm('{{t $.Lang "确定要清空「已删除」文件夹吗?此操作不可恢复。"}}');">
|
||||||
<button type="submit" class="tb-btn danger">清空文件夹</button>
|
<button type="submit" class="tb-btn danger">{{t $.Lang "清空文件夹"}}</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
<div class="toolbar-spacer"></div>
|
<div class="toolbar-spacer"></div>
|
||||||
<span class="page-info">共 {{.total}} 封</span>
|
<span class="page-info">{{tf .Lang "共 %d 封" .total}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if not .messages}}
|
{{if not .messages}}
|
||||||
<div class="mail-list">
|
<div class="mail-list">
|
||||||
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div>
|
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel $.Lang $.folder}} · {{t $.Lang "暂无邮件"}}</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
<ul class="mail-list">
|
<ul class="mail-list">
|
||||||
@@ -53,9 +53,9 @@
|
|||||||
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
|
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
|
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="{{tf $.Lang "收件人:%s" .ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
|
||||||
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
|
||||||
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}}
|
{{if eq $.folder "Drafts"}}{{t $.Lang "致:"}}{{end}}{{mailName .ToAddr}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{mailName (decodeHeader .FromAddr)}}
|
{{mailName (decodeHeader .FromAddr)}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -63,24 +63,24 @@
|
|||||||
<span class="cell-subject-wrap">
|
<span class="cell-subject-wrap">
|
||||||
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
|
{{if not .IsRead}}<span class="unread-dot"></span>{{end}}
|
||||||
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
|
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
|
||||||
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}}
|
{{if .Subject}}{{.Subject}}{{else}}{{t $.Lang "(无主题)"}}{{end}}
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span class="cell-snippet">
|
<span class="cell-snippet">
|
||||||
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}}
|
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}{{t $.Lang "[HTML 邮件]"}}{{end}}
|
||||||
</span>
|
</span>
|
||||||
<span class="cell-date">{{shortDate .Date}}</span>
|
<span class="cell-date">{{shortDate $.Lang .Date}}</span>
|
||||||
{{if $.isTrash}}
|
{{if $.isTrash}}
|
||||||
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
|
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
|
||||||
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');">
|
onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要恢复这封邮件吗?"}}');">
|
||||||
<button type="submit" class="icon-btn" title="恢复到收件箱">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
|
||||||
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');">
|
onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要删除这封邮件吗?"}}');">
|
||||||
<button type="submit" class="icon-btn" title="删除">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -91,23 +91,23 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<div class="list-footer">
|
<div class="list-footer">
|
||||||
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span>
|
<span class="page-num">{{tf .Lang "第 %d / %d 页" .page (or .totalPages 1)}}</span>
|
||||||
<div class="pager">
|
<div class="pager">
|
||||||
{{if gt .page 1}}
|
{{if gt .page 1}}
|
||||||
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .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>
|
<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>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="page-btn disabled">上一页</span>
|
<span class="page-btn disabled">{{t $.Lang "上一页"}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{if lt .page .totalPages}}
|
{{if lt .page .totalPages}}
|
||||||
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
|
<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>
|
<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>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="page-btn disabled">下一页</span>
|
<span class="page-btn disabled">{{t $.Lang "下一页"}}</span>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
{{define "login"}}
|
{{define "login"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<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" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body class="login-page">
|
<body class="login-page">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="login-logo">
|
<div class="login-logo">
|
||||||
<span class="logo-icon" style="width:44px;height:44px;font-size:22px;border-radius:12px;">✉</span>
|
<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>
|
||||||
<div class="login-sub">登录您的邮箱账户</div>
|
<div class="login-sub">{{t .Lang "登录您的邮箱账户"}}</div>
|
||||||
|
|
||||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/login">
|
<form method="POST" action="/login">
|
||||||
<div class="form-group">
|
<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;">
|
<input type="email" name="email" required autofocus placeholder="user@example.com" style="padding:11px 14px;border-radius:8px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>密码</label>
|
<label>{{t .Lang "密码"}}</label>
|
||||||
<input type="password" name="password" required placeholder="请输入密码" style="padding:11px 14px;border-radius:8px;">
|
<input type="password" name="password" required placeholder="{{t .Lang "请输入密码"}}" style="padding:11px 14px;border-radius:8px;">
|
||||||
</div>
|
</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>
|
</form>
|
||||||
|
|
||||||
{{if or .oauth2Enabled .ldapEnabled}}
|
{{if or .oauth2Enabled .ldapEnabled}}
|
||||||
<div class="divider">或</div>
|
<div class="divider">{{t .Lang "或"}}</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .ldapEnabled}}
|
{{if .ldapEnabled}}
|
||||||
<form method="POST" action="/login/ldap" style="margin-bottom:10px;">
|
<form method="POST" action="/login/ldap" style="margin-bottom:10px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>LDAP 用户名</label>
|
<label>{{t .Lang "LDAP 用户名"}}</label>
|
||||||
<input type="text" name="username" placeholder="LDAP 用户名">
|
<input type="text" name="username" placeholder="{{t .Lang "LDAP 用户名"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>LDAP 密码</label>
|
<label>{{t .Lang "LDAP 密码"}}</label>
|
||||||
<input type="password" name="password" placeholder="LDAP 密码">
|
<input type="password" name="password" placeholder="{{t .Lang "LDAP 密码"}}">
|
||||||
</div>
|
</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>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{if .oauth2Enabled}}
|
{{if .oauth2Enabled}}
|
||||||
<a href="/auth/oauth2" class="btn btn-primary" style="width:100%;text-align:center;display:block;margin-top:4px;">
|
<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>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "settings"}}
|
{{define "settings"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>设置 - MailGo</title>
|
<title>{{t .Lang "设置"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body class="page-settings">
|
<body class="page-settings">
|
||||||
@@ -15,21 +15,21 @@
|
|||||||
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
|
||||||
{{if .success}}<div class="alert alert-success">{{.success}}</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;">
|
{{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>{{end}}
|
||||||
|
|
||||||
<div class="card" style="max-width:720px;">
|
<div class="card" style="max-width:720px;">
|
||||||
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
|
<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>
|
<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>
|
</h2>
|
||||||
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
|
<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>
|
<span class="avatar" style="width:52px;height:52px;font-size:22px;{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
|
||||||
<div>
|
<div>
|
||||||
<div style="font-size:16px;font-weight:600;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</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;">
|
<div style="color:var(--text-3);font-size:12.5px;margin-top:3px;">
|
||||||
已用 {{formatBytes .currentUser.UsedBytes}} / 配额 {{formatBytes .currentUser.QuotaBytes}}
|
{{tf .Lang "已用 %s / 配额 %s" (formatBytes .currentUser.UsedBytes) (formatBytes .currentUser.QuotaBytes)}}
|
||||||
{{if .currentUser.IsAdmin}} · 管理员{{end}}
|
{{if .currentUser.IsAdmin}} · {{t .Lang "管理员"}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,30 +37,46 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="max-width:720px;">
|
<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;">
|
<form method="POST" action="/settings" style="max-width:420px;">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>当前密码</label>
|
<label>{{t .Lang "界面语言"}}</label>
|
||||||
<input type="password" name="old_password" required placeholder="请输入当前密码" autocomplete="current-password">
|
<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>
|
||||||
<div class="form-group">
|
<button type="submit" class="btn btn-primary">{{t .Lang "保存"}}</button>
|
||||||
<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>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card" style="max-width:720px;">
|
<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;">
|
<p style="color:var(--text-2);font-size:13.5px;line-height:1.9;">
|
||||||
客户端收发信(IMAP / SMTP)配置:<br>
|
{{t .Lang "客户端收发信(IMAP / SMTP)配置:"}}<br>
|
||||||
IMAP 服务器:{{.currentUser.Domain.Name}} 端口 143 / SSL 993<br>
|
{{tf .Lang "IMAP 服务器:%s 端口 %d / SSL %d" .currentUser.Domain.Name 143 993}}<br>
|
||||||
SMTP 服务器:{{.currentUser.Domain.Name}} 端口 587(提交)/ SSL 465
|
{{tf .Lang "SMTP 服务器:%s 端口 %d(提交)/ SSL %d" .currentUser.Domain.Name 587 465}}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{{define "view"}}
|
{{define "view"}}
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="{{htmlLang .Lang}}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>查看邮件 - MailGo</title>
|
<title>{{t .Lang "查看邮件"}} - MailGo</title>
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body class="page-view">
|
<body class="page-view">
|
||||||
@@ -15,50 +15,50 @@
|
|||||||
<div class="view-toolbar">
|
<div class="view-toolbar">
|
||||||
<a href="javascript:history.back()" class="tb-btn">
|
<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>
|
<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>
|
||||||
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="tb-btn">
|
<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>
|
<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>
|
</a>
|
||||||
{{if .inTrash}}
|
{{if .inTrash}}
|
||||||
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
||||||
<button type="submit" class="tb-btn">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
|
onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗?此操作不可恢复。"}}');">
|
||||||
<button type="submit" class="tb-btn danger">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗?"}}');">
|
||||||
<button type="submit" class="tb-btn danger">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mail-head">
|
<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">
|
<div class="mail-from-row">
|
||||||
<span class="avatar" style="{{avatarStyle .message.FromAddr}}">{{initial (mailName (decodeHeader .message.FromAddr))}}</span>
|
<span class="avatar" style="{{avatarStyle .message.FromAddr}}">{{initial (mailName (decodeHeader .message.FromAddr))}}</span>
|
||||||
<div>
|
<div>
|
||||||
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</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 class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="mail-date">{{time12 .message.Date}}</span>
|
<span class="mail-date">{{time12 .Lang .message.Date}}</span>
|
||||||
</div>
|
</div>
|
||||||
{{if .message.CcAddr}}
|
{{if .message.CcAddr}}
|
||||||
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
|
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
|
||||||
抄送:{{.message.CcAddr}}
|
{{t .Lang "抄送:"}}{{.message.CcAddr}}
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,28 +88,28 @@
|
|||||||
<div class="view-actions">
|
<div class="view-actions">
|
||||||
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary">
|
<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>
|
<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>
|
</a>
|
||||||
{{if .inTrash}}
|
{{if .inTrash}}
|
||||||
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
|
||||||
<button type="submit" class="btn">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要彻底删除这封邮件吗?此操作不可恢复。');">
|
onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗?此操作不可恢复。"}}');">
|
||||||
<button type="submit" class="btn btn-danger">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
|
||||||
onsubmit="return confirm('确定要删除这封邮件吗?');">
|
onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗?"}}');">
|
||||||
<button type="submit" class="btn btn-danger">
|
<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>
|
<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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func TestTime12Format(t *testing.T) {
|
|||||||
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
|
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
if got := time12(tc.in); got != tc.want {
|
if got := time12("zh", tc.in); got != tc.want {
|
||||||
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
|
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ func TestTime12Format(t *testing.T) {
|
|||||||
func TestTime12mNoSeconds(t *testing.T) {
|
func TestTime12mNoSeconds(t *testing.T) {
|
||||||
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
|
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
|
||||||
in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC)
|
in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC)
|
||||||
if got, want := time12m(in), "2026-08-20 下午 6:30"; got != want {
|
if got, want := time12m("zh", in), "2026-08-20 下午 6:30"; got != want {
|
||||||
t.Fatalf("time12m = %q, want %q", got, want)
|
t.Fatalf("time12m = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func TestShortDate12Hour(t *testing.T) {
|
|||||||
|
|
||||||
// 今天 → 「下午 2:35」(无日期)
|
// 今天 → 「下午 2:35」(无日期)
|
||||||
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
|
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
|
||||||
if got, want := shortDate(today), "下午 2:35"; got != want {
|
if got, want := shortDate("zh", today), "下午 2:35"; got != want {
|
||||||
t.Fatalf("shortDate(today) = %q, want %q", got, want)
|
t.Fatalf("shortDate(today) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,13 +62,13 @@ func TestShortDate12Hour(t *testing.T) {
|
|||||||
if day.YearDay() == now.YearDay() {
|
if day.YearDay() == now.YearDay() {
|
||||||
day = day.AddDate(0, 0, 1)
|
day = day.AddDate(0, 0, 1)
|
||||||
}
|
}
|
||||||
if got, want := shortDate(day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
|
if got, want := shortDate("zh", day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
|
||||||
t.Fatalf("shortDate(thisYear) = %q, want %q", got, want)
|
t.Fatalf("shortDate(thisYear) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 往年 → 「YYYY-MM-DD 上午 9:05」
|
// 往年 → 「YYYY-MM-DD 上午 9:05」
|
||||||
older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC()
|
older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC()
|
||||||
if got, want := shortDate(older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
|
if got, want := shortDate("zh", older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
|
||||||
t.Fatalf("shortDate(older) = %q, want %q", got, want)
|
t.Fatalf("shortDate(older) = %q, want %q", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user