diff --git a/README.md b/README.md index cf1143f..6da5e7a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Web frontend layout: top navigation + left folder sidebar + mail list (three-col - **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) diff --git a/README_cn.md b/README_cn.md index b157ccd..79143ef 100644 --- a/README_cn.md +++ b/README_cn.md @@ -9,6 +9,7 @@ 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 天,自动清理) diff --git a/internal/db/models.go b/internal/db/models.go index f00f300..3393729 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -6,20 +6,23 @@ import ( // User represents a mail user in the system. type User struct { - ID uint `gorm:"primaryKey" json:"id"` - Username string `gorm:"size:64;not null" json:"username"` - PasswordHash string `gorm:"size:255;not null" json:"-"` - DomainID uint `gorm:"index" json:"domain_id"` - Domain Domain `gorm:"foreignKey:DomainID" json:"domain"` - QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"` - UsedBytes int64 `gorm:"default:0" json:"used_bytes"` - IsActive bool `gorm:"default:true" json:"is_active"` - IsAdmin bool `gorm:"default:false" json:"is_admin"` + ID uint `gorm:"primaryKey" json:"id"` + Username string `gorm:"size:64;not null" json:"username"` + PasswordHash string `gorm:"size:255;not null" json:"-"` + DomainID uint `gorm:"index" json:"domain_id"` + Domain Domain `gorm:"foreignKey:DomainID" json:"domain"` + QuotaBytes int64 `gorm:"default:5368709120" json:"quota_bytes"` + UsedBytes int64 `gorm:"default:0" json:"used_bytes"` + IsActive bool `gorm:"default:true" json:"is_active"` + IsAdmin bool `gorm:"default:false" json:"is_admin"` // MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的 // 用户)在首次登录后必须修改密码。 - MustChangePassword bool `gorm:"default:false" json:"-"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + MustChangePassword bool `gorm:"default:false" json:"-"` + // Language 界面语言偏好:auto(跟随浏览器 Accept-Language)| en | zh | ja。 + // 默认 auto,浏览器语言无法识别时回退英语。 + Language string `gorm:"size:8;default:auto" json:"language"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // TableName specifies the table name for User. @@ -51,20 +54,20 @@ func (Domain) TableName() string { // Message represents an email message in the system. type Message struct { - ID uint `gorm:"primaryKey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` - User User `gorm:"foreignKey:UserID" json:"user"` - MessageID string `gorm:"size:255;index" json:"message_id"` - Folder string `gorm:"size:64;default:INBOX;index" json:"folder"` - FromAddr string `gorm:"size:512;not null" json:"from_addr"` - ToAddr string `gorm:"size:2048;not null" json:"to_addr"` - CcAddr string `gorm:"size:2048" json:"cc_addr"` - Subject string `gorm:"size:1024" json:"subject"` - TextBody string `gorm:"type:mediumtext" json:"text_body"` - HtmlBody string `gorm:"type:mediumtext" json:"html_body"` - RawData string `gorm:"type:mediumtext" json:"raw_data"` - IsRead bool `gorm:"default:false" json:"is_read"` - IsFlagged bool `gorm:"default:false" json:"is_flagged"` + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + User User `gorm:"foreignKey:UserID" json:"user"` + MessageID string `gorm:"size:255;index" json:"message_id"` + Folder string `gorm:"size:64;default:INBOX;index" json:"folder"` + FromAddr string `gorm:"size:512;not null" json:"from_addr"` + ToAddr string `gorm:"size:2048;not null" json:"to_addr"` + CcAddr string `gorm:"size:2048" json:"cc_addr"` + Subject string `gorm:"size:1024" json:"subject"` + TextBody string `gorm:"type:mediumtext" json:"text_body"` + HtmlBody string `gorm:"type:mediumtext" json:"html_body"` + RawData string `gorm:"type:mediumtext" json:"raw_data"` + IsRead bool `gorm:"default:false" json:"is_read"` + IsFlagged bool `gorm:"default:false" json:"is_flagged"` // IsDeleted 持久化 IMAP \Deleted 标记(STORE +FLAGS \Deleted 写入, // EXPUNGE/UID EXPUNGE 时按此删除)。此前该标记只存于内存会话 // (imapMailbox.deleted map),重选文件夹/重连即丢失,导致客户端 @@ -118,9 +121,9 @@ type BanEntry struct { ID uint `gorm:"primaryKey" json:"id"` // IPAddress 唯一索引:每 IP 恰好一条记录(观察计数与封禁状态共用), // 防止并发写入产生重复行导致计数与档位读写错位。 - IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"` - Reason string `gorm:"size:255" json:"reason"` - FailCount int `gorm:"default:0" json:"fail_count"` + IPAddress string `gorm:"size:45;uniqueIndex;not null" json:"ip_address"` + Reason string `gorm:"size:255" json:"reason"` + FailCount int `gorm:"default:0" json:"fail_count"` // BanCount 是该 IP 累计达到失败阈值的次数(含未封禁的前几次)。 // 阶段封禁依据:前 3 次只计数不封禁,第 4 次起按档位递增时长。 // 成功登录或管理员解封会删除记录,次数随之清零。 diff --git a/internal/i18n/catalog.go b/internal/i18n/catalog.go new file mode 100644 index 0000000..1d307ce --- /dev/null +++ b/internal/i18n/catalog.go @@ -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", + "禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です", +} diff --git a/internal/i18n/i18n.go b/internal/i18n/i18n.go new file mode 100644 index 0000000..943c57c --- /dev/null +++ b/internal/i18n/i18n.go @@ -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...) +} diff --git a/internal/i18n/i18n_test.go b/internal/i18n/i18n_test.go new file mode 100644 index 0000000..9010920 --- /dev/null +++ b/internal/i18n/i18n_test.go @@ -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) + } + } +} diff --git a/internal/store/user_store.go b/internal/store/user_store.go index 63c8639..ab39fd4 100644 --- a/internal/store/user_store.go +++ b/internal/store/user_store.go @@ -30,6 +30,8 @@ type UserStore interface { ListAll(page, size int) ([]db.User, int64, error) UpdateUsedBytes(id uint, delta int64) error UpdatePassword(userID uint, hashedPassword string) error + // UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。 + UpdateLanguage(userID uint, lang string) error // TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true, // 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。 TryReserveQuota(userID uint, delta int64) (bool, error) @@ -204,11 +206,22 @@ func (s *userStoreGorm) TryReserveQuota(userID uint, delta int64) (bool, error) func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error { return s.db.Model(&db.User{}).Where("id = ?", userID). Updates(map[string]interface{}{ - "password_hash": hashedPassword, + "password_hash": hashedPassword, "must_change_password": false, }).Error } +// UpdateLanguage 更新用户界面语言偏好。非法值(非 auto/en/zh/ja)回退 auto。 +func (s *userStoreGorm) UpdateLanguage(userID uint, lang string) error { + switch lang { + case "auto", "en", "zh", "ja": + default: + lang = "auto" + } + return s.db.Model(&db.User{}).Where("id = ?", userID). + Update("language", lang).Error +} + // ListAll retrieves a paginated list of all users across all domains. func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) { var users []db.User diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 2bce2be..679069f 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -16,6 +16,7 @@ import ( "mail_go/internal/connhub" "mail_go/internal/db" "mail_go/internal/dkim" + "mail_go/internal/i18n" "mail_go/internal/outbound" "mail_go/internal/storage" "mail_go/internal/store" @@ -69,13 +70,13 @@ const manualBanDuration = 180 * 24 * time.Hour func (h *AdminHandler) DisconnectConnection(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的连接ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的连接ID")) return } conn, ok := h.hub.Get(id) if !ok { - c.String(http.StatusNotFound, "连接不存在或已断开") + c.String(http.StatusNotFound, i18n.T(langOf(c), "连接不存在或已断开")) return } @@ -83,7 +84,7 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) { // BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条, // 避免与阶段性封禁的计数/档位记录错位。 if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil { - c.String(http.StatusInternalServerError, "封禁失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "封禁失败: %v", err)) return } @@ -105,7 +106,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_connections", gin.H{ + c.HTML(200, "admin_connections", withLang(c, gin.H{ "currentUser": currentUser, "conns": conns, "total": total, @@ -114,7 +115,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) { "pop3Count": pop3Count, "now": time.Now(), "activeFolder": "connections", - }) + })) } // Dashboard renders the admin dashboard with summary statistics. @@ -153,7 +154,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_dashboard", gin.H{ + c.HTML(200, "admin_dashboard", withLang(c, gin.H{ "currentUser": currentUser, "domainCount": domainCount, "userCount": userCount, @@ -171,7 +172,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) { "weekSent": weekSent, "banCount": banCount, "activeFolder": "admin", - }) + })) } // ListDomains renders the domain list page. @@ -179,7 +180,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) { page := getPageParam(c, "page", 1) domains, total, err := h.stores.Domains.List(page, 20) if err != nil { - c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载域名列表失败: %v", err)) return } @@ -193,7 +194,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) { totalPages = 0 } - c.HTML(200, "admin_domains", gin.H{ + c.HTML(200, "admin_domains", withLang(c, gin.H{ "currentUser": currentUser, "domains": domains, "total": total, @@ -201,20 +202,20 @@ func (h *AdminHandler) ListDomains(c *gin.Context) { "pageSize": 20, "totalPages": totalPages, "activeFolder": "domains", - }) + })) } // NewDomain renders the new domain form page. func (h *AdminHandler) NewDomain(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_domain_form", gin.H{ + c.HTML(200, "admin_domain_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", "error": "", "isEdit": false, "domain": &db.Domain{}, - }) + })) } // CreateDomain processes the new domain form submission. @@ -227,10 +228,10 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { if name == "" { currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", - "error": "请输入域名", + "error": i18n.T(langOf(c), "请输入域名"), "isEdit": false, "domain": &db.Domain{ Name: name, @@ -239,7 +240,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { Pop3Port: pop3Port, TlsEnabled: tlsEnabled, }, - }) + })) return } @@ -263,13 +264,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { if err := h.stores.Domains.Create(domain); err != nil { currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", - "error": fmt.Sprintf("创建域名失败: %v", err), + "error": i18n.TF(langOf(c), "创建域名失败: %v", err), "isEdit": false, "domain": domain, - }) + })) return } @@ -280,13 +281,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { func (h *AdminHandler) EditDomain(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的域名ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID")) return } domain, err := h.stores.Domains.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "域名不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在")) return } @@ -297,11 +298,11 @@ func (h *AdminHandler) EditDomain(c *gin.Context) { caddyMsg = c.Query("caddy_err") caddyMsgType = "error" } else if c.Query("caddy_ok") == "1" { - caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。" + caddyMsg = i18n.T(langOf(c), "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。") caddyMsgType = "success" } - c.HTML(200, "admin_domain_form", gin.H{ + c.HTML(200, "admin_domain_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", "error": "", @@ -311,20 +312,20 @@ func (h *AdminHandler) EditDomain(c *gin.Context) { "tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "", "caddyMsg": caddyMsg, "caddyMsgType": caddyMsgType, - }) + })) } // UpdateDomain 处理编辑域名表单提交 func (h *AdminHandler) UpdateDomain(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的域名ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID")) return } domain, err := h.stores.Domains.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "域名不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在")) return } @@ -335,7 +336,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) { tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key")) tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert")) - if err := h.handleDomainTLSUpdate(domain, tlsPublicCert, tlsPrivateKey); err != nil { + if err := h.handleDomainTLSUpdate(langOf(c), domain, tlsPublicCert, tlsPrivateKey); err != nil { h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert) return } @@ -351,7 +352,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) { } if err := h.stores.Domains.Update(domain); err != nil { - h.renderDomainFormError(c, domain, fmt.Sprintf("更新域名失败: %v", err), tlsPublicCert) + h.renderDomainFormError(c, domain, i18n.TF(langOf(c), "更新域名失败: %v", err), tlsPublicCert) return } @@ -364,43 +365,45 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) { func (h *AdminHandler) FetchCaddyCert(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的域名ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID")) return } domain, err := h.stores.Domains.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "域名不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在")) return } + lang := langOf(c) editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID) - fail := func(msg string) { + fail := func(key string, args ...interface{}) { + msg := i18n.TF(lang, key, args...) log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg) c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg)) } cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots()) if err != nil { - fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err)) + fail("从 Caddy 获取证书失败: %v", err) return } // 保存到域名 TLS 目录(与手动上传证书的位置一致) domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10)) if err := os.MkdirAll(domainTLSDir, 0700); err != nil { - fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err)) + fail("创建 TLS 证书目录失败: %v", err) return } certPath := filepath.Join(domainTLSDir, "cert.pem") keyPath := filepath.Join(domainTLSDir, "key.pem") if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil { - fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err)) + fail("保存 TLS 公钥证书失败: %v", err) return } if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil { - fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err)) + fail("保存 TLS 私钥失败: %v", err) return } @@ -408,7 +411,7 @@ func (h *AdminHandler) FetchCaddyCert(c *gin.Context) { domain.TlsKeyPath = keyPath domain.TlsEnabled = true if err := h.stores.Domains.Update(domain); err != nil { - fail(fmt.Sprintf("更新域名记录失败: %v", err)) + fail("更新域名记录失败: %v", err) return } @@ -451,7 +454,7 @@ func normalizePEM(s string) string { return s } -func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error { +func (h *AdminHandler) handleDomainTLSUpdate(lang string, domain *db.Domain, publicCert, privateKey string) error { // 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改 publicCert = normalizePEM(strings.TrimSpace(publicCert)) privateKey = normalizePEM(strings.TrimSpace(privateKey)) @@ -465,31 +468,31 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv if hasExistingCert { return nil } - return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书") + return fmt.Errorf("%s", i18n.T(lang, "启用 TLS 时必须填写 TLS 私钥和公钥证书")) } if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert { return nil } if publicCert == "" || privateKey == "" { - return fmt.Errorf("TLS 私钥和公钥证书必须同时填写") + return fmt.Errorf("%s", i18n.T(lang, "TLS 私钥和公钥证书必须同时填写")) } if _, err := tls.X509KeyPair([]byte(publicCert), []byte(privateKey)); err != nil { - return fmt.Errorf("TLS 证书或私钥无效: %v", err) + return fmt.Errorf("%s", i18n.TF(lang, "TLS 证书或私钥无效: %v", err)) } domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10)) if err := os.MkdirAll(domainTLSDir, 0700); err != nil { - return fmt.Errorf("创建 TLS 证书目录失败: %v", err) + return fmt.Errorf("%s", i18n.TF(lang, "创建 TLS 证书目录失败: %v", err)) } certPath := filepath.Join(domainTLSDir, "cert.pem") keyPath := filepath.Join(domainTLSDir, "key.pem") if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil { - return fmt.Errorf("保存 TLS 公钥证书失败: %v", err) + return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 公钥证书失败: %v", err)) } if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil { - return fmt.Errorf("保存 TLS 私钥失败: %v", err) + return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 私钥失败: %v", err)) } domain.TlsCertPath = certPath @@ -503,7 +506,7 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain, tlsPublicCert = readTLSCert(domain.TlsCertPath) } - c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", "error": message, @@ -511,19 +514,19 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain, "domain": domain, "tlsPublicCert": tlsPublicCert, "tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "", - }) + })) } // DeleteDomain removes a domain by ID. func (h *AdminHandler) DeleteDomain(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的域名ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID")) return } if err := h.stores.Domains.Delete(uint(id)); err != nil { - c.String(http.StatusInternalServerError, "删除域名失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除域名失败: %v", err)) return } @@ -534,24 +537,24 @@ func (h *AdminHandler) DeleteDomain(c *gin.Context) { func (h *AdminHandler) DNSHint(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的域名ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID")) return } domain, err := h.stores.Domains.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "域名不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在")) return } currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_dns_hint", gin.H{ + c.HTML(200, "admin_dns_hint", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "domains", "domain": domain, "dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey), - }) + })) } // ListUsers renders the user list page. @@ -560,7 +563,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { users, total, err := h.stores.Users.ListAll(page, 20) if err != nil { - c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载用户列表失败: %v", err)) return } @@ -577,7 +580,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { totalPages = 0 } - c.HTML(200, "admin_users", gin.H{ + c.HTML(200, "admin_users", withLang(c, gin.H{ "currentUser": currentUser, "users": users, "domains": domains, @@ -586,7 +589,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { "pageSize": 20, "totalPages": totalPages, "activeFolder": "users", - }) + })) } // NewUser renders the new user form page. @@ -595,14 +598,14 @@ func (h *AdminHandler) NewUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_user_form", gin.H{ + c.HTML(200, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", "error": "", "isEdit": false, "domains": domains, "user": &db.User{}, - }) + })) } // CreateUser processes the new user form submission. @@ -616,10 +619,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { if username == "" || password == "" || domainID == 0 { domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", - "error": "请填写所有必填字段", + "error": i18n.T(langOf(c), "请填写所有必填字段"), "isEdit": false, "domains": domains, "user": &db.User{ @@ -627,7 +630,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { DomainID: domainID, IsAdmin: isAdmin, }, - }) + })) return } @@ -636,10 +639,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { if err != nil { domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ + c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", - "error": "密码加密失败", + "error": i18n.T(langOf(c), "密码加密失败"), "isEdit": false, "domains": domains, "user": &db.User{ @@ -647,7 +650,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { DomainID: domainID, IsAdmin: isAdmin, }, - }) + })) return } @@ -661,19 +664,20 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { UsedBytes: 0, IsActive: true, IsAdmin: isAdmin, + Language: normLang(c.PostForm("language")), } if err := h.stores.Users.Create(user); err != nil { domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", - "error": fmt.Sprintf("创建用户失败: %v", err), + "error": i18n.TF(langOf(c), "创建用户失败: %v", err), "isEdit": false, "domains": domains, "user": user, - }) + })) return } @@ -684,18 +688,18 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { func (h *AdminHandler) DeleteUser(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的用户ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID")) return } currentUser, _ := c.Get("currentUser") if currentUser.(*db.User).ID == uint(id) { - c.String(http.StatusBadRequest, "不能删除自己的账户") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "不能删除自己的账户")) return } if err := h.stores.Users.Delete(uint(id)); err != nil { - c.String(http.StatusInternalServerError, "删除用户失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除用户失败: %v", err)) return } @@ -706,40 +710,40 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) { func (h *AdminHandler) EditUser(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的用户ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID")) return } user, err := h.stores.Users.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "用户不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在")) return } domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_user_form", gin.H{ + c.HTML(200, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", "error": "", "isEdit": true, "domains": domains, "user": user, - }) + })) } // UpdateUser processes the edit user form submission. func (h *AdminHandler) UpdateUser(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的用户ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID")) return } user, err := h.stores.Users.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "用户不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在")) return } @@ -757,6 +761,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024 user.IsActive = isActive user.IsAdmin = isAdmin + // 界面语言偏好(表单未提交时保持原值) + if lang := c.PostForm("language"); lang != "" { + user.Language = normLang(lang) + } // Update password only if a new one is provided if password != "" { @@ -764,14 +772,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { if err != nil { domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ + c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", - "error": "密码加密失败", + "error": i18n.T(langOf(c), "密码加密失败"), "isEdit": true, "domains": domains, "user": user, - }) + })) return } user.PasswordHash = string(hashedPassword) @@ -782,14 +790,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { if err := h.stores.Users.Update(user); err != nil { domains, _, _ := h.stores.Domains.List(1, 1000) currentUser, _ := c.Get("currentUser") - c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ + c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "users", - "error": fmt.Sprintf("更新用户失败: %v", err), + "error": i18n.TF(langOf(c), "更新用户失败: %v", err), "isEdit": true, "domains": domains, "user": user, - }) + })) return } @@ -802,7 +810,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) { bans, total, err := h.stores.Bans.List(page, 20) if err != nil { - c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载黑名单失败: %v", err)) return } @@ -823,7 +831,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) { totalPages = 0 } - c.HTML(200, "admin_bans", gin.H{ + c.HTML(200, "admin_bans", withLang(c, gin.H{ "currentUser": currentUser, "rows": rows, "total": total, @@ -831,7 +839,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) { "pageSize": 20, "totalPages": totalPages, "activeFolder": "bans", - }) + })) } // banRow 是黑名单列表行:附带了当前是否封禁中的标记。 @@ -844,12 +852,12 @@ type banRow struct { func (h *AdminHandler) UnbanIP(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的记录ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的记录ID")) return } if err := h.stores.Bans.Delete(uint(id)); err != nil { - c.String(http.StatusInternalServerError, "解封失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "解封失败: %v", err)) return } @@ -892,7 +900,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter) if err != nil { - c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载协议日志失败: %v", err)) return } @@ -928,7 +936,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { "to": url.QueryEscape(c.Query("to")), } - c.HTML(200, "admin_protocol_logs", gin.H{ + c.HTML(200, "admin_protocol_logs", withLang(c, gin.H{ "currentUser": currentUser, "logs": logs, "total": total, @@ -940,7 +948,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) { "allStats": normStats(allStats), "keepDays": h.protocolLogKeepDays, "activeFolder": "protocol-logs", - }) + })) } // CleanupProtocolLogs 手动清理超出保留天数的协议日志。 @@ -978,7 +986,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) { } if err != nil { - c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err)) return } @@ -992,7 +1000,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) { totalPages = 0 } - c.HTML(200, "admin_mails", gin.H{ + c.HTML(200, "admin_mails", withLang(c, gin.H{ "currentUser": currentUser, "messages": messages, "total": total, @@ -1001,20 +1009,20 @@ func (h *AdminHandler) ListMails(c *gin.Context) { "totalPages": totalPages, "folder": folder, "activeFolder": "mails", - }) + })) } // AdminViewMail renders the detail view of a specific mail for admin. func (h *AdminHandler) AdminViewMail(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "邮件不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在")) return } @@ -1023,31 +1031,31 @@ func (h *AdminHandler) AdminViewMail(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_mail_view", gin.H{ + c.HTML(200, "admin_mail_view", withLang(c, gin.H{ "currentUser": currentUser, "message": msg, "attachments": attachments, "activeFolder": "mails", - }) + })) } // AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check). func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的附件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID")) return } att, err := h.stores.Attachments.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "附件不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在")) return } data, err := h.storage.Read(att.FilePath) if err != nil { - c.String(http.StatusInternalServerError, "读取附件失败") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败")) return } @@ -1062,7 +1070,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) { items, total, err := h.stores.Outbound.List(page, 20, status) if err != nil { - c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载外发队列失败: %v", err)) return } @@ -1088,7 +1096,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "admin_outbound", gin.H{ + c.HTML(200, "admin_outbound", withLang(c, gin.H{ "currentUser": currentUser, "items": items, "total": total, @@ -1099,23 +1107,23 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) { "statCounts": statCounts, "statusText": outbound.StatusText, "activeFolder": "outbound", - }) + })) } // RetryOutbound resets an outbound queue item for immediate redelivery. func (h *AdminHandler) RetryOutbound(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的队列ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID")) return } if h.outbound == nil { - c.String(http.StatusInternalServerError, "外发服务不可用") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用")) return } if err := h.outbound.Retry(uint(id)); err != nil { - c.String(http.StatusInternalServerError, "重试失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "重试失败: %v", err)) return } @@ -1126,16 +1134,16 @@ func (h *AdminHandler) RetryOutbound(c *gin.Context) { func (h *AdminHandler) CancelOutbound(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的队列ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID")) return } if h.outbound == nil { - c.String(http.StatusInternalServerError, "外发服务不可用") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用")) return } if err := h.outbound.Cancel(uint(id)); err != nil { - c.String(http.StatusInternalServerError, "取消失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "取消失败: %v", err)) return } diff --git a/internal/web/handlers/admin_tls_test.go b/internal/web/handlers/admin_tls_test.go index 68e5b26..aeb8884 100644 --- a/internal/web/handlers/admin_tls_test.go +++ b/internal/web/handlers/admin_tls_test.go @@ -83,7 +83,7 @@ func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) { domain := writeExistingCert(t, h.tlsDir, certLF, keyLF) submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交 - if err := h.handleDomainTLSUpdate(domain, submittedCert, ""); err != nil { + if err := h.handleDomainTLSUpdate("zh", domain, submittedCert, ""); err != nil { t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err) } } @@ -95,7 +95,7 @@ func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) { h := &AdminHandler{tlsDir: t.TempDir()} domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true} - if err := h.handleDomainTLSUpdate(domain, certCRLF, keyCRLF); err != nil { + if err := h.handleDomainTLSUpdate("zh", domain, certCRLF, keyCRLF); err != nil { t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err) } @@ -119,7 +119,7 @@ func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) { h := &AdminHandler{tlsDir: t.TempDir()} domain := writeExistingCert(t, h.tlsDir, certA, keyA) - err := h.handleDomainTLSUpdate(domain, certB, "") + err := h.handleDomainTLSUpdate("zh", domain, certB, "") if err == nil || !strings.Contains(err.Error(), "必须同时填写") { t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err) } diff --git a/internal/web/handlers/auth.go b/internal/web/handlers/auth.go index a6d66a0..9ac8daa 100644 --- a/internal/web/handlers/auth.go +++ b/internal/web/handlers/auth.go @@ -11,6 +11,7 @@ import ( "mail_go/config" "mail_go/internal/auth" + "mail_go/internal/i18n" "mail_go/internal/store" "github.com/gin-contrib/sessions" @@ -37,12 +38,12 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) { c.Redirect(302, "/inbox") return } - c.HTML(200, "login", gin.H{ + c.HTML(200, "login", withLang(c, gin.H{ "error": "", "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) } // DoLogin processes the login form submission. @@ -54,7 +55,7 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { // Check if IP is banned banned, entry := h.stores.Bans.IsBanned(ip) if banned { - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) + c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry})) return } @@ -62,12 +63,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { password := c.PostForm("password") if email == "" || password == "" { - c.HTML(200, "login", gin.H{ - "error": "请输入邮箱和密码", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "请输入邮箱和密码"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -79,17 +80,17 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser) if banned { entry, _ := h.stores.Bans.GetByIP(ip) - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) + c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry})) return } remaining := h.banCfg.MaxFailAttempts - failCount - c.HTML(200, "login", gin.H{ - "error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining), + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -105,12 +106,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { session.Set("isAdmin", user.IsAdmin) session.Set("loginAt", time.Now().Unix()) if err := session.Save(); err != nil { - c.HTML(200, "login", gin.H{ - "error": "会话保存失败,请重试", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "会话保存失败,请重试"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -124,7 +125,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) { // Check if IP is banned banned, entry := h.stores.Bans.IsBanned(ip) if banned { - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) + c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry})) return } @@ -132,12 +133,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) { password := c.PostForm("password") if username == "" || password == "" { - c.HTML(200, "login", gin.H{ - "error": "请输入LDAP用户名和密码", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "请输入LDAP用户名和密码"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -153,39 +154,39 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) { banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true) if banned { entry, _ := h.stores.Bans.GetByIP(ip) - c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) + c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry})) return } remaining := h.banCfg.MaxFailAttempts - failCount - c.HTML(200, "login", gin.H{ - "error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining), + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } // Look up or auto-create user by email user, err := h.stores.Users.GetByEmail(email) if err != nil { - c.HTML(200, "login", gin.H{ - "error": "LDAP 账号未接入本系统,请联系管理员", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } if !user.IsActive { - c.HTML(200, "login", gin.H{ - "error": "用户已被禁用", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "用户已被禁用"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -201,12 +202,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) { session.Set("isAdmin", user.IsAdmin) session.Set("loginAt", time.Now().Unix()) if err := session.Save(); err != nil { - c.HTML(200, "login", gin.H{ - "error": "会话保存失败,请重试", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "会话保存失败,请重试"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -246,7 +247,7 @@ func (h *AuthHandler) oauth2LoginVars() gin.H { // OAuth2Start redirects to the OAuth2 provider's authorization page. func (h *AuthHandler) OAuth2Start(c *gin.Context) { if !h.authCfg.OAuth2Enabled { - c.String(http.StatusBadRequest, "OAuth2 未启用") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用")) return } @@ -254,7 +255,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) { state, err := randomOAuth2State() if err != nil { log.Printf("生成 OAuth2 state 失败: %v", err) - c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试")) return } c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true) @@ -264,7 +265,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) { // OAuth2Callback handles the OAuth2 provider's callback after user authorization. func (h *AuthHandler) OAuth2Callback(c *gin.Context) { if !h.authCfg.OAuth2Enabled { - c.String(http.StatusBadRequest, "OAuth2 未启用") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用")) return } @@ -274,11 +275,11 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) { reqState := c.Query("state") if cookieErr != nil || reqState == "" || subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 { - c.HTML(http.StatusForbidden, "login", func() gin.H { + c.HTML(http.StatusForbidden, "login", withLang(c, func() gin.H { v := h.oauth2LoginVars() - v["error"] = "OAuth2 state 校验失败,请重新发起登录" + v["error"] = i18n.T(langOf(c), "OAuth2 state 校验失败,请重新发起登录") return v - }()) + }())) return } // state 一次性使用:无论后续成败都立即失效 @@ -286,12 +287,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) { code := c.Query("code") if code == "" { - c.HTML(200, "login", gin.H{ - "error": "OAuth2 授权码缺失", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "OAuth2 授权码缺失"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -299,34 +300,34 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) { email, err := provider.HandleCallback(code) if err != nil { log.Printf("OAuth2 回调失败: %v", err) - c.HTML(200, "login", gin.H{ - "error": "OAuth2 认证失败,请重试或联系管理员", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } // Look up user by email user, err := h.stores.Users.GetByEmail(email) if err != nil { - c.HTML(200, "login", gin.H{ - "error": "OAuth2 账号未接入本系统,请联系管理员", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } if !user.IsActive { - c.HTML(200, "login", gin.H{ - "error": "用户已被禁用", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "用户已被禁用"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } @@ -339,12 +340,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) { session.Set("isAdmin", user.IsAdmin) session.Set("loginAt", time.Now().Unix()) if err := session.Save(); err != nil { - c.HTML(200, "login", gin.H{ - "error": "会话保存失败,请重试", + c.HTML(200, "login", withLang(c, gin.H{ + "error": i18n.T(langOf(c), "会话保存失败,请重试"), "oauth2Enabled": h.authCfg.OAuth2Enabled, "ldapEnabled": h.authCfg.LDAPEnabled, "oauth2Provider": h.authCfg.OAuth2Provider, - }) + })) return } diff --git a/internal/web/handlers/i18n.go b/internal/web/handlers/i18n.go new file mode 100644 index 0000000..8db3525 --- /dev/null +++ b/internal/web/handlers/i18n.go @@ -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 + } +} diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index 6b3cbd4..abb9eb4 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -13,6 +13,7 @@ import ( "time" "mail_go/internal/db" + "mail_go/internal/i18n" "mail_go/internal/imap_server" "mail_go/internal/outbound" "mail_go/internal/storage" @@ -123,14 +124,14 @@ func (h *MailHandler) Folder(c *gin.Context) { userID := c.GetUint("userID") name, ok := h.svc.Canonical(userID, c.Param("name")) if !ok { - c.String(http.StatusNotFound, "邮箱不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在")) return } page := getPageParam(c, "page", 1) messages, total, err := h.svc.Messages(userID, name, page, 20) if err != nil { - c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err)) return } @@ -143,7 +144,7 @@ func (h *MailHandler) Folder(c *gin.Context) { } currentUser, _ := c.Get("currentUser") - c.HTML(200, "folder", gin.H{ + c.HTML(200, "folder", withLang(c, gin.H{ "currentUser": currentUser, "messages": messages, "total": total, @@ -154,7 +155,7 @@ func (h *MailHandler) Folder(c *gin.Context) { "activeFolder": name, "isTrash": name == "Trash", "folders": h.foldersFor(userID), - }) + })) } // View renders the email detail page for a specific message. @@ -162,19 +163,19 @@ func (h *MailHandler) View(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "邮件不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在")) return } // Verify the message belongs to the current user if msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } @@ -191,14 +192,14 @@ func (h *MailHandler) View(c *gin.Context) { currentUser, _ := c.Get("currentUser") - c.HTML(200, "view", gin.H{ + c.HTML(200, "view", withLang(c, gin.H{ "currentUser": currentUser, "message": msg, "attachments": attachments, "activeFolder": msg.Folder, "inTrash": msg.Folder == "Trash", "folders": h.foldersFor(userID), - }) + })) } // Compose renders the email composition page. @@ -215,7 +216,7 @@ func (h *MailHandler) Compose(c *gin.Context) { quotaBytes = user.QuotaBytes } - c.HTML(200, "compose", gin.H{ + c.HTML(200, "compose", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "compose", "error": "", @@ -225,12 +226,12 @@ func (h *MailHandler) Compose(c *gin.Context) { "usedBytes": usedBytes, "quotaBytes": quotaBytes, "folders": h.foldersFor(userID), - }) + })) } // composeData builds the shared template context for the compose page. -func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H { - return gin.H{ +func (h *MailHandler) composeData(c *gin.Context, userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H { + return withLang(c, gin.H{ "currentUser": user, "activeFolder": "compose", "error": errMsg, @@ -241,7 +242,7 @@ func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subjec "usedBytes": user.UsedBytes, "quotaBytes": user.QuotaBytes, "folders": h.foldersFor(userID), - } + }) } // DoSend processes the email composition form, sends the email via SMTP, @@ -258,7 +259,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { cc := c.PostForm("cc") if to == "" { - c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody)) + c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "请输入收件人"), to, subject, cc, htmlBody)) return } @@ -276,7 +277,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { } reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize) if err != nil { - c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody)) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "配额检查失败,请稍后重试"), to, subject, cc, htmlBody)) return } if !reserved { @@ -285,10 +286,10 @@ func (h *MailHandler) DoSend(c *gin.Context) { if user != nil { usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes } - c.HTML(http.StatusBadRequest, "compose", gin.H{ + c.HTML(http.StatusBadRequest, "compose", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "compose", - "error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)), + "error": i18n.TF(langOf(c), "附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)), "to": to, "subject": subject, "cc": cc, @@ -296,7 +297,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { "usedBytes": usedBytes, "quotaBytes": quotaBytes, "folders": h.foldersFor(userID), - }) + })) return } @@ -355,16 +356,16 @@ func (h *MailHandler) DoSend(c *gin.Context) { if len(externalRecipients) > 0 { ob := h.outbound if ob == nil || !ob.Enabled() { - c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody)) + c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "外部投递未启用"), to, subject, cc, htmlBody)) return } if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt { - c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody)) + c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody)) return } for _, rcpt := range externalRecipients { if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil { - c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody)) + c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody)) return } } @@ -386,7 +387,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { IsRead: false, } if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil { - c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody)) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "投递邮件失败: %v", createErr), to, subject, cc, htmlBody)) return } // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) @@ -412,7 +413,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { } if createErr := h.stores.Mails.Create(msg); createErr != nil { - c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody)) + c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "保存邮件失败: %v", createErr), to, subject, cc, htmlBody)) return } @@ -579,13 +580,13 @@ func (h *MailHandler) Delete(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil || msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } userEmail := userEmailOf(c) @@ -602,7 +603,7 @@ func (h *MailHandler) Delete(c *gin.Context) { seq := h.seqOfFolder(userID, msg.Folder, msg.ID) if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil { log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err) - c.String(http.StatusInternalServerError, "删除失败") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "删除失败")) return } if h.pusher != nil && userEmail != "" { @@ -624,13 +625,13 @@ func (h *MailHandler) Restore(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil || msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } if msg.Folder != "Trash" { @@ -641,7 +642,7 @@ func (h *MailHandler) Restore(c *gin.Context) { seq := h.seqOfFolder(userID, "Trash", msg.ID) if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil { log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err) - c.String(http.StatusInternalServerError, "恢复失败") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "恢复失败")) return } if h.pusher != nil { @@ -658,13 +659,13 @@ func (h *MailHandler) Purge(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil || msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } @@ -688,13 +689,13 @@ func (h *MailHandler) EmptyFolder(c *gin.Context) { userID := c.GetUint("userID") name, ok := h.svc.Canonical(userID, c.Param("name")) if !ok { - c.String(http.StatusNotFound, "邮箱不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在")) return } msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name) if err != nil { - c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err) + c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "清空文件夹失败: %v", err)) return } seqs := make([]uint32, 0, len(msgs)) @@ -715,13 +716,13 @@ func (h *MailHandler) MarkRead(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的邮件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID")) return } msg, err := h.stores.Mails.GetByID(uint(id)) if err != nil || msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } @@ -752,26 +753,26 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) { userID := c.GetUint("userID") id, err := strconv.ParseUint(c.Param("id"), 10, 64) if err != nil { - c.String(http.StatusBadRequest, "无效的附件ID") + c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID")) return } att, err := h.stores.Attachments.GetByID(uint(id)) if err != nil { - c.String(http.StatusNotFound, "附件不存在") + c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在")) return } // Verify the message belongs to the current user msg, err := h.stores.Mails.GetByID(att.MessageID) if err != nil || msg.UserID != userID { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问")) return } data, err := h.storage.Read(att.FilePath) if err != nil { - c.String(http.StatusInternalServerError, "读取附件失败") + c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败")) return } @@ -797,65 +798,76 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int { func (h *MailHandler) Settings(c *gin.Context) { currentUser, _ := c.Get("currentUser") userID := c.GetUint("userID") - c.HTML(200, "settings", gin.H{ + c.HTML(200, "settings", withLang(c, gin.H{ "currentUser": currentUser, "activeFolder": "settings", "error": "", "success": "", "mustChange": c.Query("force") == "1", "folders": h.foldersFor(userID), - }) + })) } // settingsData builds the shared template context for the settings page. -func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H { - return gin.H{ +func (h *MailHandler) settingsData(c *gin.Context, userID uint, user *db.User, errMsg, success string) gin.H { + return withLang(c, gin.H{ "currentUser": user, "activeFolder": "settings", "error": errMsg, "success": success, "folders": h.foldersFor(userID), - } + }) } -// UpdateSettings handles the password change form. +// UpdateSettings 处理设置页表单:密码修改(old_password + new_password + +// confirm_password)与界面语言偏好(仅 language 字段)二选一提交。 func (h *MailHandler) UpdateSettings(c *gin.Context) { userID := c.GetUint("userID") currentUserVal, _ := c.Get("currentUser") currentUser := currentUserVal.(*db.User) + // 仅提交 language 字段 → 语言偏好更新(设置页独立表单) + if lang := c.PostForm("language"); lang != "" && c.PostForm("old_password") == "" { + if err := h.stores.Users.UpdateLanguage(userID, lang); err != nil { + c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "语言偏好保存失败"), "")) + return + } + c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "语言偏好已保存"))) + return + } + oldPassword := c.PostForm("old_password") newPassword := c.PostForm("new_password") confirmPassword := c.PostForm("confirm_password") // Verify old password if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil { - c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", "")) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "当前密码不正确"), "")) return } if newPassword == "" { - c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", "")) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "新密码不能为空"), "")) return } if newPassword != confirmPassword { - c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", "")) + c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "两次输入的密码不一致"), "")) return } hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) if err != nil { - c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", "")) + c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码加密失败"), "")) return } if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil { - c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", "")) + c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码更新失败"), "")) return } - c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功")) + c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "密码修改成功"))) } // formatBytes converts a file size in bytes to a human-readable string. diff --git a/internal/web/handlers/oauth2_state_test.go b/internal/web/handlers/oauth2_state_test.go index 4916f33..4e27beb 100644 --- a/internal/web/handlers/oauth2_state_test.go +++ b/internal/web/handlers/oauth2_state_test.go @@ -5,6 +5,7 @@ package handlers import ( "encoding/json" + "fmt" "html/template" "math" "net/http" @@ -25,13 +26,19 @@ import ( // templateFuncs 等价,但 handlers 包无法反向依赖 web 包)。 func testTemplateFuncs() template.FuncMap { return template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "sub": func(a, b int) int { return a - b }, - "mul": func(a, b int) int { return a * b }, - "div": func(a, b int) int { return a / b }, - "mod": func(a, b int) int { return a % b }, - "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, - "seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r }, + "add": func(a, b int) int { return a + b }, + "sub": func(a, b int) int { return a - b }, + "mul": func(a, b int) int { return a * b }, + "div": func(a, b int) int { return a / b }, + "mod": func(a, b int) int { return a % b }, + "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, + "seq": func(n int) []int { + r := make([]int, n) + for i := range r { + r[i] = i + 1 + } + return r + }, "domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" }, "jsonify": func(v interface{}) template.JS { b, _ := json.Marshal(v) @@ -45,13 +52,16 @@ func testTemplateFuncs() template.FuncMap { "mailEmail": func(s string) string { return s }, "initial": func(s string) string { return "?" }, "truncate": func(s string, n int) string { return s }, - "shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, + "t": func(lang, key string) string { return key }, + "tf": func(lang, key string, args ...interface{}) string { return fmt.Sprintf(key, args...) }, + "htmlLang": func(lang string) string { return "zh-CN" }, + "shortDate": func(lang string, t time.Time) string { return t.Format("2006-01-02") }, "localTime": func(t time.Time) time.Time { return t }, - "time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, - "time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, + "time12": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04:05") }, + "time12m": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04") }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" }, "urlPath": func(s string) string { return url.PathEscape(s) }, - "folderLabel": func(s string) string { return s }, + "folderLabel": func(lang, s string) string { return s }, } } @@ -66,7 +76,7 @@ func newOAuth2TestContext(t *testing.T) (*gin.Context, *AuthHandler, *httptest.R c.Request = httptest.NewRequest(http.MethodGet, "/auth/oauth2", nil) authCfg := config.AuthConfig{ - OAuth2Enabled: true, + OAuth2Enabled: true, // 使用本地拒绝连接的地址作为 provider,token 交换快速失败, // 测试不依赖外部网络。 OAuth2Provider: "127.0.0.1:1", diff --git a/internal/web/middleware/admin.go b/internal/web/middleware/admin.go index e8a6c48..9d69c7c 100644 --- a/internal/web/middleware/admin.go +++ b/internal/web/middleware/admin.go @@ -4,6 +4,7 @@ import ( "net/http" "mail_go/internal/db" + "mail_go/internal/i18n" "github.com/gin-gonic/gin" ) @@ -12,16 +13,22 @@ import ( // Must be used after AuthMiddleware so that "currentUser" is available. func AdminMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language")) + if v, ok := c.Get("lang"); ok { + if s, ok := v.(string); ok && i18n.Supported(s) { + lang = s + } + } userVal, exists := c.Get("currentUser") if !exists { - c.String(http.StatusForbidden, "禁止访问") + c.String(http.StatusForbidden, i18n.T(lang, "禁止访问")) c.Abort() return } user, ok := userVal.(*db.User) if !ok || !user.IsAdmin { - c.String(http.StatusForbidden, "禁止访问:需要管理员权限") + c.String(http.StatusForbidden, i18n.T(lang, "禁止访问:需要管理员权限")) c.Abort() return } diff --git a/internal/web/middleware/auth.go b/internal/web/middleware/auth.go index 6e72f06..758b444 100644 --- a/internal/web/middleware/auth.go +++ b/internal/web/middleware/auth.go @@ -3,6 +3,7 @@ package middleware import ( "time" + "mail_go/internal/i18n" "mail_go/internal/store" "github.com/gin-contrib/sessions" @@ -100,6 +101,10 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc { return } + // 解析界面语言:用户偏好 auto 时按浏览器 Accept-Language 选择, + // 最终兜底英语。模板数据与错误提示统一使用该值。 + c.Set("lang", i18n.Resolve(user.Language, c.GetHeader("Accept-Language"))) + c.Set("currentUser", user) c.Set("userID", id) c.Next() diff --git a/internal/web/middleware/ban.go b/internal/web/middleware/ban.go index 270907b..ee70c7e 100644 --- a/internal/web/middleware/ban.go +++ b/internal/web/middleware/ban.go @@ -3,6 +3,7 @@ package middleware import ( "net/http" + "mail_go/internal/i18n" "mail_go/internal/store" "github.com/gin-gonic/gin" @@ -15,8 +16,12 @@ func BanMiddleware(stores *store.Stores) gin.HandlerFunc { ip := c.ClientIP() banned, entry := stores.Bans.IsBanned(ip) if banned { + // 封禁页无登录用户,按浏览器语言渲染 + lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language")) + c.Set("lang", lang) c.HTML(http.StatusForbidden, "banned", gin.H{ "entry": entry, + "Lang": lang, }) c.Abort() return diff --git a/internal/web/render_test.go b/internal/web/render_test.go index 37391f8..11f3e67 100644 --- a/internal/web/render_test.go +++ b/internal/web/render_test.go @@ -59,24 +59,24 @@ func TestRenderAllPages(t *testing.T) { name string data ginH }{ - {"login", ginH{"error": ""}}, - {"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, - {"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}}, - {"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}}, - {"view", ginH{ + {"login", ginH{"Lang": "zh", "error": ""}}, + {"banned", ginH{"Lang": "zh", "entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, + {"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}}, + {"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}}, + {"view", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "INBOX", "message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= ", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false}, "attachments": attachments, "inTrash": false, "folders": folders, }}, - {"compose", ginH{ + {"compose", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "compose", "error": "", "to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "", "usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024), "folders": folders, }}, - {"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}}, - {"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, - {"admin_bans", ginH{ + {"settings", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}}, + {"admin_dashboard", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, + {"admin_bans", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "bans", "rows": []struct { db.BanEntry @@ -88,7 +88,7 @@ func TestRenderAllPages(t *testing.T) { }, "total": 3, "page": 1, "pageSize": 20, "totalPages": 1, }}, - {"admin_protocol_logs", ginH{ + {"admin_protocol_logs", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "protocol-logs", "logs": []db.ProtocolLog{ {ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM: RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now}, @@ -109,7 +109,7 @@ func TestRenderAllPages(t *testing.T) { }, "keepDays": 30, }}, - {"admin_connections", ginH{ + {"admin_connections", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "connections", "conns": []struct { ID uint64 diff --git a/internal/web/server.go b/internal/web/server.go index adfc99c..7a08ddd 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -18,6 +18,7 @@ import ( "mail_go/config" "mail_go/internal/connhub" + "mail_go/internal/i18n" "mail_go/internal/imap_server" "mail_go/internal/mailutil" "mail_go/internal/outbound" @@ -84,6 +85,21 @@ func templateFuncs() template.FuncMap { "domainName": func(domainID uint, domains []interface{}) string { return fmt.Sprintf("Domain #%d", domainID) }, + // t / tf 多语言翻译(见 internal/i18n):t 返回译文, + // tf 对译文做 fmt.Sprintf(带占位符的消息)。 + "t": i18n.T, + "tf": i18n.TF, + // htmlLang 返回 属性值(zh → zh-CN)。 + "htmlLang": func(lang string) string { + switch i18n.Normalize(lang) { + case i18n.LangZh: + return "zh-CN" + case i18n.LangJa: + return "ja" + default: + return "en" + } + }, // jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串), // 用于在 ,杜绝 script 注入。 @@ -108,12 +124,11 @@ func templateFuncs() template.FuncMap { "initial": initial, // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 "truncate": truncate, - // shortDate 邮件列表时间:统一 12 小时制(上午/下午)。 + // shortDate 邮件列表时间:按界面语言输出 12 小时制。 // 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」, // 更早显示「2026-08-20 下午 2:35」。 "shortDate": shortDate, - // time12 完整时间(含秒),先转换为 Web 时区: - // 「2026-08-20 下午 2:35:05」。 + // time12 完整时间(含秒),先转换为 Web 时区。 "time12": time12, // time12m 同上但不含秒。 "time12m": time12m, @@ -123,24 +138,30 @@ func templateFuncs() template.FuncMap { "avatarStyle": avatarStyle, // urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。 "urlPath": url.PathEscape, - // folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。 - "folderLabel": func(name string) string { - switch name { - case "INBOX": - return "收件箱" - case "Sent": - return "已发送" - case "Drafts": - return "草稿箱" - case "Trash": - return "已删除" - default: - return name - } + // folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。 + "folderLabel": func(lang, name string) string { + return folderLabel(lang, name) }, } } +// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译, +// 自定义文件夹原样返回。 +func folderLabel(lang, name string) string { + switch name { + case "INBOX": + return i18n.T(lang, "收件箱") + case "Sent": + return i18n.T(lang, "已发送") + case "Drafts": + return i18n.T(lang, "草稿箱") + case "Trash": + return i18n.T(lang, "已删除") + default: + return name + } +} + // mailName extracts the display name from an RFC 5322 address. func mailName(s string) string { s = strings.TrimSpace(s) @@ -186,11 +207,12 @@ func truncate(s string, n int) string { return string(r[:n]) + "…" } -// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。 -func periodOf(t time.Time) (string, int) { - period := "上午" +// periodOf 返回 12 小时制的时间段与小时:按界面语言输出 +// 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。 +func periodOf(lang string, t time.Time) (string, int) { + period := i18n.T(lang, "上午") if t.Hour() >= 12 { - period = "下午" + period = i18n.T(lang, "下午") } h := t.Hour() % 12 if h == 0 { @@ -202,10 +224,10 @@ func periodOf(t time.Time) (string, int) { // shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换): // 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」; // 更早 → 「2026-08-20 下午 2:35」。 -func shortDate(t time.Time) string { +func shortDate(lang string, t time.Time) string { t = inWebTZ(t) now := time.Now().In(t.Location()) - period, h := periodOf(t) + period, h := periodOf(lang, t) clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute()) if t.Year() == now.Year() && t.YearDay() == now.YearDay() { return clock @@ -218,16 +240,16 @@ func shortDate(t time.Time) string { // time12 完整时间(12 小时制,先按 Web 配置时区转换): // 「2026-08-20 下午 2:35:05」。 -func time12(t time.Time) string { +func time12(lang string, t time.Time) string { t = inWebTZ(t) - period, h := periodOf(t) + period, h := periodOf(lang, t) return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second()) } // time12m 完整时间(12 小时制,不含秒)。 -func time12m(t time.Time) string { +func time12m(lang string, t time.Time) string { t = inWebTZ(t) - period, h := periodOf(t) + period, h := periodOf(lang, t) return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute()) } diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html index a339a49..4042382 100644 --- a/internal/web/templates/admin/bans.html +++ b/internal/web/templates/admin/bans.html @@ -1,10 +1,10 @@ {{define "admin_bans"}} - + - IP黑名单 - MailGo + {{t .Lang "IP黑名单"}} - MailGo {{template "styles" .}} @@ -12,32 +12,32 @@
-

IP 黑名单

- 阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限) +

{{t .Lang "IP 黑名单"}}

+ {{t .Lang "阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)"}}
- - - - - - - + + + + + + + @@ -45,21 +45,21 @@ - - + + @@ -67,17 +67,17 @@
IP 地址状态封禁次数失败次数原因到期时间操作{{t .Lang "IP 地址"}}{{t .Lang "状态"}}{{t .Lang "封禁次数"}}{{t .Lang "失败次数"}}{{t .Lang "原因"}}{{t .Lang "到期时间"}}{{t .Lang "操作"}}
{{.IPAddress}} - {{if .Active}}封禁中 - {{else}}已过期{{end}} + {{if .Active}}{{t $.Lang "封禁中"}} + {{else}}{{t $.Lang "已过期"}}{{end}} {{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}} {{.FailCount}}{{if .Reason}}{{.Reason}}{{else}}—{{end}}{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}{{if .Reason}}{{t $.Lang .Reason}}{{else}}—{{end}}{{time12 $.Lang .ExpiresAt}}{{if not .Active}}{{t $.Lang "(已过期)"}}{{end}}
- + onsubmit="return confirm('{{tf $.Lang "确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。" .IPAddress}}');"> +
{{if not .rows}} -

暂无封禁记录

+

{{t $.Lang "暂无封禁记录"}}

{{end}}
{{if .totalPages}} {{end}} diff --git a/internal/web/templates/admin/connections.html b/internal/web/templates/admin/connections.html index ec4a154..1ddfb5a 100644 --- a/internal/web/templates/admin/connections.html +++ b/internal/web/templates/admin/connections.html @@ -1,10 +1,10 @@ {{define "admin_connections"}} - + - 当前连接 - MailGo + {{t .Lang "当前连接"}} - MailGo {{template "styles" .}} @@ -13,26 +13,26 @@
-

当前连接(SMTP / IMAP / POP3)

- 每 5 秒自动刷新 +

{{t .Lang "当前连接(SMTP / IMAP / POP3)"}}

+ {{t .Lang "每 5 秒自动刷新"}}

{{.total}}

-

当前连接总数

+

{{t .Lang "当前连接总数"}}

{{.smtpCount}}

@@ -53,15 +53,15 @@ ID - 协议 - 来源 IP - 端口 - 用户名 + {{t .Lang "协议"}} + {{t .Lang "来源 IP"}} + {{t .Lang "端口"}} + {{t .Lang "用户名"}} TLS - 连接时间 - 时长 - 最后活跃 - 操作 + {{t .Lang "连接时间"}} + {{t .Lang "时长"}} + {{t .Lang "最后活跃"}} + {{t .Lang "操作"}} @@ -76,19 +76,19 @@ {{.IP}} {{.Port}} {{if .User}}{{.User}}{{else}}—{{end}} - {{if .TLS}}TLS{{else}}明文{{end}} - {{time12 .Connected}} + {{if .TLS}}TLS{{else}}{{t $.Lang "明文"}}{{end}} + {{time12 $.Lang .Connected}} {{durationSeconds ($.now.Sub .Connected)}}s - {{time12 .LastActive}} + {{time12 $.Lang .LastActive}}
- + onsubmit="return confirm('{{tf $.Lang "确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?" .IP}}');"> +
{{else}} - 当前没有活动连接 + {{t $.Lang "当前没有活动连接"}} {{end}} diff --git a/internal/web/templates/admin/dashboard.html b/internal/web/templates/admin/dashboard.html index 7d8c159..251ac45 100644 --- a/internal/web/templates/admin/dashboard.html +++ b/internal/web/templates/admin/dashboard.html @@ -1,10 +1,10 @@ {{define "admin_dashboard"}} - + - 管理后台 - MailGo + {{t .Lang "管理后台"}} - MailGo {{template "styles" .}} @@ -12,74 +12,74 @@
-

管理后台

+

{{t .Lang "管理后台"}}

{{.domainCount}}

-

域名数

+

{{t .Lang "域名数"}}

{{.userCount}}

-

用户数

+

{{t .Lang "用户数"}}

{{.totalMails}}

-

邮件总数

+

{{t .Lang "邮件总数"}}

{{.banCount}}

-

被封IP

+

{{t .Lang "被封IP"}}

-

邮件分布

+

{{t .Lang "邮件分布"}}

- - - + + + - - - - - + + + + +
文件夹邮件数占用空间{{t .Lang "文件夹"}}{{t .Lang "邮件数"}}{{t .Lang "占用空间"}}
收件箱 (INBOX){{.inboxCount}}{{formatBytes .inboxSize}}
发件箱 (Sent){{.sentCount}}{{formatBytes .sentSize}}
草稿箱 (Drafts){{.draftsCount}}
垃圾箱 (Trash){{.trashCount}}
合计{{.totalMails}}{{formatBytes .totalSize}}
{{folderLabel .Lang "INBOX"}} (INBOX){{.inboxCount}}{{formatBytes .inboxSize}}
{{folderLabel .Lang "Sent"}} (Sent){{.sentCount}}{{formatBytes .sentSize}}
{{folderLabel .Lang "Drafts"}} (Drafts){{.draftsCount}}
{{folderLabel .Lang "Trash"}} (Trash){{.trashCount}}
{{t .Lang "合计"}}{{.totalMails}}{{formatBytes .totalSize}}
-

收发统计

+

{{t .Lang "收发统计"}}

- + - - + +
时间段收件发件
{{t .Lang "时间段"}}{{t .Lang "收件"}}{{t .Lang "发件"}}
今日{{.todayReceived}}{{.todaySent}}
近 7 天{{.weekReceived}}{{.weekSent}}
{{t .Lang "今日"}}{{.todayReceived}}{{.todaySent}}
{{t .Lang "近 7 天"}}{{.weekReceived}}{{.weekSent}}
diff --git a/internal/web/templates/admin/dns_hint.html b/internal/web/templates/admin/dns_hint.html index ab769bb..fc3768a 100644 --- a/internal/web/templates/admin/dns_hint.html +++ b/internal/web/templates/admin/dns_hint.html @@ -1,10 +1,10 @@ {{define "admin_dns_hint"}} - + - DNS配置 - MailGo + {{t .Lang "DNS配置"}} - MailGo {{template "styles" .}} @@ -12,39 +12,39 @@
-

DNS 配置提示 — {{.domain.Name}}

-

请在您的 DNS 服务商处添加以下记录:

+

{{tf .Lang "DNS 配置提示 — %s" .domain.Name}}

+

{{t .Lang "请在您的 DNS 服务商处添加以下记录:"}}

-

MX 记录

+

{{t .Lang "MX 记录"}}

@ IN MX 10 mail.{{.domain.Name}}.
-

SPF 记录 (TXT)

+

{{t .Lang "SPF 记录 (TXT)"}}

@ IN TXT "v=spf1 mx -all"
-

DKIM 记录 (TXT)

+

{{t .Lang "DKIM 记录 (TXT)"}}

{{if .domain.DkimPublicKey}}
default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"
{{else}} -
⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。
+
{{t .Lang "⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。"}}
{{end}} -

DMARC 记录

+

{{t .Lang "DMARC 记录"}}

_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"
diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html index ef9eb51..3d52695 100644 --- a/internal/web/templates/admin/domain_form.html +++ b/internal/web/templates/admin/domain_form.html @@ -1,10 +1,10 @@ {{define "admin_domain_form"}} - + - {{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo + {{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}} - MailGo {{template "styles" .}} @@ -12,24 +12,24 @@
-

{{if .isEdit}}编辑域名{{else}}新增域名{{end}}

+

{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}}

{{if .error}}
{{.error}}
{{end}} {{if .caddyMsg}}
{{.caddyMsg}}
{{end}}
- + {{if .isEdit}} {{else}} @@ -37,45 +37,45 @@ {{end}}
- +
- +
- +
-

勾选后端口将自动切换为 SSL/TLS 标准端口

+

{{t .Lang "勾选后端口将自动切换为 SSL/TLS 标准端口"}}

{{if .isEdit}}
- -

若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。

- + +

{{t .Lang "若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。"}}

+