Merge pull request 'feat(web): 多语言界面(中/英/日)与用户语言偏好字段' (#15) from dsh/mailgo:fix/legacy-ban-index into main

Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
2026-08-20 00:27:02 -04:00
38 changed files with 1909 additions and 768 deletions
+1
View File
@@ -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 - **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 - **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 - **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 - **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) - **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)
+1
View File
@@ -9,6 +9,7 @@ Web 前端的布局:顶部导航 + 左侧文件夹栏 + 邮件列表三栏设
- **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密 - **邮件协议**:SMTP(发送)、IMAP(同步)、POP3(收取),均支持 TLS 加密
- **外部投递**:认证用户可向外部邮箱(Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名 - **外部投递**:认证用户可向外部邮箱(Gmail/Outlook 等)发送邮件,内置外发队列、**并发 worker 池投递**(默认 4 线程 + 每收件域并发上限 2)、MX 直投、STARTTLS、指数退避重试、退信通知与 DKIM 签名
- **多语言界面(i18n)**:中文 / 英文 / 日文,在「设置」页可选择界面语言(或自动跟随浏览器 Accept-Language,英语兜底),覆盖全部用户页与管理后台、12 小时制时间与错误提示
- **Web 邮箱**:文件夹数据驱动(Web 与 IMAP 共享 MailboxServiceIMAP LIST 返回什么界面就显示什么),系统文件夹(收件箱 / 已发送 / 草稿箱 / 垃圾箱)与自定义文件夹管理、删除邮件移入垃圾箱(支持恢复 / 彻底删除 / 清空)、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载 - **Web 邮箱**:文件夹数据驱动(Web 与 IMAP 共享 MailboxServiceIMAP LIST 返回什么界面就显示什么),系统文件夹(收件箱 / 已发送 / 草稿箱 / 垃圾箱)与自定义文件夹管理、删除邮件移入垃圾箱(支持恢复 / 彻底删除 / 清空)、未读角标与搜索过滤、全选 / 批量删除、发件人头像、富文本编辑(Quill.js)、附件上传/下载
- **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计、时间统一按 Web 时区显示 12 小时制(上午/下午) - **管理后台**:域名管理、用户管理、DKIM 密钥自动生成、DNS 配置提示、全量邮件查看、外发队列管理、IP 封禁管理、仪表盘统计、时间统一按 Web 时区显示 12 小时制(上午/下午)
- **协议调用日志**SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理) - **协议调用日志**SMTP / IMAP / POP3 每次连接自动记录来源 IP、用户名、成功/失败、失败原因与操作摘要,可按协议/状态/IP/用户名/时间筛选,用于分析密码爆破、中继滥用等攻击行为(默认保留 30 天,自动清理)
+3
View File
@@ -18,6 +18,9 @@ type User struct {
// MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的 // MustChangePassword 为 true 时该用户(通常是初始管理员或被重置密码的
// 用户)在首次登录后必须修改密码。 // 用户)在首次登录后必须修改密码。
MustChangePassword bool `gorm:"default:false" json:"-"` 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"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
} }
+736
View File
@@ -0,0 +1,736 @@
package i18n
// en 英文目录:key 为界面中的原始中文字符串。
// 英语是兜底语言:ja 缺译时回退这里,未知语言也回退这里。
var en = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "AM",
"下午": "PM",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "Inbox",
"已发送": "Sent",
"草稿箱": "Drafts",
"已删除": "Trash",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "Mail",
"搜索邮件(发件人 / 主题)": "Search mail (sender / subject)",
"管理后台": "Admin",
"设置": "Settings",
"退出登录": "Log out",
"写信": "Compose",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "Select all / deselect all",
"全选": "Select all",
"刷新": "Refresh",
"删除": "Delete",
"彻底删除": "Delete permanently",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "Empty the Trash folder? This cannot be undone.",
"清空文件夹": "Empty folder",
"共 %d 封": "%d messages",
"暂无邮件": "No messages",
"收件人:%s": "To: %s",
"致:": "To: ",
"(无主题)": "(no subject)",
"[HTML 邮件]": "[HTML email]",
"确定要恢复这封邮件吗?": "Restore this message?",
"恢复到收件箱": "Restore to inbox",
"确定要删除这封邮件吗?": "Delete this message?",
"第 %d / %d 页": "Page %d of %d",
"上一页": "Previous",
"下一页": "Next",
"确定要删除选中的 %s 封邮件吗?": "Delete the %s selected message(s)?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "View message",
"返回": "Back",
"回复": "Reply",
"恢复": "Restore",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "Permanently delete this message? This cannot be undone.",
"删除邮件": "Delete message",
"抄送:": "Cc: ",
// ---------- 写信(compose.html ----------
"发送": "Send",
"附件": "Attach",
"取消": "Cancel",
"撰写新邮件": "New message",
"收件人": "To",
"输入收件人邮箱地址": "Recipient email address",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "Separate multiple addresses with commas (optional)",
"主题": "Subject",
"输入邮件主题": "Email subject",
"请输入邮件内容...": "Enter your message...",
"附件配额": "Attachment quota",
"移除": "Remove",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ First login / password has been reset. Please change your password before using the mailbox.",
"账号信息": "Account info",
"已用 %s / 配额 %s": "Used %s / Quota %s",
"管理员": "Admin",
"修改密码": "Change password",
"当前密码": "Current password",
"请输入当前密码": "Enter current password",
"新密码": "New password",
"请输入新密码": "Enter new password",
"确认新密码": "Confirm new password",
"请再次输入新密码": "Re-enter new password",
"界面语言": "Interface language",
"自动(跟随浏览器)": "Auto (browser)",
"保存": "Save",
"语言偏好已保存": "Language preference saved",
"语言偏好保存失败": "Failed to save language preference",
"帮助": "Help",
"客户端收发信(IMAP / SMTP)配置:": "IMAP / SMTP client settings:",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP server: %s port %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP server: %s port %d (submission) / SSL %d",
// ---------- 登录(login.html ----------
"登录": "Log in",
"登录您的邮箱账户": "Log in to your mailbox",
"邮箱地址": "Email address",
"密码": "Password",
"请输入密码": "Enter password",
"登 录": "Log in",
"或": "or",
"LDAP 用户名": "LDAP username",
"LDAP 密码": "LDAP password",
"LDAP 登录": "LDAP sign in",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "Access denied",
"您的 IP 已被暂时禁止访问": "Your IP has been temporarily blocked",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "Your IP address has been temporarily banned due to too many failed login attempts.",
"IP 地址": "IP address",
"封禁档位": "Ban level",
"第 %d 次封禁": "Ban #%d",
"原因": "Reason",
"失败次数": "Failed attempts",
"%d 次": "%d times",
"解封时间": "Unban time",
"请在解封时间后重试,或联系管理员。": "Please retry after the unban time, or contact the administrator.",
"返回登录页": "Back to login",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "Back to mailbox",
"控制面板": "Dashboard",
"域名管理": "Domains",
"用户管理": "Users",
"所有邮件": "All mail",
"外发队列": "Outbound queue",
"协议日志": "Protocol logs",
"当前连接": "Connections",
"IP黑名单": "IP blacklist",
// ---------- 控制面板(dashboard.html ----------
"域名数": "Domains",
"用户数": "Users",
"邮件总数": "Total mail",
"被封IP": "Banned IPs",
"邮件分布": "Mail distribution",
"文件夹": "Folder",
"邮件数": "Messages",
"占用空间": "Size",
"合计": "Total",
"收发统计": "Send/receive stats",
"时间段": "Period",
"收件": "Received",
"发件": "Sent",
"今日": "Today",
"近 7 天": "Last 7 days",
"快捷操作": "Quick actions",
"新增域名": "Add domain",
"新增用户": "Add user",
"查看所有邮件": "View all mail",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "Users",
"暂无用户": "No users",
"用户名": "Username",
"域名ID": "Domain ID",
"配额": "Quota",
"已用": "Used",
"状态": "Status",
"操作": "Actions",
"编辑": "Edit",
"确定要删除用户 %s 吗?": "Delete user %s?",
"编辑用户": "Edit user",
"新密码(留空则不修改)": "New password (leave blank to keep)",
"域名": "Domain",
"请选择域名": "Select a domain",
"配额 (GB)": "Quota (GB)",
"启用账户": "Active account",
"保存更改": "Save changes",
"创建用户": "Create user",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "Domains",
"暂无域名": "No domains",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "Delete domain %s? Users of this domain will no longer be able to send or receive mail!",
"编辑域名": "Edit domain",
"SMTP 端口": "SMTP port",
"IMAP 端口": "IMAP port",
"POP3 端口": "POP3 port",
"启用 TLS": "Enable TLS",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "When checked, ports switch to standard SSL/TLS ports automatically",
"TLS 私钥 PEM": "TLS private key (PEM)",
"TLS 公钥证书 PEM": "TLS certificate (PEM)",
"私钥不会回显;留空则保留现有私钥。": "The private key is not shown; leave blank to keep the current key.",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ Certificate configured; uploading a new certificate hot-reloads automatically.",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ No certificate configured. To enable TLS you must provide both the private key and the certificate.",
"从 Caddy 获取证书": "Fetch certificate from Caddy",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "If this domain is served by local Caddy with HTTPS (automatic certificates), import its certificate and private key with one click; TLS is enabled and hot-reloaded, no restart needed.",
"🔒 从 Caddy 获取证书": "🔒 Fetch certificate from Caddy",
"获取中…": "Fetching...",
"获取失败": "Fetch failed",
"请求失败": "Request failed",
"重新生成 DKIM 密钥": "Regenerate DKIM key",
"✅ DKIM 密钥已配置": "✅ DKIM key configured",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM key not configured",
"创建域名": "Create domain",
"DNS配置": "DNS configuration",
"DNS 配置提示 — %s": "DNS configuration hint — %s",
"请在您的 DNS 服务商处添加以下记录:": "Add the following records at your DNS provider:",
"MX 记录": "MX record",
"SPF 记录 (TXT)": "SPF record (TXT)",
"DKIM 记录 (TXT)": "DKIM record (TXT)",
"DMARC 记录": "DMARC record",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM key not generated yet. Edit the domain to regenerate it.",
"返回域名列表": "Back to domains",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "From",
"所属用户": "Owner",
"日期": "Date",
"全部": "All",
"返回邮件列表": "Back to list",
"发件人:": "From:",
"收件人:": "To:",
"抄送:": "Cc:",
"所属用户:": "Owner:",
"文件夹:": "Folder:",
"时间:": "Time:",
"附件列表": "Attachments",
// ---------- 外发队列(outbound.html ----------
"队列为空": "Queue is empty",
"待发送": "Pending",
"发送中": "Sending",
"已送达": "Delivered",
"等待重试": "Waiting to retry",
"失败": "Failed",
"已取消": "Canceled",
"尝试次数": "Attempts",
"下次重试": "Next retry",
"最后响应 / 错误": "Last response / error",
"创建时间": "Created",
"重试": "Retry",
"确认取消该投递任务?": "Cancel this delivery task?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "Protocol logs (SMTP / IMAP / POP3)",
"清理旧日志": "Clean old logs",
"确认清理 %d 天前的协议日志?": "Delete protocol logs older than %d days?",
"今日 SMTP 失败": "SMTP failures today",
"今日 IMAP 失败": "IMAP failures today",
"今日 POP3 失败": "POP3 failures today",
"历史失败(全部)": "Historical failures (all)",
"全部协议": "All protocols",
"全部状态": "All statuses",
"成功": "Success",
"来源 IP(模糊)": "Source IP (fuzzy)",
"用户名(模糊)": "Username (fuzzy)",
"至": "to",
"筛选": "Filter",
"重置": "Reset",
"时间": "Time",
"端口": "Port",
"失败原因": "Failure reason",
"操作摘要": "Summary",
"消息数": "Messages",
"时长": "Duration",
"暂无记录": "No records",
"第 %d / %d 页(共 %d 条)": "Page %d of %d (%d entries)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "Active connections (SMTP / IMAP / POP3)",
"每 5 秒自动刷新": "Auto-refreshes every 5 seconds",
"当前连接总数": "Total active connections",
"协议": "Protocol",
"来源 IP": "Source IP",
"TLS": "TLS",
"明文": "Plain",
"连接时间": "Connected",
"最后活跃": "Last active",
"断开并封禁": "Disconnect & ban",
"当前没有活动连接": "No active connections",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "Disconnect all connections of IP %s and add to blacklist (180 days)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP blacklist",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "Staged bans: banning starts at the 4th trigger; 30 min → 3 h → 3 mo → 6 mo (max)",
"封禁次数": "Ban count",
"到期时间": "Expires",
"封禁中": "Banned",
"已过期": "Expired",
"仅计数": "Counting only",
"(已过期)": " (expired)",
"解封": "Unban",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "Unban IP %s? Its ban level will be reset.",
"暂无封禁记录": "No ban records",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "Please enter your email and password",
"用户名或密码错误,还剩 %d 次尝试机会": "Incorrect username or password. %d attempts remaining",
"会话保存失败,请重试": "Failed to save session, please retry",
"请输入LDAP用户名和密码": "Please enter your LDAP username and password",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP authentication failed. %d attempts remaining",
"LDAP 账号未接入本系统,请联系管理员": "LDAP account is not linked to this system. Contact the administrator",
"用户已被禁用": "User is disabled",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 state validation failed, please log in again",
"OAuth2 授权码缺失": "Missing OAuth2 authorization code",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 authentication failed. Please retry or contact the administrator",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 account is not linked to this system. Contact the administrator",
"OAuth2 未启用": "OAuth2 is not enabled",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 login is temporarily unavailable, please try again later",
"登录失败次数过多": "Too many failed login attempts",
"LDAP 登录失败次数过多": "Too many LDAP login failures",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "Please enter a recipient",
"配额检查失败,请稍后重试": "Quota check failed, please try again later",
"附件超出配额限制。已用 %s / 总配额 %s": "Attachments exceed the quota. Used %s / total %s",
"外部投递未启用": "Outbound delivery is not enabled",
"外部收件人过多:最多 %d 个": "Too many external recipients: maximum %d",
"外发邮件入队失败 (%s): %v": "Failed to enqueue outbound mail (%s): %v",
"投递邮件失败: %v": "Failed to deliver mail: %v",
"保存邮件失败: %v": "Failed to save mail: %v",
"当前密码不正确": "Current password is incorrect",
"新密码不能为空": "New password cannot be empty",
"两次输入的密码不一致": "The two passwords do not match",
"密码加密失败": "Failed to encrypt password",
"密码更新失败": "Failed to update password",
"密码修改成功": "Password changed successfully",
"邮箱不存在": "Mailbox does not exist",
"加载邮件列表失败: %v": "Failed to load messages: %v",
"无效的邮件ID": "Invalid message ID",
"邮件不存在": "Message does not exist",
"禁止访问": "Access denied",
"无效的附件ID": "Invalid attachment ID",
"附件不存在": "Attachment does not exist",
"读取附件失败": "Failed to read attachment",
"删除失败": "Delete failed",
"恢复失败": "Restore failed",
"清空文件夹失败: %v": "Failed to empty folder: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "Invalid connection ID",
"连接不存在或已断开": "Connection does not exist or is disconnected",
"管理员手动封禁(连接断开)": "Banned manually by admin (connection disconnected)",
"封禁失败: %v": "Ban failed: %v",
"加载域名列表失败: %v": "Failed to load domains: %v",
"请输入域名": "Please enter a domain name",
"创建域名失败: %v": "Failed to create domain: %v",
"无效的域名ID": "Invalid domain ID",
"域名不存在": "Domain does not exist",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Certificate fetched from Caddy and saved to the domain TLS directory; TLS enabled and hot-reloaded, no restart needed.",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS private key and certificate are required to enable TLS",
"TLS 私钥和公钥证书必须同时填写": "TLS private key and certificate must be provided together",
"TLS 证书或私钥无效: %v": "Invalid TLS certificate or private key: %v",
"创建 TLS 证书目录失败: %v": "Failed to create TLS directory: %v",
"保存 TLS 公钥证书失败: %v": "Failed to save TLS certificate: %v",
"保存 TLS 私钥失败: %v": "Failed to save TLS private key: %v",
"从 Caddy 获取证书失败: %v": "Failed to fetch certificate from Caddy: %v",
"更新域名记录失败: %v": "Failed to update domain record: %v",
"更新域名失败: %v": "Failed to update domain: %v",
"删除域名失败: %v": "Failed to delete domain: %v",
"加载用户列表失败: %v": "Failed to load users: %v",
"请填写所有必填字段": "Please fill in all required fields",
"创建用户失败: %v": "Failed to create user: %v",
"无效的用户ID": "Invalid user ID",
"不能删除自己的账户": "You cannot delete your own account",
"删除用户失败: %v": "Failed to delete user: %v",
"用户不存在": "User does not exist",
"更新用户失败: %v": "Failed to update user: %v",
"加载黑名单失败: %v": "Failed to load blacklist: %v",
"无效的记录ID": "Invalid record ID",
"解封失败: %v": "Unban failed: %v",
"加载协议日志失败: %v": "Failed to load protocol logs: %v",
"加载外发队列失败: %v": "Failed to load outbound queue: %v",
"外发服务不可用": "Outbound service is unavailable",
"重试失败: %v": "Retry failed: %v",
"取消失败: %v": "Cancel failed: %v",
"无效的队列ID": "Invalid queue ID",
"禁止访问:需要管理员权限": "Access denied: administrator privileges required",
}
// ja 日文目录:key 为界面中的原始中文字符串;缺译回退英文目录。
var ja = map[string]string{
// ---------- 12 小时制时段(日期函数) ----------
"上午": "午前",
"下午": "午後",
// ---------- 系统文件夹名(folderLabel ----------
"收件箱": "受信トレイ",
"已发送": "送信済み",
"草稿箱": "下書き",
"已删除": "ゴミ箱",
// ---------- 顶栏 / 侧栏(base.html ----------
"邮箱": "メール",
"搜索邮件(发件人 / 主题)": "メールを検索(送信者 / 件名)",
"管理后台": "管理画面",
"设置": "設定",
"退出登录": "ログアウト",
"写信": "作成",
// ---------- 邮件列表(folder.html ----------
"全选/取消全选": "すべて選択 / 選択解除",
"全选": "すべて選択",
"刷新": "更新",
"删除": "削除",
"彻底删除": "完全に削除",
"确定要清空「已删除」文件夹吗?此操作不可恢复。": "「ゴミ箱」フォルダを空にしますか?この操作は元に戻せません。",
"清空文件夹": "フォルダを空にする",
"共 %d 封": "%d 件",
"暂无邮件": "メールはありません",
"收件人:%s": "宛先:%s",
"致:": "宛先:",
"(无主题)": "(件名なし)",
"[HTML 邮件]": "[HTML メール]",
"确定要恢复这封邮件吗?": "このメールを復元しますか?",
"恢复到收件箱": "受信トレイへ復元",
"确定要删除这封邮件吗?": "このメールを削除しますか?",
"第 %d / %d 页": "%d / %d ページ",
"上一页": "前へ",
"下一页": "次へ",
"确定要删除选中的 %s 封邮件吗?": "選択した %s 件のメールを削除しますか?",
// ---------- 邮件阅读(view.html ----------
"查看邮件": "メールを表示",
"返回": "戻る",
"回复": "返信",
"恢复": "復元",
"确定要彻底删除这封邮件吗?此操作不可恢复。": "このメールを完全に削除しますか?この操作は元に戻せません。",
"删除邮件": "メールを削除",
"抄送:": "Cc",
// ---------- 写信(compose.html ----------
"发送": "送信",
"附件": "添付",
"取消": "キャンセル",
"撰写新邮件": "新しいメールを作成",
"收件人": "宛先",
"输入收件人邮箱地址": "宛先メールアドレスを入力",
"抄送": "Cc",
"多个地址用逗号分隔(可选)": "複数のアドレスはカンマ区切り(任意)",
"主题": "件名",
"输入邮件主题": "メールの件名を入力",
"请输入邮件内容...": "メール本文を入力してください...",
"附件配额": "添付ファイル容量",
"移除": "削除",
// ---------- 设置(settings.html ----------
"⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。": "⚠️ 初回ログインまたはパスワードリセット後です。メール機能を利用する前にパスワードを変更してください。",
"账号信息": "アカウント情報",
"已用 %s / 配额 %s": "使用 %s / 容量 %s",
"管理员": "管理者",
"修改密码": "パスワード変更",
"当前密码": "現在のパスワード",
"请输入当前密码": "現在のパスワードを入力",
"新密码": "新しいパスワード",
"请输入新密码": "新しいパスワードを入力",
"确认新密码": "新しいパスワード(確認)",
"请再次输入新密码": "新しいパスワードを再入力",
"界面语言": "表示言語",
"自动(跟随浏览器)": "自動(ブラウザに従う)",
"保存": "保存",
"语言偏好已保存": "言語設定を保存しました",
"语言偏好保存失败": "言語設定の保存に失敗しました",
"帮助": "ヘルプ",
"客户端收发信(IMAP / SMTP)配置:": "クライアント設定(IMAP / SMTP):",
"IMAP 服务器:%s 端口 %d / SSL %d": "IMAP サーバー:%s ポート %d / SSL %d",
"SMTP 服务器:%s 端口 %d(提交)/ SSL %d": "SMTP サーバー:%s ポート %d(サブミッション)/ SSL %d",
// ---------- 登录(login.html ----------
"登录": "ログイン",
"登录您的邮箱账户": "メールアカウントにログイン",
"邮箱地址": "メールアドレス",
"密码": "パスワード",
"请输入密码": "パスワードを入力",
"登 录": "ログイン",
"或": "または",
"LDAP 用户名": "LDAP ユーザー名",
"LDAP 密码": "LDAP パスワード",
"LDAP 登录": "LDAP ログイン",
// ---------- 封禁页(banned.html ----------
"访问被禁止": "アクセスが禁止されています",
"您的 IP 已被暂时禁止访问": "お使いの IP は一時的にアクセス禁止されています",
"由于登录失败次数过多,您的 IP 地址已被暂时封禁。": "ログイン失敗が多すぎるため、IP アドレスが一時的にブロックされています。",
"IP 地址": "IP アドレス",
"封禁档位": "ブロック段階",
"第 %d 次封禁": "%d 回目のブロック",
"原因": "理由",
"失败次数": "失敗回数",
"%d 次": "%d 回",
"解封时间": "ブロック解除時刻",
"请在解封时间后重试,或联系管理员。": "ブロック解除後にもう一度お試しいただくか、管理者に連絡してください。",
"返回登录页": "ログインページに戻る",
// ---------- 管理后台侧栏(所有 admin 页面) ----------
"返回邮箱": "メールボックスに戻る",
"控制面板": "ダッシュボード",
"域名管理": "ドメイン管理",
"用户管理": "ユーザー管理",
"所有邮件": "すべてのメール",
"外发队列": "送信キュー",
"协议日志": "プロトコルログ",
"当前连接": "現在の接続",
"IP黑名单": "IP ブラックリスト",
// ---------- 控制面板(dashboard.html ----------
"域名数": "ドメイン数",
"用户数": "ユーザー数",
"邮件总数": "メール総数",
"被封IP": "ブロック済み IP",
"邮件分布": "メール分布",
"文件夹": "フォルダ",
"邮件数": "メール数",
"占用空间": "使用容量",
"合计": "合計",
"收发统计": "送受信統計",
"时间段": "期間",
"收件": "受信",
"发件": "送信",
"今日": "今日",
"近 7 天": "過去 7 日間",
"快捷操作": "クイック操作",
"新增域名": "ドメインを追加",
"新增用户": "ユーザーを追加",
"查看所有邮件": "すべてのメールを表示",
// ---------- 用户管理(users.html / user_form.html ----------
"用户列表": "ユーザー一覧",
"暂无用户": "ユーザーはいません",
"用户名": "ユーザー名",
"域名ID": "ドメイン ID",
"配额": "容量",
"已用": "使用量",
"状态": "状態",
"操作": "操作",
"编辑": "編集",
"确定要删除用户 %s 吗?": "ユーザー %s を削除しますか?",
"编辑用户": "ユーザーを編集",
"新密码(留空则不修改)": "新しいパスワード(空欄なら変更なし)",
"域名": "ドメイン",
"请选择域名": "ドメインを選択",
"配额 (GB)": "容量 (GB)",
"启用账户": "アカウントを有効にする",
"保存更改": "変更を保存",
"创建用户": "ユーザーを作成",
// ---------- 域名管理(domains.html / domain_form.html / dns_hint.html ----------
"域名列表": "ドメイン一覧",
"暂无域名": "ドメインはありません",
"确定要删除域名 %s 吗?删除域名将导致关联用户无法收发邮件!": "ドメイン %s を削除しますか?関連ユーザーはメールの送受信ができなくなります!",
"编辑域名": "ドメインを編集",
"SMTP 端口": "SMTP ポート",
"IMAP 端口": "IMAP ポート",
"POP3 端口": "POP3 ポート",
"启用 TLS": "TLS を有効にする",
"勾选后端口将自动切换为 SSL/TLS 标准端口": "チェックするとポートは標準の SSL/TLS ポートに自動的に切り替わります",
"TLS 私钥 PEM": "TLS 秘密鍵 (PEM)",
"TLS 公钥证书 PEM": "TLS 公開鍵証明書 (PEM)",
"私钥不会回显;留空则保留现有私钥。": "秘密鍵は表示されません。空欄の場合は現在の鍵を保持します。",
"✅ TLS 证书已配置;上传新证书后自动热加载生效。": "✅ 証明書は設定済みです。新しい証明書をアップロードすると自動的にホットリロードされます。",
"⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。": "⚠️ 証明書が未設定です。TLS を有効にするには秘密鍵と証明書の両方が必要です。",
"从 Caddy 获取证书": "Caddy から証明書を取得",
"若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。": "このドメインがローカル Caddy で HTTPS 運用されている場合(自動証明書)、証明書と秘密鍵をワンクリックでインポートし、TLS を自動で有効化します。ホットリロードされるため再起動は不要です。",
"🔒 从 Caddy 获取证书": "🔒 Caddy から証明書を取得",
"获取中…": "取得中...",
"获取失败": "取得失敗",
"请求失败": "リクエスト失敗",
"重新生成 DKIM 密钥": "DKIM キーを再生成",
"✅ DKIM 密钥已配置": "✅ DKIM キー設定済み",
"⚠️ DKIM 密钥未配置": "⚠️ DKIM キー未設定",
"创建域名": "ドメインを作成",
"DNS配置": "DNS 設定",
"DNS 配置提示 — %s": "DNS 設定ヒント — %s",
"请在您的 DNS 服务商处添加以下记录:": "DNS プロバイダーで次のレコードを追加してください:",
"MX 记录": "MX レコード",
"SPF 记录 (TXT)": "SPF レコード (TXT)",
"DKIM 记录 (TXT)": "DKIM レコード (TXT)",
"DMARC 记录": "DMARC レコード",
"⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。": "⚠️ DKIM キーが未生成です。ドメインを編集して再生成してください。",
"返回域名列表": "ドメイン一覧に戻る",
// ---------- 所有邮件(mails.html / mail_view.html ----------
"发件人": "送信者",
"所属用户": "所有者",
"日期": "日付",
"全部": "すべて",
"返回邮件列表": "一覧に戻る",
"发件人:": "送信者:",
"收件人:": "宛先:",
"抄送:": "Cc",
"所属用户:": "所有者:",
"文件夹:": "フォルダ:",
"时间:": "時刻:",
"附件列表": "添付ファイル",
// ---------- 外发队列(outbound.html ----------
"队列为空": "キューは空です",
"待发送": "待機中",
"发送中": "送信中",
"已送达": "送信済み",
"等待重试": "再試行待ち",
"失败": "失敗",
"已取消": "キャンセル済み",
"尝试次数": "試行回数",
"下次重试": "次回再試行",
"最后响应 / 错误": "最終レスポンス / エラー",
"创建时间": "作成日時",
"重试": "再試行",
"确认取消该投递任务?": "この配信タスクをキャンセルしますか?",
// ---------- 协议日志(protocol_logs.html ----------
"协议日志(SMTP / IMAP / POP3": "プロトコルログ(SMTP / IMAP / POP3",
"清理旧日志": "古いログを削除",
"确认清理 %d 天前的协议日志?": "%d 日前より前のプロトコルログを削除しますか?",
"今日 SMTP 失败": "今日の SMTP 失敗",
"今日 IMAP 失败": "今日の IMAP 失敗",
"今日 POP3 失败": "今日の POP3 失敗",
"历史失败(全部)": "過去の失敗(すべて)",
"全部协议": "すべてのプロトコル",
"全部状态": "すべての状態",
"成功": "成功",
"来源 IP(模糊)": "送信元 IP(あいまい)",
"用户名(模糊)": "ユーザー名(あいまい)",
"至": "〜",
"筛选": "フィルター",
"重置": "リセット",
"时间": "時刻",
"端口": "ポート",
"失败原因": "失敗理由",
"操作摘要": "操作概要",
"消息数": "メッセージ数",
"时长": "所要時間",
"暂无记录": "記録はありません",
"第 %d / %d 页(共 %d 条)": "%d / %d ページ(全 %d 件)",
// ---------- 当前连接(connections.html ----------
"当前连接(SMTP / IMAP / POP3": "現在の接続(SMTP / IMAP / POP3",
"每 5 秒自动刷新": "5 秒ごとに自動更新",
"当前连接总数": "現在の接続数",
"协议": "プロトコル",
"来源 IP": "送信元 IP",
"TLS": "TLS",
"明文": "平文",
"连接时间": "接続時刻",
"最后活跃": "最終アクティブ",
"断开并封禁": "切断してブロック",
"当前没有活动连接": "アクティブな接続はありません",
"确定要断开 IP %s 的所有连接并加入黑名单(180 天)吗?": "IP %s のすべての接続を切断してブラックリストに追加しますか(180 日)?",
// ---------- IP 黑名单(bans.html ----------
"IP 黑名单": "IP ブラックリスト",
"阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)": "段階的ブロック:4 回目の発動からブロック開始、30分 → 3時間 → 3か月 → 半年(上限)",
"封禁次数": "ブロック回数",
"到期时间": "解除時刻",
"封禁中": "ブロック中",
"已过期": "期限切れ",
"仅计数": "カウントのみ",
"(已过期)": "(期限切れ)",
"解封": "解除",
"确定要解封 IP %s 吗?解封后该 IP 的封禁档位将清零。": "IP %s のブロックを解除しますか?ブロック段階はリセットされます。",
"暂无封禁记录": "ブロック記録はありません",
// ---------- 登录/认证错误(auth.go ----------
"请输入邮箱和密码": "メールアドレスとパスワードを入力してください",
"用户名或密码错误,还剩 %d 次尝试机会": "ユーザー名またはパスワードが違います。あと %d 回試行できます",
"会话保存失败,请重试": "セッションの保存に失敗しました。もう一度お試しください",
"请输入LDAP用户名和密码": "LDAP ユーザー名とパスワードを入力してください",
"LDAP 认证失败,还剩 %d 次尝试机会": "LDAP 認証に失敗しました。あと %d 回試行できます",
"LDAP 账号未接入本系统,请联系管理员": "LDAP アカウントはこのシステムに接続されていません。管理者に連絡してください",
"用户已被禁用": "ユーザーは無効です",
"OAuth2 state 校验失败,请重新发起登录": "OAuth2 の state 検証に失敗しました。もう一度ログインしてください",
"OAuth2 授权码缺失": "OAuth2 認可コードがありません",
"OAuth2 认证失败,请重试或联系管理员": "OAuth2 認証に失敗しました。再試行するか管理者に連絡してください",
"OAuth2 账号未接入本系统,请联系管理员": "OAuth2 アカウントはこのシステムに接続されていません。管理者に連絡してください",
"OAuth2 未启用": "OAuth2 は有効ではありません",
"OAuth2 登录暂不可用,请稍后重试": "OAuth2 ログインは一時的に利用できません。後でもう一度お試しください",
"登录失败次数过多": "ログイン失敗が多すぎます",
"LDAP 登录失败次数过多": "LDAP ログイン失敗が多すぎます",
// ---------- 邮件操作错误(mail.go ----------
"请输入收件人": "宛先を入力してください",
"配额检查失败,请稍后重试": "容量チェックに失敗しました。後でもう一度お試しください",
"附件超出配额限制。已用 %s / 总配额 %s": "添付ファイルが容量を超えています。使用 %s / 合計 %s",
"外部投递未启用": "外部送信は有効ではありません",
"外部收件人过多:最多 %d 个": "外部宛先が多すぎます:最大 %d 件",
"外发邮件入队失败 (%s): %v": "外部メールのキュー登録に失敗しました (%s): %v",
"投递邮件失败: %v": "メールの配信に失敗しました: %v",
"保存邮件失败: %v": "メールの保存に失敗しました: %v",
"当前密码不正确": "現在のパスワードが正しくありません",
"新密码不能为空": "新しいパスワードは必須です",
"两次输入的密码不一致": "パスワードが一致しません",
"密码加密失败": "パスワードの暗号化に失敗しました",
"密码更新失败": "パスワードの更新に失敗しました",
"密码修改成功": "パスワードを変更しました",
"邮箱不存在": "メールボックスが存在しません",
"加载邮件列表失败: %v": "メール一覧の読み込みに失敗しました: %v",
"无效的邮件ID": "無効なメール ID",
"邮件不存在": "メールが存在しません",
"禁止访问": "アクセスが拒否されました",
"无效的附件ID": "無効な添付ファイル ID",
"附件不存在": "添付ファイルが存在しません",
"读取附件失败": "添付ファイルの読み込みに失敗しました",
"删除失败": "削除に失敗しました",
"恢复失败": "復元に失敗しました",
"清空文件夹失败: %v": "フォルダを空にできませんでした: %v",
// ---------- 管理操作错误(admin.go ----------
"无效的连接ID": "無効な接続 ID",
"连接不存在或已断开": "接続が存在しないか切断されています",
"管理员手动封禁(连接断开)": "管理者による手動ブロック(接続切断)",
"封禁失败: %v": "ブロックに失敗しました: %v",
"加载域名列表失败: %v": "ドメイン一覧の読み込みに失敗しました: %v",
"请输入域名": "ドメイン名を入力してください",
"创建域名失败: %v": "ドメインの作成に失敗しました: %v",
"无效的域名ID": "無効なドメイン ID",
"域名不存在": "ドメインが存在しません",
"✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。": "✅ Caddy から証明書を取得し、ドメインの TLS ディレクトリに保存しました。TLS が有効になり、ホットリロード済みです。再起動は不要です。",
"启用 TLS 时必须填写 TLS 私钥和公钥证书": "TLS を有効にするには秘密鍵と証明書が必要です",
"TLS 私钥和公钥证书必须同时填写": "秘密鍵と証明書は両方必要です",
"TLS 证书或私钥无效: %v": "TLS 証明書または秘密鍵が無効です: %v",
"创建 TLS 证书目录失败: %v": "TLS ディレクトリの作成に失敗しました: %v",
"保存 TLS 公钥证书失败: %v": "TLS 証明書の保存に失敗しました: %v",
"保存 TLS 私钥失败: %v": "TLS 秘密鍵の保存に失敗しました: %v",
"从 Caddy 获取证书失败: %v": "Caddy からの証明書取得に失敗しました: %v",
"更新域名记录失败: %v": "ドメイン情報の更新に失敗しました: %v",
"更新域名失败: %v": "ドメインの更新に失敗しました: %v",
"删除域名失败: %v": "ドメインの削除に失敗しました: %v",
"加载用户列表失败: %v": "ユーザー一覧の読み込みに失敗しました: %v",
"请填写所有必填字段": "必須項目をすべて入力してください",
"创建用户失败: %v": "ユーザーの作成に失敗しました: %v",
"无效的用户ID": "無効なユーザー ID",
"不能删除自己的账户": "自分のアカウントは削除できません",
"删除用户失败: %v": "ユーザーの削除に失敗しました: %v",
"用户不存在": "ユーザーが存在しません",
"更新用户失败: %v": "ユーザーの更新に失敗しました: %v",
"加载黑名单失败: %v": "ブラックリストの読み込みに失敗しました: %v",
"无效的记录ID": "無効なレコード ID",
"解封失败: %v": "ブロック解除に失敗しました: %v",
"加载协议日志失败: %v": "プロトコルログの読み込みに失敗しました: %v",
"加载外发队列失败: %v": "送信キューの読み込みに失敗しました: %v",
"外发服务不可用": "送信サービスを利用できません",
"重试失败: %v": "再試行に失敗しました: %v",
"取消失败: %v": "キャンセルに失敗しました: %v",
"无效的队列ID": "無効なキュー ID",
"禁止访问:需要管理员权限": "アクセスが拒否されました:管理者権限が必要です",
}
+139
View File
@@ -0,0 +1,139 @@
// Package i18n provides lightweight UI localization for the MailGo web
// frontend. Supported languages: zh (Chinese), en (English, fallback) and
// ja (Japanese). Users may also pick "auto" so the effective language is
// derived from their browser's Accept-Language header.
//
// Translation keys are the original Chinese UI strings themselves: the zh
// catalog is the identity map, and the en/ja catalogs map those keys to
// their translations. Missing translations fall back to English, then to
// the key itself.
package i18n
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Language preference values stored on the user record.
const (
// LangAuto 跟随浏览器语言(Accept-Language)。
LangAuto = "auto"
// LangEn 英语:兜底语言。
LangEn = "en"
// LangZh 中文。
LangZh = "zh"
// LangJa 日文。
LangJa = "ja"
)
// Supported 判断 lang 是否为可显式选择的界面语言(不含 auto)。
func Supported(lang string) bool {
switch lang {
case LangEn, LangZh, LangJa:
return true
default:
return false
}
}
// Normalize 把浏览器语言标签规整为受支持的语言代码:
// zh-CN / zh-TW / zh-Hans → zhja-JP → jaen-US / en-GB → en
// 其余返回空串(无法识别)。
func Normalize(tag string) string {
tag = strings.ToLower(strings.TrimSpace(tag))
if i := strings.IndexByte(tag, '-'); i >= 0 {
tag = tag[:i]
}
if i := strings.IndexByte(tag, '_'); i >= 0 {
tag = tag[:i]
}
switch tag {
case LangZh, "zh-hans", "zh-hant":
return LangZh
case LangJa:
return LangJa
case LangEn:
return LangEn
default:
return ""
}
}
// FromAcceptLanguage 从 Accept-Language 请求头解析首选语言。
// 按 q 值(缺省 1.0)降序取第一个受支持的语言;都不支持时回退英语。
func FromAcceptLanguage(header string) string {
type entry struct {
lang string
q float64
}
var entries []entry
for _, part := range strings.Split(header, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
tag := part
q := 1.0
if i := strings.IndexByte(part, ';'); i >= 0 {
tag = strings.TrimSpace(part[:i])
for _, param := range strings.Split(part[i+1:], ";") {
param = strings.TrimSpace(param)
if strings.HasPrefix(param, "q=") {
if v, err := strconv.ParseFloat(param[2:], 64); err == nil {
q = v
}
}
}
}
if lang := Normalize(tag); lang != "" {
entries = append(entries, entry{lang: lang, q: q})
}
}
sort.SliceStable(entries, func(i, j int) bool { return entries[i].q > entries[j].q })
for _, e := range entries {
if e.q > 0 {
return e.lang
}
}
return LangEn
}
// Resolve 计算有效界面语言:用户偏好为显式语言时直接采用;
// auto(或空/未知值)时按浏览器 Accept-Language 解析;最终兜底英语。
func Resolve(pref, acceptLanguage string) string {
if Supported(pref) {
return pref
}
return FromAcceptLanguage(acceptLanguage)
}
// T 返回 key 在 lang 下的译文。zh 直接返回 key;
// ja 优先查日文表,缺译回退英文表;其余语言(含未知)查英文表,
// 仍缺失时原样返回 key(保证界面永不出现空文本)。
func T(lang, key string) string {
switch Normalize(lang) {
case LangZh:
return key
case LangJa:
if s, ok := ja[key]; ok {
return s
}
if s, ok := en[key]; ok {
return s
}
return key
default:
if s, ok := en[key]; ok {
return s
}
return key
}
}
// TF 是 T 的格式化版本:对译文执行 fmt.Sprintf。
// 用于带占位符的消息(如「还剩 %d 次尝试机会」)。
func TF(lang, key string, args ...interface{}) string {
return fmt.Sprintf(T(lang, key), args...)
}
+114
View File
@@ -0,0 +1,114 @@
package i18n
import "testing"
func TestNormalize(t *testing.T) {
cases := map[string]string{
"zh": "zh",
"zh-CN": "zh",
"zh-TW": "zh",
"zh-Hans": "zh",
"ja": "ja",
"ja-JP": "ja",
"en": "en",
"en-US": "en",
"en_GB": "en",
"fr": "",
"": "",
}
for in, want := range cases {
if got := Normalize(in); got != want {
t.Errorf("Normalize(%q) = %q, want %q", in, got, want)
}
}
}
func TestFromAcceptLanguage(t *testing.T) {
cases := []struct {
header string
want string
}{
{"zh-CN,zh;q=0.9,en;q=0.8", "zh"},
{"ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"en-US,en;q=0.9", "en"},
{"en;q=0.5,zh;q=0.9", "zh"}, // q 值优先
{"fr-FR,de-DE", "en"}, // 全部不支持 → 兜底英语
{"", "en"},
}
for _, tc := range cases {
if got := FromAcceptLanguage(tc.header); got != tc.want {
t.Errorf("FromAcceptLanguage(%q) = %q, want %q", tc.header, got, tc.want)
}
}
}
func TestResolve(t *testing.T) {
cases := []struct {
pref, header, want string
}{
{"auto", "ja-JP,ja;q=0.9,en;q=0.8", "ja"},
{"auto", "en-US,en;q=0.9", "en"},
{"auto", "fr-FR", "en"},
{"zh", "en-US,en;q=0.9", "zh"}, // 显式偏好优先于浏览器
{"ja", "en-US", "ja"},
{"en", "zh-CN,zh;q=0.9", "en"},
{"", "zh-CN", "zh"},
{"fr", "zh-CN", "zh"}, // 非法偏好按 auto 处理
}
for _, tc := range cases {
if got := Resolve(tc.pref, tc.header); got != tc.want {
t.Errorf("Resolve(%q, %q) = %q, want %q", tc.pref, tc.header, got, tc.want)
}
}
}
func TestT(t *testing.T) {
key := "收件箱"
// zh 恒等于 key 本身
if got := T("zh", key); got != key {
t.Errorf("T(zh) = %q, want %q", got, key)
}
// en
if got, want := T("en", key), "Inbox"; got != want {
t.Errorf("T(en) = %q, want %q", got, want)
}
// ja
if got, want := T("ja", key), "受信トレイ"; got != want {
t.Errorf("T(ja) = %q, want %q", got, want)
}
// 未知语言 → 英文兜底
if got := T("fr", key); got != "Inbox" {
t.Errorf("T(fr) = %q, want Inbox", got)
}
// 无此键 → 原样返回 key
if got := T("en", "不存在的键"); got != "不存在的键" {
t.Errorf("T(missing) = %q, want key itself", got)
}
}
func TestTF(t *testing.T) {
key := "用户名或密码错误,还剩 %d 次尝试机会"
if got, want := TF("zh", key, 3), "用户名或密码错误,还剩 3 次尝试机会"; got != want {
t.Errorf("TF(zh) = %q, want %q", got, want)
}
if got, want := TF("en", key, 2), "Incorrect username or password. 2 attempts remaining"; got != want {
t.Errorf("TF(en) = %q, want %q", got, want)
}
if got, want := TF("ja", key, 1), "ユーザー名またはパスワードが違います。あと 1 回試行できます"; got != want {
t.Errorf("TF(ja) = %q, want %q", got, want)
}
}
// TestCatalogCoverage 校验 en/ja 目录中不包含空译文。
func TestCatalogCoverage(t *testing.T) {
for k, v := range en {
if v == "" {
t.Errorf("en 目录空译文: %q", k)
}
}
for k, v := range ja {
if v == "" {
t.Errorf("ja 目录空译文: %q", k)
}
}
}
+13
View File
@@ -30,6 +30,8 @@ type UserStore interface {
ListAll(page, size int) ([]db.User, int64, error) ListAll(page, size int) ([]db.User, int64, error)
UpdateUsedBytes(id uint, delta int64) error UpdateUsedBytes(id uint, delta int64) error
UpdatePassword(userID uint, hashedPassword string) error UpdatePassword(userID uint, hashedPassword string) error
// UpdateLanguage 更新用户界面语言偏好(auto | en | zh | ja)。
UpdateLanguage(userID uint, lang string) error
// TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true, // TryReserveQuota 原子预扣 delta 字节:仅在不超过配额时生效并返回 true,
// 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。 // 否则不做任何修改返回 false。防止并发提交绕过配额检查(TOCTOU)。
TryReserveQuota(userID uint, delta int64) (bool, error) TryReserveQuota(userID uint, delta int64) (bool, error)
@@ -209,6 +211,17 @@ func (s *userStoreGorm) UpdatePassword(userID uint, hashedPassword string) error
}).Error }).Error
} }
// UpdateLanguage 更新用户界面语言偏好。非法值(非 auto/en/zh/ja)回退 auto。
func (s *userStoreGorm) UpdateLanguage(userID uint, lang string) error {
switch lang {
case "auto", "en", "zh", "ja":
default:
lang = "auto"
}
return s.db.Model(&db.User{}).Where("id = ?", userID).
Update("language", lang).Error
}
// ListAll retrieves a paginated list of all users across all domains. // ListAll retrieves a paginated list of all users across all domains.
func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) { func (s *userStoreGorm) ListAll(page, size int) ([]db.User, int64, error) {
var users []db.User var users []db.User
+114 -106
View File
@@ -16,6 +16,7 @@ import (
"mail_go/internal/connhub" "mail_go/internal/connhub"
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/dkim" "mail_go/internal/dkim"
"mail_go/internal/i18n"
"mail_go/internal/outbound" "mail_go/internal/outbound"
"mail_go/internal/storage" "mail_go/internal/storage"
"mail_go/internal/store" "mail_go/internal/store"
@@ -69,13 +70,13 @@ const manualBanDuration = 180 * 24 * time.Hour
func (h *AdminHandler) DisconnectConnection(c *gin.Context) { func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的连接ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的连接ID"))
return return
} }
conn, ok := h.hub.Get(id) conn, ok := h.hub.Get(id)
if !ok { if !ok {
c.String(http.StatusNotFound, "连接不存在或已断开") c.String(http.StatusNotFound, i18n.T(langOf(c), "连接不存在或已断开"))
return return
} }
@@ -83,7 +84,7 @@ func (h *AdminHandler) DisconnectConnection(c *gin.Context) {
// BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条, // BanIP 为 upsert 语义:清理该 IP 既有观察/重复记录后仅保留一条,
// 避免与阶段性封禁的计数/档位记录错位。 // 避免与阶段性封禁的计数/档位记录错位。
if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil { if err := h.stores.Bans.BanIP(conn.IP, "管理员手动封禁(连接断开)", manualBanDuration); err != nil {
c.String(http.StatusInternalServerError, "封禁失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "封禁失败: %v", err))
return return
} }
@@ -105,7 +106,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_connections", gin.H{ c.HTML(200, "admin_connections", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"conns": conns, "conns": conns,
"total": total, "total": total,
@@ -114,7 +115,7 @@ func (h *AdminHandler) ListConnections(c *gin.Context) {
"pop3Count": pop3Count, "pop3Count": pop3Count,
"now": time.Now(), "now": time.Now(),
"activeFolder": "connections", "activeFolder": "connections",
}) }))
} }
// Dashboard renders the admin dashboard with summary statistics. // Dashboard renders the admin dashboard with summary statistics.
@@ -153,7 +154,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dashboard", gin.H{ c.HTML(200, "admin_dashboard", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"domainCount": domainCount, "domainCount": domainCount,
"userCount": userCount, "userCount": userCount,
@@ -171,7 +172,7 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
"weekSent": weekSent, "weekSent": weekSent,
"banCount": banCount, "banCount": banCount,
"activeFolder": "admin", "activeFolder": "admin",
}) }))
} }
// ListDomains renders the domain list page. // ListDomains renders the domain list page.
@@ -179,7 +180,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
page := getPageParam(c, "page", 1) page := getPageParam(c, "page", 1)
domains, total, err := h.stores.Domains.List(page, 20) domains, total, err := h.stores.Domains.List(page, 20)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载域名列表失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载域名列表失败: %v", err))
return return
} }
@@ -193,7 +194,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
totalPages = 0 totalPages = 0
} }
c.HTML(200, "admin_domains", gin.H{ c.HTML(200, "admin_domains", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"domains": domains, "domains": domains,
"total": total, "total": total,
@@ -201,20 +202,20 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
"pageSize": 20, "pageSize": 20,
"totalPages": totalPages, "totalPages": totalPages,
"activeFolder": "domains", "activeFolder": "domains",
}) }))
} }
// NewDomain renders the new domain form page. // NewDomain renders the new domain form page.
func (h *AdminHandler) NewDomain(c *gin.Context) { func (h *AdminHandler) NewDomain(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_domain_form", gin.H{ c.HTML(200, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": "", "error": "",
"isEdit": false, "isEdit": false,
"domain": &db.Domain{}, "domain": &db.Domain{},
}) }))
} }
// CreateDomain processes the new domain form submission. // CreateDomain processes the new domain form submission.
@@ -227,10 +228,10 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
if name == "" { if name == "" {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": "请输入域名", "error": i18n.T(langOf(c), "请输入域名"),
"isEdit": false, "isEdit": false,
"domain": &db.Domain{ "domain": &db.Domain{
Name: name, Name: name,
@@ -239,7 +240,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
Pop3Port: pop3Port, Pop3Port: pop3Port,
TlsEnabled: tlsEnabled, TlsEnabled: tlsEnabled,
}, },
}) }))
return return
} }
@@ -263,13 +264,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
if err := h.stores.Domains.Create(domain); err != nil { if err := h.stores.Domains.Create(domain); err != nil {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": fmt.Sprintf("创建域名失败: %v", err), "error": i18n.TF(langOf(c), "创建域名失败: %v", err),
"isEdit": false, "isEdit": false,
"domain": domain, "domain": domain,
}) }))
return return
} }
@@ -280,13 +281,13 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
func (h *AdminHandler) EditDomain(c *gin.Context) { func (h *AdminHandler) EditDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return return
} }
domain, err := h.stores.Domains.GetByID(uint(id)) domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "域名不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return return
} }
@@ -297,11 +298,11 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
caddyMsg = c.Query("caddy_err") caddyMsg = c.Query("caddy_err")
caddyMsgType = "error" caddyMsgType = "error"
} else if c.Query("caddy_ok") == "1" { } else if c.Query("caddy_ok") == "1" {
caddyMsg = "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。" caddyMsg = i18n.T(langOf(c), "✅ 已从 Caddy 获取证书并保存到域名 TLS 目录,同时已启用该域名的 TLS;证书已热加载,无需重启服务。")
caddyMsgType = "success" caddyMsgType = "success"
} }
c.HTML(200, "admin_domain_form", gin.H{ c.HTML(200, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": "", "error": "",
@@ -311,20 +312,20 @@ func (h *AdminHandler) EditDomain(c *gin.Context) {
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "", "tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
"caddyMsg": caddyMsg, "caddyMsg": caddyMsg,
"caddyMsgType": caddyMsgType, "caddyMsgType": caddyMsgType,
}) }))
} }
// UpdateDomain 处理编辑域名表单提交 // UpdateDomain 处理编辑域名表单提交
func (h *AdminHandler) UpdateDomain(c *gin.Context) { func (h *AdminHandler) UpdateDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return return
} }
domain, err := h.stores.Domains.GetByID(uint(id)) domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "域名不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return return
} }
@@ -335,7 +336,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key")) tlsPrivateKey := strings.TrimSpace(c.PostForm("tls_private_key"))
tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert")) tlsPublicCert := strings.TrimSpace(c.PostForm("tls_public_cert"))
if err := h.handleDomainTLSUpdate(domain, tlsPublicCert, tlsPrivateKey); err != nil { if err := h.handleDomainTLSUpdate(langOf(c), domain, tlsPublicCert, tlsPrivateKey); err != nil {
h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert) h.renderDomainFormError(c, domain, err.Error(), tlsPublicCert)
return return
} }
@@ -351,7 +352,7 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
} }
if err := h.stores.Domains.Update(domain); err != nil { if err := h.stores.Domains.Update(domain); err != nil {
h.renderDomainFormError(c, domain, fmt.Sprintf("更新域名失败: %v", err), tlsPublicCert) h.renderDomainFormError(c, domain, i18n.TF(langOf(c), "更新域名失败: %v", err), tlsPublicCert)
return return
} }
@@ -364,43 +365,45 @@ func (h *AdminHandler) UpdateDomain(c *gin.Context) {
func (h *AdminHandler) FetchCaddyCert(c *gin.Context) { func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return return
} }
domain, err := h.stores.Domains.GetByID(uint(id)) domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "域名不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return return
} }
lang := langOf(c)
editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID) editURL := fmt.Sprintf("/admin/domains/%d/edit", domain.ID)
fail := func(msg string) { fail := func(key string, args ...interface{}) {
msg := i18n.TF(lang, key, args...)
log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg) log.Printf("从 Caddy 获取证书失败 domain=%s: %s", domain.Name, msg)
c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg)) c.Redirect(http.StatusFound, editURL+"?caddy_err="+url.QueryEscape(msg))
} }
cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots()) cert, err := caddycert.Fetch(domain.Name, h.caddyCertRoots())
if err != nil { if err != nil {
fail(fmt.Sprintf("从 Caddy 获取证书失败: %v", err)) fail("从 Caddy 获取证书失败: %v", err)
return return
} }
// 保存到域名 TLS 目录(与手动上传证书的位置一致) // 保存到域名 TLS 目录(与手动上传证书的位置一致)
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10)) domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil { if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
fail(fmt.Sprintf("创建 TLS 证书目录失败: %v", err)) fail("创建 TLS 证书目录失败: %v", err)
return return
} }
certPath := filepath.Join(domainTLSDir, "cert.pem") certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem") keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil { if err := os.WriteFile(certPath, cert.CertPEM, 0644); err != nil {
fail(fmt.Sprintf("保存 TLS 公钥证书失败: %v", err)) fail("保存 TLS 公钥证书失败: %v", err)
return return
} }
if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil { if err := os.WriteFile(keyPath, cert.KeyPEM, 0600); err != nil {
fail(fmt.Sprintf("保存 TLS 私钥失败: %v", err)) fail("保存 TLS 私钥失败: %v", err)
return return
} }
@@ -408,7 +411,7 @@ func (h *AdminHandler) FetchCaddyCert(c *gin.Context) {
domain.TlsKeyPath = keyPath domain.TlsKeyPath = keyPath
domain.TlsEnabled = true domain.TlsEnabled = true
if err := h.stores.Domains.Update(domain); err != nil { if err := h.stores.Domains.Update(domain); err != nil {
fail(fmt.Sprintf("更新域名记录失败: %v", err)) fail("更新域名记录失败: %v", err)
return return
} }
@@ -451,7 +454,7 @@ func normalizePEM(s string) string {
return s return s
} }
func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, privateKey string) error { func (h *AdminHandler) handleDomainTLSUpdate(lang string, domain *db.Domain, publicCert, privateKey string) error {
// 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改 // 归一化换行,保证与磁盘文件一致,避免表单往返时被误判为已修改
publicCert = normalizePEM(strings.TrimSpace(publicCert)) publicCert = normalizePEM(strings.TrimSpace(publicCert))
privateKey = normalizePEM(strings.TrimSpace(privateKey)) privateKey = normalizePEM(strings.TrimSpace(privateKey))
@@ -465,31 +468,31 @@ func (h *AdminHandler) handleDomainTLSUpdate(domain *db.Domain, publicCert, priv
if hasExistingCert { if hasExistingCert {
return nil return nil
} }
return fmt.Errorf("启用 TLS 时必须填写 TLS 私钥和公钥证书") return fmt.Errorf("%s", i18n.T(lang, "启用 TLS 时必须填写 TLS 私钥和公钥证书"))
} }
if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert { if hasExistingCert && privateKey == "" && normalizePEM(strings.TrimSpace(readTLSCert(domain.TlsCertPath))) == publicCert {
return nil return nil
} }
if publicCert == "" || privateKey == "" { if publicCert == "" || privateKey == "" {
return fmt.Errorf("TLS 私钥和公钥证书必须同时填写") return fmt.Errorf("%s", i18n.T(lang, "TLS 私钥和公钥证书必须同时填写"))
} }
if _, err := tls.X509KeyPair([]byte(publicCert), []byte(privateKey)); err != nil { if _, err := tls.X509KeyPair([]byte(publicCert), []byte(privateKey)); err != nil {
return fmt.Errorf("TLS 证书或私钥无效: %v", err) return fmt.Errorf("%s", i18n.TF(lang, "TLS 证书或私钥无效: %v", err))
} }
domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10)) domainTLSDir := filepath.Join(h.tlsDir, strconv.FormatUint(uint64(domain.ID), 10))
if err := os.MkdirAll(domainTLSDir, 0700); err != nil { if err := os.MkdirAll(domainTLSDir, 0700); err != nil {
return fmt.Errorf("创建 TLS 证书目录失败: %v", err) return fmt.Errorf("%s", i18n.TF(lang, "创建 TLS 证书目录失败: %v", err))
} }
certPath := filepath.Join(domainTLSDir, "cert.pem") certPath := filepath.Join(domainTLSDir, "cert.pem")
keyPath := filepath.Join(domainTLSDir, "key.pem") keyPath := filepath.Join(domainTLSDir, "key.pem")
if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil { if err := os.WriteFile(certPath, []byte(publicCert+"\n"), 0644); err != nil {
return fmt.Errorf("保存 TLS 公钥证书失败: %v", err) return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 公钥证书失败: %v", err))
} }
if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil { if err := os.WriteFile(keyPath, []byte(privateKey+"\n"), 0600); err != nil {
return fmt.Errorf("保存 TLS 私钥失败: %v", err) return fmt.Errorf("%s", i18n.TF(lang, "保存 TLS 私钥失败: %v", err))
} }
domain.TlsCertPath = certPath domain.TlsCertPath = certPath
@@ -503,7 +506,7 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
tlsPublicCert = readTLSCert(domain.TlsCertPath) tlsPublicCert = readTLSCert(domain.TlsCertPath)
} }
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_domain_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"error": message, "error": message,
@@ -511,19 +514,19 @@ func (h *AdminHandler) renderDomainFormError(c *gin.Context, domain *db.Domain,
"domain": domain, "domain": domain,
"tlsPublicCert": tlsPublicCert, "tlsPublicCert": tlsPublicCert,
"tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "", "tlsCertConfigured": domain.TlsCertPath != "" && domain.TlsKeyPath != "",
}) }))
} }
// DeleteDomain removes a domain by ID. // DeleteDomain removes a domain by ID.
func (h *AdminHandler) DeleteDomain(c *gin.Context) { func (h *AdminHandler) DeleteDomain(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return return
} }
if err := h.stores.Domains.Delete(uint(id)); err != nil { if err := h.stores.Domains.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "删除域名失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除域名失败: %v", err))
return return
} }
@@ -534,24 +537,24 @@ func (h *AdminHandler) DeleteDomain(c *gin.Context) {
func (h *AdminHandler) DNSHint(c *gin.Context) { func (h *AdminHandler) DNSHint(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的域名ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的域名ID"))
return return
} }
domain, err := h.stores.Domains.GetByID(uint(id)) domain, err := h.stores.Domains.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "域名不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "域名不存在"))
return return
} }
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_dns_hint", gin.H{ c.HTML(200, "admin_dns_hint", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "domains", "activeFolder": "domains",
"domain": domain, "domain": domain,
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey), "dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
}) }))
} }
// ListUsers renders the user list page. // ListUsers renders the user list page.
@@ -560,7 +563,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
users, total, err := h.stores.Users.ListAll(page, 20) users, total, err := h.stores.Users.ListAll(page, 20)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载用户列表失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载用户列表失败: %v", err))
return return
} }
@@ -577,7 +580,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
totalPages = 0 totalPages = 0
} }
c.HTML(200, "admin_users", gin.H{ c.HTML(200, "admin_users", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"users": users, "users": users,
"domains": domains, "domains": domains,
@@ -586,7 +589,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
"pageSize": 20, "pageSize": 20,
"totalPages": totalPages, "totalPages": totalPages,
"activeFolder": "users", "activeFolder": "users",
}) }))
} }
// NewUser renders the new user form page. // NewUser renders the new user form page.
@@ -595,14 +598,14 @@ func (h *AdminHandler) NewUser(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_user_form", gin.H{ c.HTML(200, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": "", "error": "",
"isEdit": false, "isEdit": false,
"domains": domains, "domains": domains,
"user": &db.User{}, "user": &db.User{},
}) }))
} }
// CreateUser processes the new user form submission. // CreateUser processes the new user form submission.
@@ -616,10 +619,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
if username == "" || password == "" || domainID == 0 { if username == "" || password == "" || domainID == 0 {
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": "请填写所有必填字段", "error": i18n.T(langOf(c), "请填写所有必填字段"),
"isEdit": false, "isEdit": false,
"domains": domains, "domains": domains,
"user": &db.User{ "user": &db.User{
@@ -627,7 +630,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
DomainID: domainID, DomainID: domainID,
IsAdmin: isAdmin, IsAdmin: isAdmin,
}, },
}) }))
return return
} }
@@ -636,10 +639,10 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
if err != nil { if err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": "密码加密失败", "error": i18n.T(langOf(c), "密码加密失败"),
"isEdit": false, "isEdit": false,
"domains": domains, "domains": domains,
"user": &db.User{ "user": &db.User{
@@ -647,7 +650,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
DomainID: domainID, DomainID: domainID,
IsAdmin: isAdmin, IsAdmin: isAdmin,
}, },
}) }))
return return
} }
@@ -661,19 +664,20 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
UsedBytes: 0, UsedBytes: 0,
IsActive: true, IsActive: true,
IsAdmin: isAdmin, IsAdmin: isAdmin,
Language: normLang(c.PostForm("language")),
} }
if err := h.stores.Users.Create(user); err != nil { if err := h.stores.Users.Create(user); err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": fmt.Sprintf("创建用户失败: %v", err), "error": i18n.TF(langOf(c), "创建用户失败: %v", err),
"isEdit": false, "isEdit": false,
"domains": domains, "domains": domains,
"user": user, "user": user,
}) }))
return return
} }
@@ -684,18 +688,18 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
func (h *AdminHandler) DeleteUser(c *gin.Context) { func (h *AdminHandler) DeleteUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return return
} }
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
if currentUser.(*db.User).ID == uint(id) { if currentUser.(*db.User).ID == uint(id) {
c.String(http.StatusBadRequest, "不能删除自己的账户") c.String(http.StatusBadRequest, i18n.T(langOf(c), "不能删除自己的账户"))
return return
} }
if err := h.stores.Users.Delete(uint(id)); err != nil { if err := h.stores.Users.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "删除用户失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "删除用户失败: %v", err))
return return
} }
@@ -706,40 +710,40 @@ func (h *AdminHandler) DeleteUser(c *gin.Context) {
func (h *AdminHandler) EditUser(c *gin.Context) { func (h *AdminHandler) EditUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return return
} }
user, err := h.stores.Users.GetByID(uint(id)) user, err := h.stores.Users.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "用户不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
return return
} }
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_user_form", gin.H{ c.HTML(200, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": "", "error": "",
"isEdit": true, "isEdit": true,
"domains": domains, "domains": domains,
"user": user, "user": user,
}) }))
} }
// UpdateUser processes the edit user form submission. // UpdateUser processes the edit user form submission.
func (h *AdminHandler) UpdateUser(c *gin.Context) { func (h *AdminHandler) UpdateUser(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的用户ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的用户ID"))
return return
} }
user, err := h.stores.Users.GetByID(uint(id)) user, err := h.stores.Users.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "用户不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "用户不存在"))
return return
} }
@@ -757,6 +761,10 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024 user.QuotaBytes = int64(quotaGB) * 1024 * 1024 * 1024
user.IsActive = isActive user.IsActive = isActive
user.IsAdmin = isAdmin user.IsAdmin = isAdmin
// 界面语言偏好(表单未提交时保持原值)
if lang := c.PostForm("language"); lang != "" {
user.Language = normLang(lang)
}
// Update password only if a new one is provided // Update password only if a new one is provided
if password != "" { if password != "" {
@@ -764,14 +772,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
if err != nil { if err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ c.HTML(http.StatusInternalServerError, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": "密码加密失败", "error": i18n.T(langOf(c), "密码加密失败"),
"isEdit": true, "isEdit": true,
"domains": domains, "domains": domains,
"user": user, "user": user,
}) }))
return return
} }
user.PasswordHash = string(hashedPassword) user.PasswordHash = string(hashedPassword)
@@ -782,14 +790,14 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
if err := h.stores.Users.Update(user); err != nil { if err := h.stores.Users.Update(user); err != nil {
domains, _, _ := h.stores.Domains.List(1, 1000) domains, _, _ := h.stores.Domains.List(1, 1000)
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ c.HTML(http.StatusBadRequest, "admin_user_form", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "users", "activeFolder": "users",
"error": fmt.Sprintf("更新用户失败: %v", err), "error": i18n.TF(langOf(c), "更新用户失败: %v", err),
"isEdit": true, "isEdit": true,
"domains": domains, "domains": domains,
"user": user, "user": user,
}) }))
return return
} }
@@ -802,7 +810,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
bans, total, err := h.stores.Bans.List(page, 20) bans, total, err := h.stores.Bans.List(page, 20)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载黑名单失败: %v", err))
return return
} }
@@ -823,7 +831,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
totalPages = 0 totalPages = 0
} }
c.HTML(200, "admin_bans", gin.H{ c.HTML(200, "admin_bans", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"rows": rows, "rows": rows,
"total": total, "total": total,
@@ -831,7 +839,7 @@ func (h *AdminHandler) ListBans(c *gin.Context) {
"pageSize": 20, "pageSize": 20,
"totalPages": totalPages, "totalPages": totalPages,
"activeFolder": "bans", "activeFolder": "bans",
}) }))
} }
// banRow 是黑名单列表行:附带了当前是否封禁中的标记。 // banRow 是黑名单列表行:附带了当前是否封禁中的标记。
@@ -844,12 +852,12 @@ type banRow struct {
func (h *AdminHandler) UnbanIP(c *gin.Context) { func (h *AdminHandler) UnbanIP(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的记录ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的记录ID"))
return return
} }
if err := h.stores.Bans.Delete(uint(id)); err != nil { if err := h.stores.Bans.Delete(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "解封失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "解封失败: %v", err))
return return
} }
@@ -892,7 +900,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter) logs, total, err := h.stores.ProtocolLogs.List(page, pageSize, filter)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载协议日志失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载协议日志失败: %v", err))
return return
} }
@@ -928,7 +936,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
"to": url.QueryEscape(c.Query("to")), "to": url.QueryEscape(c.Query("to")),
} }
c.HTML(200, "admin_protocol_logs", gin.H{ c.HTML(200, "admin_protocol_logs", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"logs": logs, "logs": logs,
"total": total, "total": total,
@@ -940,7 +948,7 @@ func (h *AdminHandler) ListProtocolLogs(c *gin.Context) {
"allStats": normStats(allStats), "allStats": normStats(allStats),
"keepDays": h.protocolLogKeepDays, "keepDays": h.protocolLogKeepDays,
"activeFolder": "protocol-logs", "activeFolder": "protocol-logs",
}) }))
} }
// CleanupProtocolLogs 手动清理超出保留天数的协议日志。 // CleanupProtocolLogs 手动清理超出保留天数的协议日志。
@@ -978,7 +986,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
} }
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
return return
} }
@@ -992,7 +1000,7 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
totalPages = 0 totalPages = 0
} }
c.HTML(200, "admin_mails", gin.H{ c.HTML(200, "admin_mails", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"messages": messages, "messages": messages,
"total": total, "total": total,
@@ -1001,20 +1009,20 @@ func (h *AdminHandler) ListMails(c *gin.Context) {
"totalPages": totalPages, "totalPages": totalPages,
"folder": folder, "folder": folder,
"activeFolder": "mails", "activeFolder": "mails",
}) }))
} }
// AdminViewMail renders the detail view of a specific mail for admin. // AdminViewMail renders the detail view of a specific mail for admin.
func (h *AdminHandler) AdminViewMail(c *gin.Context) { func (h *AdminHandler) AdminViewMail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "邮件不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
return return
} }
@@ -1023,31 +1031,31 @@ func (h *AdminHandler) AdminViewMail(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_mail_view", gin.H{ c.HTML(200, "admin_mail_view", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"message": msg, "message": msg,
"attachments": attachments, "attachments": attachments,
"activeFolder": "mails", "activeFolder": "mails",
}) }))
} }
// AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check). // AdminDownloadAttachment serves an attachment file for admin (bypasses user ownership check).
func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) { func (h *AdminHandler) AdminDownloadAttachment(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的附件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
return return
} }
att, err := h.stores.Attachments.GetByID(uint(id)) att, err := h.stores.Attachments.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "附件不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
return return
} }
data, err := h.storage.Read(att.FilePath) data, err := h.storage.Read(att.FilePath)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "读取附件失败") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
return return
} }
@@ -1062,7 +1070,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
items, total, err := h.stores.Outbound.List(page, 20, status) items, total, err := h.stores.Outbound.List(page, 20, status)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载外发队列失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载外发队列失败: %v", err))
return return
} }
@@ -1088,7 +1096,7 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "admin_outbound", gin.H{ c.HTML(200, "admin_outbound", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"items": items, "items": items,
"total": total, "total": total,
@@ -1099,23 +1107,23 @@ func (h *AdminHandler) ListOutbound(c *gin.Context) {
"statCounts": statCounts, "statCounts": statCounts,
"statusText": outbound.StatusText, "statusText": outbound.StatusText,
"activeFolder": "outbound", "activeFolder": "outbound",
}) }))
} }
// RetryOutbound resets an outbound queue item for immediate redelivery. // RetryOutbound resets an outbound queue item for immediate redelivery.
func (h *AdminHandler) RetryOutbound(c *gin.Context) { func (h *AdminHandler) RetryOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的队列ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
return return
} }
if h.outbound == nil { if h.outbound == nil {
c.String(http.StatusInternalServerError, "外发服务不可用") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
return return
} }
if err := h.outbound.Retry(uint(id)); err != nil { if err := h.outbound.Retry(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "重试失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "重试失败: %v", err))
return return
} }
@@ -1126,16 +1134,16 @@ func (h *AdminHandler) RetryOutbound(c *gin.Context) {
func (h *AdminHandler) CancelOutbound(c *gin.Context) { func (h *AdminHandler) CancelOutbound(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的队列ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的队列ID"))
return return
} }
if h.outbound == nil { if h.outbound == nil {
c.String(http.StatusInternalServerError, "外发服务不可用") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "外发服务不可用"))
return return
} }
if err := h.outbound.Cancel(uint(id)); err != nil { if err := h.outbound.Cancel(uint(id)); err != nil {
c.String(http.StatusInternalServerError, "取消失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "取消失败: %v", err))
return return
} }
+3 -3
View File
@@ -83,7 +83,7 @@ func TestHandleDomainTLSUpdateUnchangedCertCRLF(t *testing.T) {
domain := writeExistingCert(t, h.tlsDir, certLF, keyLF) domain := writeExistingCert(t, h.tlsDir, certLF, keyLF)
submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交 submittedCert := strings.ReplaceAll(certLF, "\n", "\r\n") // 模拟浏览器提交
if err := h.handleDomainTLSUpdate(domain, submittedCert, ""); err != nil { if err := h.handleDomainTLSUpdate("zh", domain, submittedCert, ""); err != nil {
t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err) t.Fatalf("证书未修改且私钥留空时应保留现有私钥,实际报错: %v", err)
} }
} }
@@ -95,7 +95,7 @@ func TestHandleDomainTLSUpdateNewPairCRLF(t *testing.T) {
h := &AdminHandler{tlsDir: t.TempDir()} h := &AdminHandler{tlsDir: t.TempDir()}
domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true} domain := &db.Domain{ID: 1, Name: "mail.example.com", TlsEnabled: true}
if err := h.handleDomainTLSUpdate(domain, certCRLF, keyCRLF); err != nil { if err := h.handleDomainTLSUpdate("zh", domain, certCRLF, keyCRLF); err != nil {
t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err) t.Fatalf("CRLF 提交的新证书应保存成功,实际报错: %v", err)
} }
@@ -119,7 +119,7 @@ func TestHandleDomainTLSUpdateChangedCertWithoutKey(t *testing.T) {
h := &AdminHandler{tlsDir: t.TempDir()} h := &AdminHandler{tlsDir: t.TempDir()}
domain := writeExistingCert(t, h.tlsDir, certA, keyA) domain := writeExistingCert(t, h.tlsDir, certA, keyA)
err := h.handleDomainTLSUpdate(domain, certB, "") err := h.handleDomainTLSUpdate("zh", domain, certB, "")
if err == nil || !strings.Contains(err.Error(), "必须同时填写") { if err == nil || !strings.Contains(err.Error(), "必须同时填写") {
t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err) t.Fatalf("修改证书但私钥留空应报“必须同时填写”,实际: %v", err)
} }
+52 -51
View File
@@ -11,6 +11,7 @@ import (
"mail_go/config" "mail_go/config"
"mail_go/internal/auth" "mail_go/internal/auth"
"mail_go/internal/i18n"
"mail_go/internal/store" "mail_go/internal/store"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -37,12 +38,12 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
c.Redirect(302, "/inbox") c.Redirect(302, "/inbox")
return return
} }
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "", "error": "",
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
} }
// DoLogin processes the login form submission. // DoLogin processes the login form submission.
@@ -54,7 +55,7 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
// Check if IP is banned // Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip) banned, entry := h.stores.Bans.IsBanned(ip)
if banned { if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return return
} }
@@ -62,12 +63,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
password := c.PostForm("password") password := c.PostForm("password")
if email == "" || password == "" { if email == "" || password == "" {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "请输入邮箱和密码", "error": i18n.T(langOf(c), "请输入邮箱和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -79,17 +80,17 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser) banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "登录失败次数过多", knownUser)
if banned { if banned {
entry, _ := h.stores.Bans.GetByIP(ip) entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return return
} }
remaining := h.banCfg.MaxFailAttempts - failCount remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining), "error": i18n.TF(langOf(c), "用户名或密码错误,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -105,12 +106,12 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin) session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix()) session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "会话保存失败,请重试", "error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -124,7 +125,7 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
// Check if IP is banned // Check if IP is banned
banned, entry := h.stores.Bans.IsBanned(ip) banned, entry := h.stores.Bans.IsBanned(ip)
if banned { if banned {
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return return
} }
@@ -132,12 +133,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
password := c.PostForm("password") password := c.PostForm("password")
if username == "" || password == "" { if username == "" || password == "" {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "请输入LDAP用户名和密码", "error": i18n.T(langOf(c), "请输入LDAP用户名和密码"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -153,39 +154,39 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true) banned, failCount := h.stores.RecordAuthFailure(ip, h.banCfg.MaxFailAttempts, h.banCfg.BanDurationMin, "LDAP 登录失败次数过多", true)
if banned { if banned {
entry, _ := h.stores.Bans.GetByIP(ip) entry, _ := h.stores.Bans.GetByIP(ip)
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry}) c.HTML(http.StatusForbidden, "banned", withLang(c, gin.H{"entry": entry}))
return return
} }
remaining := h.banCfg.MaxFailAttempts - failCount remaining := h.banCfg.MaxFailAttempts - failCount
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会", remaining), "error": i18n.TF(langOf(c), "LDAP 认证失败,还剩 %d 次尝试机会", remaining),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
// Look up or auto-create user by email // Look up or auto-create user by email
user, err := h.stores.Users.GetByEmail(email) user, err := h.stores.Users.GetByEmail(email)
if err != nil { if err != nil {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "LDAP 账号未接入本系统,请联系管理员", "error": i18n.T(langOf(c), "LDAP 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
if !user.IsActive { if !user.IsActive {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "用户已被禁用", "error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -201,12 +202,12 @@ func (h *AuthHandler) LDAPLogin(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin) session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix()) session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "会话保存失败,请重试", "error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -246,7 +247,7 @@ func (h *AuthHandler) oauth2LoginVars() gin.H {
// OAuth2Start redirects to the OAuth2 provider's authorization page. // OAuth2Start redirects to the OAuth2 provider's authorization page.
func (h *AuthHandler) OAuth2Start(c *gin.Context) { func (h *AuthHandler) OAuth2Start(c *gin.Context) {
if !h.authCfg.OAuth2Enabled { if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用") c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
return return
} }
@@ -254,7 +255,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
state, err := randomOAuth2State() state, err := randomOAuth2State()
if err != nil { if err != nil {
log.Printf("生成 OAuth2 state 失败: %v", err) log.Printf("生成 OAuth2 state 失败: %v", err)
c.String(http.StatusInternalServerError, "OAuth2 登录暂不可用,请稍后重试") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "OAuth2 登录暂不可用,请稍后重试"))
return return
} }
c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true) c.SetCookie(oauth2StateCookie, state, oauth2StateMaxAge, "/auth/oauth2", "", true, true)
@@ -264,7 +265,7 @@ func (h *AuthHandler) OAuth2Start(c *gin.Context) {
// OAuth2Callback handles the OAuth2 provider's callback after user authorization. // OAuth2Callback handles the OAuth2 provider's callback after user authorization.
func (h *AuthHandler) OAuth2Callback(c *gin.Context) { func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
if !h.authCfg.OAuth2Enabled { if !h.authCfg.OAuth2Enabled {
c.String(http.StatusBadRequest, "OAuth2 未启用") c.String(http.StatusBadRequest, i18n.T(langOf(c), "OAuth2 未启用"))
return return
} }
@@ -274,11 +275,11 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
reqState := c.Query("state") reqState := c.Query("state")
if cookieErr != nil || reqState == "" || if cookieErr != nil || reqState == "" ||
subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 { subtle.ConstantTimeCompare([]byte(cookieState), []byte(reqState)) != 1 {
c.HTML(http.StatusForbidden, "login", func() gin.H { c.HTML(http.StatusForbidden, "login", withLang(c, func() gin.H {
v := h.oauth2LoginVars() v := h.oauth2LoginVars()
v["error"] = "OAuth2 state 校验失败,请重新发起登录" v["error"] = i18n.T(langOf(c), "OAuth2 state 校验失败,请重新发起登录")
return v return v
}()) }()))
return return
} }
// state 一次性使用:无论后续成败都立即失效 // state 一次性使用:无论后续成败都立即失效
@@ -286,12 +287,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
code := c.Query("code") code := c.Query("code")
if code == "" { if code == "" {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "OAuth2 授权码缺失", "error": i18n.T(langOf(c), "OAuth2 授权码缺失"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -299,34 +300,34 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
email, err := provider.HandleCallback(code) email, err := provider.HandleCallback(code)
if err != nil { if err != nil {
log.Printf("OAuth2 回调失败: %v", err) log.Printf("OAuth2 回调失败: %v", err)
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "OAuth2 认证失败,请重试或联系管理员", "error": i18n.T(langOf(c), "OAuth2 认证失败,请重试或联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
// Look up user by email // Look up user by email
user, err := h.stores.Users.GetByEmail(email) user, err := h.stores.Users.GetByEmail(email)
if err != nil { if err != nil {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "OAuth2 账号未接入本系统,请联系管理员", "error": i18n.T(langOf(c), "OAuth2 账号未接入本系统,请联系管理员"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
if !user.IsActive { if !user.IsActive {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "用户已被禁用", "error": i18n.T(langOf(c), "用户已被禁用"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
@@ -339,12 +340,12 @@ func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
session.Set("isAdmin", user.IsAdmin) session.Set("isAdmin", user.IsAdmin)
session.Set("loginAt", time.Now().Unix()) session.Set("loginAt", time.Now().Unix())
if err := session.Save(); err != nil { if err := session.Save(); err != nil {
c.HTML(200, "login", gin.H{ c.HTML(200, "login", withLang(c, gin.H{
"error": "会话保存失败,请重试", "error": i18n.T(langOf(c), "会话保存失败,请重试"),
"oauth2Enabled": h.authCfg.OAuth2Enabled, "oauth2Enabled": h.authCfg.OAuth2Enabled,
"ldapEnabled": h.authCfg.LDAPEnabled, "ldapEnabled": h.authCfg.LDAPEnabled,
"oauth2Provider": h.authCfg.OAuth2Provider, "oauth2Provider": h.authCfg.OAuth2Provider,
}) }))
return return
} }
+39
View File
@@ -0,0 +1,39 @@
package handlers
import (
"mail_go/internal/i18n"
"github.com/gin-gonic/gin"
)
// langOf 返回当前请求的界面语言:优先取中间件解析好的 context 值
// (登录用户偏好 / 浏览器语言),未设置时按浏览器 Accept-Language 兜底。
func langOf(c *gin.Context) string {
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
return s
}
}
return i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
}
// withLang 把当前请求的界面语言注入模板数据(key "lang"),
// 所有 c.HTML 渲染入口都应经过它,保证模板可调用 {{t .Lang "..."}}。
func withLang(c *gin.Context, data gin.H) gin.H {
if data == nil {
data = gin.H{}
}
data["Lang"] = langOf(c)
return data
}
// normLang 把表单提交的语言偏好规整为合法值(auto | en | zh | ja),
// 非法值回退 auto(跟随浏览器)。
func normLang(v string) string {
switch v {
case i18n.LangAuto, i18n.LangEn, i18n.LangZh, i18n.LangJa:
return v
default:
return i18n.LangAuto
}
}
+64 -52
View File
@@ -13,6 +13,7 @@ import (
"time" "time"
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/i18n"
"mail_go/internal/imap_server" "mail_go/internal/imap_server"
"mail_go/internal/outbound" "mail_go/internal/outbound"
"mail_go/internal/storage" "mail_go/internal/storage"
@@ -123,14 +124,14 @@ func (h *MailHandler) Folder(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name")) name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok { if !ok {
c.String(http.StatusNotFound, "邮箱不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
return return
} }
page := getPageParam(c, "page", 1) page := getPageParam(c, "page", 1)
messages, total, err := h.svc.Messages(userID, name, page, 20) messages, total, err := h.svc.Messages(userID, name, page, 20)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "加载邮件列表失败: %v", err))
return return
} }
@@ -143,7 +144,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
} }
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "folder", gin.H{ c.HTML(200, "folder", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"messages": messages, "messages": messages,
"total": total, "total": total,
@@ -154,7 +155,7 @@ func (h *MailHandler) Folder(c *gin.Context) {
"activeFolder": name, "activeFolder": name,
"isTrash": name == "Trash", "isTrash": name == "Trash",
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
}) }))
} }
// View renders the email detail page for a specific message. // View renders the email detail page for a specific message.
@@ -162,19 +163,19 @@ func (h *MailHandler) View(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "邮件不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "邮件不存在"))
return return
} }
// Verify the message belongs to the current user // Verify the message belongs to the current user
if msg.UserID != userID { if msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
@@ -191,14 +192,14 @@ func (h *MailHandler) View(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
c.HTML(200, "view", gin.H{ c.HTML(200, "view", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"message": msg, "message": msg,
"attachments": attachments, "attachments": attachments,
"activeFolder": msg.Folder, "activeFolder": msg.Folder,
"inTrash": msg.Folder == "Trash", "inTrash": msg.Folder == "Trash",
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
}) }))
} }
// Compose renders the email composition page. // Compose renders the email composition page.
@@ -215,7 +216,7 @@ func (h *MailHandler) Compose(c *gin.Context) {
quotaBytes = user.QuotaBytes quotaBytes = user.QuotaBytes
} }
c.HTML(200, "compose", gin.H{ c.HTML(200, "compose", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "compose", "activeFolder": "compose",
"error": "", "error": "",
@@ -225,12 +226,12 @@ func (h *MailHandler) Compose(c *gin.Context) {
"usedBytes": usedBytes, "usedBytes": usedBytes,
"quotaBytes": quotaBytes, "quotaBytes": quotaBytes,
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
}) }))
} }
// composeData builds the shared template context for the compose page. // composeData builds the shared template context for the compose page.
func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H { func (h *MailHandler) composeData(c *gin.Context, userID uint, user *db.User, errMsg, to, subject, cc, body string) gin.H {
return gin.H{ return withLang(c, gin.H{
"currentUser": user, "currentUser": user,
"activeFolder": "compose", "activeFolder": "compose",
"error": errMsg, "error": errMsg,
@@ -241,7 +242,7 @@ func (h *MailHandler) composeData(userID uint, user *db.User, errMsg, to, subjec
"usedBytes": user.UsedBytes, "usedBytes": user.UsedBytes,
"quotaBytes": user.QuotaBytes, "quotaBytes": user.QuotaBytes,
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
} })
} }
// DoSend processes the email composition form, sends the email via SMTP, // DoSend processes the email composition form, sends the email via SMTP,
@@ -258,7 +259,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
cc := c.PostForm("cc") cc := c.PostForm("cc")
if to == "" { if to == "" {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "请输入收件人", to, subject, cc, htmlBody)) c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "请输入收件人"), to, subject, cc, htmlBody))
return return
} }
@@ -276,7 +277,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
} }
reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize) reserved, err := h.stores.Users.TryReserveQuota(userID, totalNewSize)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, "配额检查失败,请稍后重试", to, subject, cc, htmlBody)) c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "配额检查失败,请稍后重试"), to, subject, cc, htmlBody))
return return
} }
if !reserved { if !reserved {
@@ -285,10 +286,10 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if user != nil { if user != nil {
usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes usedBytes, quotaBytes = user.UsedBytes, user.QuotaBytes
} }
c.HTML(http.StatusBadRequest, "compose", gin.H{ c.HTML(http.StatusBadRequest, "compose", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "compose", "activeFolder": "compose",
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)), "error": i18n.TF(langOf(c), "附件超出配额限制。已用 %s / 总配额 %s", formatBytes(usedBytes), formatBytes(quotaBytes)),
"to": to, "to": to,
"subject": subject, "subject": subject,
"cc": cc, "cc": cc,
@@ -296,7 +297,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
"usedBytes": usedBytes, "usedBytes": usedBytes,
"quotaBytes": quotaBytes, "quotaBytes": quotaBytes,
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
}) }))
return return
} }
@@ -355,16 +356,16 @@ func (h *MailHandler) DoSend(c *gin.Context) {
if len(externalRecipients) > 0 { if len(externalRecipients) > 0 {
ob := h.outbound ob := h.outbound
if ob == nil || !ob.Enabled() { if ob == nil || !ob.Enabled() {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, "外部投递未启用", to, subject, cc, htmlBody)) c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.T(langOf(c), "外部投递未启用"), to, subject, cc, htmlBody))
return return
} }
if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt { if maxRcpt := ob.MaxRecipients(); maxRcpt > 0 && len(externalRecipients) > maxRcpt {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody)) c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外部收件人过多:最多 %d 个", maxRcpt), to, subject, cc, htmlBody))
return return
} }
for _, rcpt := range externalRecipients { for _, rcpt := range externalRecipients {
if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil { if _, err := ob.Enqueue(currentUser, fromAddr, rcpt, []byte(rawMessage)); err != nil {
c.HTML(http.StatusBadRequest, "compose", h.composeData(userID, currentUser, fmt.Sprintf("外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody)) c.HTML(http.StatusBadRequest, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "外发邮件入队失败 (%s): %v", rcpt, err), to, subject, cc, htmlBody))
return return
} }
} }
@@ -386,7 +387,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
IsRead: false, IsRead: false,
} }
if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil { if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("投递邮件失败: %v", createErr), to, subject, cc, htmlBody)) c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "投递邮件失败: %v", createErr), to, subject, cc, htmlBody))
return return
} }
// 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知) // 本地投递成功 → IMAP 新邮件推送(IDLE 客户端实时收到通知)
@@ -412,7 +413,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
} }
if createErr := h.stores.Mails.Create(msg); createErr != nil { if createErr := h.stores.Mails.Create(msg); createErr != nil {
c.HTML(http.StatusInternalServerError, "compose", h.composeData(userID, currentUser, fmt.Sprintf("保存邮件失败: %v", createErr), to, subject, cc, htmlBody)) c.HTML(http.StatusInternalServerError, "compose", h.composeData(c, userID, currentUser, i18n.TF(langOf(c), "保存邮件失败: %v", createErr), to, subject, cc, htmlBody))
return return
} }
@@ -579,13 +580,13 @@ func (h *MailHandler) Delete(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID { if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
userEmail := userEmailOf(c) userEmail := userEmailOf(c)
@@ -602,7 +603,7 @@ func (h *MailHandler) Delete(c *gin.Context) {
seq := h.seqOfFolder(userID, msg.Folder, msg.ID) seq := h.seqOfFolder(userID, msg.Folder, msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil { if err := h.svc.Move(userID, []uint{msg.ID}, "Trash"); err != nil {
log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err) log.Printf("web: 移入垃圾箱失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "删除失败") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "删除失败"))
return return
} }
if h.pusher != nil && userEmail != "" { if h.pusher != nil && userEmail != "" {
@@ -624,13 +625,13 @@ func (h *MailHandler) Restore(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID { if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
if msg.Folder != "Trash" { if msg.Folder != "Trash" {
@@ -641,7 +642,7 @@ func (h *MailHandler) Restore(c *gin.Context) {
seq := h.seqOfFolder(userID, "Trash", msg.ID) seq := h.seqOfFolder(userID, "Trash", msg.ID)
if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil { if err := h.svc.Move(userID, []uint{msg.ID}, "INBOX"); err != nil {
log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err) log.Printf("web: 恢复邮件失败 msg=%d: %v", id, err)
c.String(http.StatusInternalServerError, "恢复失败") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "恢复失败"))
return return
} }
if h.pusher != nil { if h.pusher != nil {
@@ -658,13 +659,13 @@ func (h *MailHandler) Purge(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID { if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
@@ -688,13 +689,13 @@ func (h *MailHandler) EmptyFolder(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
name, ok := h.svc.Canonical(userID, c.Param("name")) name, ok := h.svc.Canonical(userID, c.Param("name"))
if !ok { if !ok {
c.String(http.StatusNotFound, "邮箱不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "邮箱不存在"))
return return
} }
msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name) msgs, err := h.stores.Mails.ListAllByUserAndFolder(userID, name)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "清空文件夹失败: %v", err) c.String(http.StatusInternalServerError, i18n.TF(langOf(c), "清空文件夹失败: %v", err))
return return
} }
seqs := make([]uint32, 0, len(msgs)) seqs := make([]uint32, 0, len(msgs))
@@ -715,13 +716,13 @@ func (h *MailHandler) MarkRead(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的邮件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的邮件ID"))
return return
} }
msg, err := h.stores.Mails.GetByID(uint(id)) msg, err := h.stores.Mails.GetByID(uint(id))
if err != nil || msg.UserID != userID { if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
@@ -752,26 +753,26 @@ func (h *MailHandler) DownloadAttachment(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
id, err := strconv.ParseUint(c.Param("id"), 10, 64) id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil { if err != nil {
c.String(http.StatusBadRequest, "无效的附件ID") c.String(http.StatusBadRequest, i18n.T(langOf(c), "无效的附件ID"))
return return
} }
att, err := h.stores.Attachments.GetByID(uint(id)) att, err := h.stores.Attachments.GetByID(uint(id))
if err != nil { if err != nil {
c.String(http.StatusNotFound, "附件不存在") c.String(http.StatusNotFound, i18n.T(langOf(c), "附件不存在"))
return return
} }
// Verify the message belongs to the current user // Verify the message belongs to the current user
msg, err := h.stores.Mails.GetByID(att.MessageID) msg, err := h.stores.Mails.GetByID(att.MessageID)
if err != nil || msg.UserID != userID { if err != nil || msg.UserID != userID {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(langOf(c), "禁止访问"))
return return
} }
data, err := h.storage.Read(att.FilePath) data, err := h.storage.Read(att.FilePath)
if err != nil { if err != nil {
c.String(http.StatusInternalServerError, "读取附件失败") c.String(http.StatusInternalServerError, i18n.T(langOf(c), "读取附件失败"))
return return
} }
@@ -797,65 +798,76 @@ func getPageParam(c *gin.Context, key string, defaultVal int) int {
func (h *MailHandler) Settings(c *gin.Context) { func (h *MailHandler) Settings(c *gin.Context) {
currentUser, _ := c.Get("currentUser") currentUser, _ := c.Get("currentUser")
userID := c.GetUint("userID") userID := c.GetUint("userID")
c.HTML(200, "settings", gin.H{ c.HTML(200, "settings", withLang(c, gin.H{
"currentUser": currentUser, "currentUser": currentUser,
"activeFolder": "settings", "activeFolder": "settings",
"error": "", "error": "",
"success": "", "success": "",
"mustChange": c.Query("force") == "1", "mustChange": c.Query("force") == "1",
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
}) }))
} }
// settingsData builds the shared template context for the settings page. // settingsData builds the shared template context for the settings page.
func (h *MailHandler) settingsData(userID uint, user *db.User, errMsg, success string) gin.H { func (h *MailHandler) settingsData(c *gin.Context, userID uint, user *db.User, errMsg, success string) gin.H {
return gin.H{ return withLang(c, gin.H{
"currentUser": user, "currentUser": user,
"activeFolder": "settings", "activeFolder": "settings",
"error": errMsg, "error": errMsg,
"success": success, "success": success,
"folders": h.foldersFor(userID), "folders": h.foldersFor(userID),
} })
} }
// UpdateSettings handles the password change form. // UpdateSettings 处理设置页表单:密码修改(old_password + new_password +
// confirm_password)与界面语言偏好(仅 language 字段)二选一提交。
func (h *MailHandler) UpdateSettings(c *gin.Context) { func (h *MailHandler) UpdateSettings(c *gin.Context) {
userID := c.GetUint("userID") userID := c.GetUint("userID")
currentUserVal, _ := c.Get("currentUser") currentUserVal, _ := c.Get("currentUser")
currentUser := currentUserVal.(*db.User) currentUser := currentUserVal.(*db.User)
// 仅提交 language 字段 → 语言偏好更新(设置页独立表单)
if lang := c.PostForm("language"); lang != "" && c.PostForm("old_password") == "" {
if err := h.stores.Users.UpdateLanguage(userID, lang); err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "语言偏好保存失败"), ""))
return
}
c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "语言偏好已保存")))
return
}
oldPassword := c.PostForm("old_password") oldPassword := c.PostForm("old_password")
newPassword := c.PostForm("new_password") newPassword := c.PostForm("new_password")
confirmPassword := c.PostForm("confirm_password") confirmPassword := c.PostForm("confirm_password")
// Verify old password // Verify old password
if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(currentUser.PasswordHash), []byte(oldPassword)); err != nil {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "当前密码不正确", "")) c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "当前密码不正确"), ""))
return return
} }
if newPassword == "" { if newPassword == "" {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "新密码不能为空", "")) c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "新密码不能为空"), ""))
return return
} }
if newPassword != confirmPassword { if newPassword != confirmPassword {
c.HTML(http.StatusBadRequest, "settings", h.settingsData(userID, currentUser, "两次输入的密码不一致", "")) c.HTML(http.StatusBadRequest, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "两次输入的密码不一致"), ""))
return return
} }
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil { if err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码加密失败", "")) c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码加密失败"), ""))
return return
} }
if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil { if err := h.stores.Users.UpdatePassword(userID, string(hashedPassword)); err != nil {
c.HTML(http.StatusInternalServerError, "settings", h.settingsData(userID, currentUser, "密码更新失败", "")) c.HTML(http.StatusInternalServerError, "settings", h.settingsData(c, userID, currentUser, i18n.T(langOf(c), "密码更新失败"), ""))
return return
} }
c.HTML(http.StatusOK, "settings", h.settingsData(userID, currentUser, "", "密码修改成功")) c.HTML(http.StatusOK, "settings", h.settingsData(c, userID, currentUser, "", i18n.T(langOf(c), "密码修改成功")))
} }
// formatBytes converts a file size in bytes to a human-readable string. // formatBytes converts a file size in bytes to a human-readable string.
+15 -5
View File
@@ -5,6 +5,7 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"fmt"
"html/template" "html/template"
"math" "math"
"net/http" "net/http"
@@ -31,7 +32,13 @@ func testTemplateFuncs() template.FuncMap {
"div": func(a, b int) int { return a / b }, "div": func(a, b int) int { return a / b },
"mod": func(a, b int) int { return a % b }, "mod": func(a, b int) int { return a % b },
"ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) }, "ceilDiv": func(a, b int) int { return int(math.Ceil(float64(a) / float64(b))) },
"seq": func(n int) []int { r := make([]int, n); for i := range r { r[i] = i + 1 }; return r }, "seq": func(n int) []int {
r := make([]int, n)
for i := range r {
r[i] = i + 1
}
return r
},
"domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" }, "domainName": func(domainID uint, domains []interface{}) string { return "Domain #1" },
"jsonify": func(v interface{}) template.JS { "jsonify": func(v interface{}) template.JS {
b, _ := json.Marshal(v) b, _ := json.Marshal(v)
@@ -45,13 +52,16 @@ func testTemplateFuncs() template.FuncMap {
"mailEmail": func(s string) string { return s }, "mailEmail": func(s string) string { return s },
"initial": func(s string) string { return "?" }, "initial": func(s string) string { return "?" },
"truncate": func(s string, n int) string { return s }, "truncate": func(s string, n int) string { return s },
"shortDate": func(t time.Time) string { return t.Format("2006-01-02") }, "t": func(lang, key string) string { return key },
"tf": func(lang, key string, args ...interface{}) string { return fmt.Sprintf(key, args...) },
"htmlLang": func(lang string) string { return "zh-CN" },
"shortDate": func(lang string, t time.Time) string { return t.Format("2006-01-02") },
"localTime": func(t time.Time) time.Time { return t }, "localTime": func(t time.Time) time.Time { return t },
"time12": func(t time.Time) string { return t.Format("2006-01-02 15:04:05") }, "time12": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04:05") },
"time12m": func(t time.Time) string { return t.Format("2006-01-02 15:04") }, "time12m": func(lang string, t time.Time) string { return t.Format("2006-01-02 15:04") },
"avatarStyle": func(s string) string { return "background:#eee;color:#333" }, "avatarStyle": func(s string) string { return "background:#eee;color:#333" },
"urlPath": func(s string) string { return url.PathEscape(s) }, "urlPath": func(s string) string { return url.PathEscape(s) },
"folderLabel": func(s string) string { return s }, "folderLabel": func(lang, s string) string { return s },
} }
} }
+9 -2
View File
@@ -4,6 +4,7 @@ import (
"net/http" "net/http"
"mail_go/internal/db" "mail_go/internal/db"
"mail_go/internal/i18n"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
@@ -12,16 +13,22 @@ import (
// Must be used after AuthMiddleware so that "currentUser" is available. // Must be used after AuthMiddleware so that "currentUser" is available.
func AdminMiddleware() gin.HandlerFunc { func AdminMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
if v, ok := c.Get("lang"); ok {
if s, ok := v.(string); ok && i18n.Supported(s) {
lang = s
}
}
userVal, exists := c.Get("currentUser") userVal, exists := c.Get("currentUser")
if !exists { if !exists {
c.String(http.StatusForbidden, "禁止访问") c.String(http.StatusForbidden, i18n.T(lang, "禁止访问"))
c.Abort() c.Abort()
return return
} }
user, ok := userVal.(*db.User) user, ok := userVal.(*db.User)
if !ok || !user.IsAdmin { if !ok || !user.IsAdmin {
c.String(http.StatusForbidden, "禁止访问:需要管理员权限") c.String(http.StatusForbidden, i18n.T(lang, "禁止访问:需要管理员权限"))
c.Abort() c.Abort()
return return
} }
+5
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"time" "time"
"mail_go/internal/i18n"
"mail_go/internal/store" "mail_go/internal/store"
"github.com/gin-contrib/sessions" "github.com/gin-contrib/sessions"
@@ -100,6 +101,10 @@ func AuthMiddleware(stores *store.Stores) gin.HandlerFunc {
return return
} }
// 解析界面语言:用户偏好 auto 时按浏览器 Accept-Language 选择,
// 最终兜底英语。模板数据与错误提示统一使用该值。
c.Set("lang", i18n.Resolve(user.Language, c.GetHeader("Accept-Language")))
c.Set("currentUser", user) c.Set("currentUser", user)
c.Set("userID", id) c.Set("userID", id)
c.Next() c.Next()
+5
View File
@@ -3,6 +3,7 @@ package middleware
import ( import (
"net/http" "net/http"
"mail_go/internal/i18n"
"mail_go/internal/store" "mail_go/internal/store"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -15,8 +16,12 @@ func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
ip := c.ClientIP() ip := c.ClientIP()
banned, entry := stores.Bans.IsBanned(ip) banned, entry := stores.Bans.IsBanned(ip)
if banned { if banned {
// 封禁页无登录用户,按浏览器语言渲染
lang := i18n.FromAcceptLanguage(c.GetHeader("Accept-Language"))
c.Set("lang", lang)
c.HTML(http.StatusForbidden, "banned", gin.H{ c.HTML(http.StatusForbidden, "banned", gin.H{
"entry": entry, "entry": entry,
"Lang": lang,
}) })
c.Abort() c.Abort()
return return
+11 -11
View File
@@ -59,24 +59,24 @@ func TestRenderAllPages(t *testing.T) {
name string name string
data ginH data ginH
}{ }{
{"login", ginH{"error": ""}}, {"login", ginH{"Lang": "zh", "error": ""}},
{"banned", ginH{"entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}}, {"banned", ginH{"Lang": "zh", "entry": &db.BanEntry{IPAddress: "1.2.3.4", Reason: "登录失败次数过多", FailCount: 8, ExpiresAt: now.Add(20 * time.Minute)}}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}}, {"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 5, "page": 1, "totalPages": 1, "folder": "INBOX", "activeFolder": "INBOX", "isTrash": false, "folders": folders}},
{"folder", ginH{"currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}}, {"folder", ginH{"Lang": "zh", "currentUser": user, "messages": messages, "total": 2, "page": 1, "totalPages": 1, "folder": "Trash", "activeFolder": "Trash", "isTrash": true, "folders": folders}},
{"view", ginH{ {"view", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "INBOX", "currentUser": user, "activeFolder": "INBOX",
"message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false}, "message": &db.Message{ID: 1, Folder: "INBOX", FromAddr: "=?UTF-8?B?5byg5LiJ?= <zhangsan@lmve.net>", ToAddr: "admin@lmve.net", Subject: "邮件系统部署完成通知", TextBody: "您好!您的 MailGo 邮件系统已成功部署。", HtmlBody: "", Date: now, IsRead: false},
"attachments": attachments, "inTrash": false, "folders": folders, "attachments": attachments, "inTrash": false, "folders": folders,
}}, }},
{"compose", ginH{ {"compose", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "compose", "error": "", "currentUser": user, "activeFolder": "compose", "error": "",
"to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "", "to": "zhangsan@lmve.net", "subject": "Re: 邮件系统部署完成通知", "bodyContent": "",
"usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024), "usedBytes": int64(5 * 1024 * 1024), "quotaBytes": int64(5 * 1024 * 1024 * 1024),
"folders": folders, "folders": folders,
}}, }},
{"settings", ginH{"currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}}, {"settings", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "settings", "error": "", "success": "", "folders": folders}},
{"admin_dashboard", ginH{"currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}}, {"admin_dashboard", ginH{"Lang": "zh", "currentUser": user, "activeFolder": "admin", "domainCount": 2, "userCount": 5, "totalMails": 100, "banCount": 1, "inboxCount": 50, "sentCount": 30, "draftsCount": 10, "trashCount": 5, "inboxSize": int64(1024), "sentSize": int64(512), "totalSize": int64(2048), "todayReceived": 3, "todaySent": 2, "weekReceived": 20, "weekSent": 15}},
{"admin_bans", ginH{ {"admin_bans", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "bans", "currentUser": user, "activeFolder": "bans",
"rows": []struct { "rows": []struct {
db.BanEntry db.BanEntry
@@ -88,7 +88,7 @@ func TestRenderAllPages(t *testing.T) {
}, },
"total": 3, "page": 1, "pageSize": 20, "totalPages": 1, "total": 3, "page": 1, "pageSize": 20, "totalPages": 1,
}}, }},
{"admin_protocol_logs", ginH{ {"admin_protocol_logs", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "protocol-logs", "currentUser": user, "activeFolder": "protocol-logs",
"logs": []db.ProtocolLog{ "logs": []db.ProtocolLog{
{ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM:<spam@evil.example> RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now}, {ID: 1, Protocol: db.ProtocolSMTP, Port: 25, ClientIP: "203.0.113.7", Username: "", Success: true, FailReason: "", Detail: "MAIL FROM:<spam@evil.example> RCPT×1 本地投递1", MsgCount: 1, DurationMs: 1234, CreatedAt: now},
@@ -109,7 +109,7 @@ func TestRenderAllPages(t *testing.T) {
}, },
"keepDays": 30, "keepDays": 30,
}}, }},
{"admin_connections", ginH{ {"admin_connections", ginH{"Lang": "zh",
"currentUser": user, "activeFolder": "connections", "currentUser": user, "activeFolder": "connections",
"conns": []struct { "conns": []struct {
ID uint64 ID uint64
+43 -21
View File
@@ -18,6 +18,7 @@ import (
"mail_go/config" "mail_go/config"
"mail_go/internal/connhub" "mail_go/internal/connhub"
"mail_go/internal/i18n"
"mail_go/internal/imap_server" "mail_go/internal/imap_server"
"mail_go/internal/mailutil" "mail_go/internal/mailutil"
"mail_go/internal/outbound" "mail_go/internal/outbound"
@@ -84,6 +85,21 @@ func templateFuncs() template.FuncMap {
"domainName": func(domainID uint, domains []interface{}) string { "domainName": func(domainID uint, domains []interface{}) string {
return fmt.Sprintf("Domain #%d", domainID) return fmt.Sprintf("Domain #%d", domainID)
}, },
// t / tf 多语言翻译(见 internal/i18n):t 返回译文,
// tf 对译文做 fmt.Sprintf(带占位符的消息)。
"t": i18n.T,
"tf": i18n.TF,
// htmlLang 返回 <html lang="..."> 属性值(zh → zh-CN)。
"htmlLang": func(lang string) string {
switch i18n.Normalize(lang) {
case i18n.LangZh:
return "zh-CN"
case i18n.LangJa:
return "ja"
default:
return "en"
}
},
// jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串), // jsonify 把任意值序列化为安全的 JS 字面量(JSON 字符串),
// 用于在 <script> 上下文中注入数据。encoding/json 默认转义 // 用于在 <script> 上下文中注入数据。encoding/json 默认转义
// < > &\u003c 等),无法逃出 </script>,杜绝 script 注入。 // < > &\u003c 等),无法逃出 </script>,杜绝 script 注入。
@@ -108,12 +124,11 @@ func templateFuncs() template.FuncMap {
"initial": initial, "initial": initial,
// truncate 折叠空白并截断到 n 个字符(用于列表摘要)。 // truncate 折叠空白并截断到 n 个字符(用于列表摘要)。
"truncate": truncate, "truncate": truncate,
// shortDate 邮件列表时间:统一 12 小时制(上午/下午) // shortDate 邮件列表时间:按界面语言输出 12 小时制
// 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」, // 今天显示「下午 2:35」,今年显示「08-20 下午 2:35」,
// 更早显示「2026-08-20 下午 2:35」。 // 更早显示「2026-08-20 下午 2:35」。
"shortDate": shortDate, "shortDate": shortDate,
// time12 完整时间(含秒),先转换为 Web 时区 // time12 完整时间(含秒),先转换为 Web 时区
// 「2026-08-20 下午 2:35:05」。
"time12": time12, "time12": time12,
// time12m 同上但不含秒。 // time12m 同上但不含秒。
"time12m": time12m, "time12m": time12m,
@@ -123,22 +138,28 @@ func templateFuncs() template.FuncMap {
"avatarStyle": avatarStyle, "avatarStyle": avatarStyle,
// urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。 // urlPath 转义文件夹名用于 URL 路径(自定义文件夹可能含中文/空格)。
"urlPath": url.PathEscape, "urlPath": url.PathEscape,
// folderLabel 返回文件夹的界面显示名(系统文件夹中文名,自定义原名)。 // folderLabel 返回文件夹的界面显示名(系统文件夹按语言翻译,自定义原名)。
"folderLabel": func(name string) string { "folderLabel": func(lang, name string) string {
return folderLabel(lang, name)
},
}
}
// folderLabel 返回文件夹的界面显示名:系统文件夹按界面语言翻译,
// 自定义文件夹原样返回。
func folderLabel(lang, name string) string {
switch name { switch name {
case "INBOX": case "INBOX":
return "收件箱" return i18n.T(lang, "收件箱")
case "Sent": case "Sent":
return "已发送" return i18n.T(lang, "已发送")
case "Drafts": case "Drafts":
return "草稿箱" return i18n.T(lang, "草稿箱")
case "Trash": case "Trash":
return "已删除" return i18n.T(lang, "已删除")
default: default:
return name return name
} }
},
}
} }
// mailName extracts the display name from an RFC 5322 address. // mailName extracts the display name from an RFC 5322 address.
@@ -186,11 +207,12 @@ func truncate(s string, n int) string {
return string(r[:n]) + "…" return string(r[:n]) + "…"
} }
// periodOf 返回 12 小时制的时间段与小时:上午/下午 + 1-12。 // periodOf 返回 12 小时制的时间段与小时:按界面语言输出
func periodOf(t time.Time) (string, int) { // 上午/下午(zh)、AM/PM(en)、午前/午後(ja),小时 1-12。
period := "上午" func periodOf(lang string, t time.Time) (string, int) {
period := i18n.T(lang, "上午")
if t.Hour() >= 12 { if t.Hour() >= 12 {
period = "下午" period = i18n.T(lang, "下午")
} }
h := t.Hour() % 12 h := t.Hour() % 12
if h == 0 { if h == 0 {
@@ -202,10 +224,10 @@ func periodOf(t time.Time) (string, int) {
// shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换): // shortDate 邮件列表时间(12 小时制,先按 Web 配置时区转换):
// 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」; // 今天 → 「下午 2:35」;今年 → 「08-20 下午 2:35」;
// 更早 → 「2026-08-20 下午 2:35」。 // 更早 → 「2026-08-20 下午 2:35」。
func shortDate(t time.Time) string { func shortDate(lang string, t time.Time) string {
t = inWebTZ(t) t = inWebTZ(t)
now := time.Now().In(t.Location()) now := time.Now().In(t.Location())
period, h := periodOf(t) period, h := periodOf(lang, t)
clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute()) clock := fmt.Sprintf("%s %d:%02d", period, h, t.Minute())
if t.Year() == now.Year() && t.YearDay() == now.YearDay() { if t.Year() == now.Year() && t.YearDay() == now.YearDay() {
return clock return clock
@@ -218,16 +240,16 @@ func shortDate(t time.Time) string {
// time12 完整时间(12 小时制,先按 Web 配置时区转换): // time12 完整时间(12 小时制,先按 Web 配置时区转换):
// 「2026-08-20 下午 2:35:05」。 // 「2026-08-20 下午 2:35:05」。
func time12(t time.Time) string { func time12(lang string, t time.Time) string {
t = inWebTZ(t) t = inWebTZ(t)
period, h := periodOf(t) period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second()) return fmt.Sprintf("%s %s %d:%02d:%02d", t.Format("2006-01-02"), period, h, t.Minute(), t.Second())
} }
// time12m 完整时间(12 小时制,不含秒)。 // time12m 完整时间(12 小时制,不含秒)。
func time12m(t time.Time) string { func time12m(lang string, t time.Time) string {
t = inWebTZ(t) t = inWebTZ(t)
period, h := periodOf(t) period, h := periodOf(lang, t)
return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute()) return fmt.Sprintf("%s %s %d:%02d", t.Format("2006-01-02"), period, h, t.Minute())
} }
+31 -31
View File
@@ -1,10 +1,10 @@
{{define "admin_bans"}} {{define "admin_bans"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>IP黑名单 - MailGo</title> <title>{{t .Lang "IP黑名单"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,32 +12,32 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>IP 黑名单</h2> <h2>{{t .Lang "IP 黑名单"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)</span> <span style="color:#7f8c8d;font-size:13px;">{{t .Lang "阶段性封禁:第 4 次触发起封禁,30分钟 → 3小时 → 3个月 → 半年(上限)"}}</span>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>IP 地址</th> <th>{{t .Lang "IP 地址"}}</th>
<th>状态</th> <th>{{t .Lang "状态"}}</th>
<th>封禁次数</th> <th>{{t .Lang "封禁次数"}}</th>
<th>失败次数</th> <th>{{t .Lang "失败次数"}}</th>
<th>原因</th> <th>{{t .Lang "原因"}}</th>
<th>到期时间</th> <th>{{t .Lang "到期时间"}}</th>
<th>操作</th> <th>{{t .Lang "操作"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,21 +45,21 @@
<tr> <tr>
<td>{{.IPAddress}}</td> <td>{{.IPAddress}}</td>
<td> <td>
{{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">封禁中</span> {{if .Active}}<span class="badge" style="background:#e74c3c;color:#fff;">{{t $.Lang "封禁中"}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">已过期</span>{{end}} {{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "已过期"}}</span>{{end}}
</td> </td>
<td> <td>
{{if .BanCount}} {{if .BanCount}}
{{if gt .BanCount 3}}{{sub .BanCount 3}}次封禁{{else}}仅计数{{end}} {{if gt .BanCount 3}}{{tf $.Lang "第 %d 次封禁" (sub .BanCount 3)}}{{else}}{{t $.Lang "仅计数"}}{{end}}
{{else}}—{{end}} {{else}}—{{end}}
</td> </td>
<td>{{.FailCount}}</td> <td>{{.FailCount}}</td>
<td>{{if .Reason}}{{.Reason}}{{else}}—{{end}}</td> <td>{{if .Reason}}{{t $.Lang .Reason}}{{else}}—{{end}}</td>
<td>{{time12 .ExpiresAt}}{{if not .Active}}(已过期){{end}}</td> <td>{{time12 $.Lang .ExpiresAt}}{{if not .Active}}{{t $.Lang "(已过期)"}}{{end}}</td>
<td> <td>
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;" <form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?解封后该 IP 的封禁档位将清零。');"> onsubmit="return confirm('{{tf $.Lang "确定要解封 IP %s 解封后该 IP 的封禁档位将清零" .IPAddress}}');">
<button type="submit" class="btn btn-primary btn-sm">解封</button> <button type="submit" class="btn btn-primary btn-sm">{{t $.Lang "解封"}}</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -67,17 +67,17 @@
</tbody> </tbody>
</table> </table>
{{if not .rows}} {{if not .rows}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无封禁记录</p> <p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无封禁记录"}}</p>
{{end}} {{end}}
</div> </div>
{{if .totalPages}} {{if .totalPages}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}} {{if gt .page 1}}
<a href="/admin/bans?page={{sub .page 1}}">上一页</a> <a href="/admin/bans?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}} {{end}}
<span>第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span> <span>{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a href="/admin/bans?page={{add .page 1}}">下一页</a> <a href="/admin/bans?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+28 -28
View File
@@ -1,10 +1,10 @@
{{define "admin_connections"}} {{define "admin_connections"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>当前连接 - MailGo</title> <title>{{t .Lang "当前连接"}} - MailGo</title>
<meta http-equiv="refresh" content="5"> <meta http-equiv="refresh" content="5">
{{template "styles" .}} {{template "styles" .}}
</head> </head>
@@ -13,26 +13,26 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>当前连接(SMTP / IMAP / POP3</h2> <h2>{{t .Lang "当前连接(SMTP / IMAP / POP3"}}</h2>
<span style="color:#7f8c8d;font-size:13px;">每 5 秒自动刷新</span> <span style="color:#7f8c8d;font-size:13px;">{{t .Lang "每 5 秒自动刷新"}}</span>
</div> </div>
<div style="margin-bottom:24px;"> <div style="margin-bottom:24px;">
<div class="stat-card"> <div class="stat-card">
<h3>{{.total}}</h3> <h3>{{.total}}</h3>
<p>当前连接总数</p> <p>{{t .Lang "当前连接总数"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{.smtpCount}}</h3> <h3>{{.smtpCount}}</h3>
@@ -53,15 +53,15 @@
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>协议</th> <th>{{t .Lang "协议"}}</th>
<th>来源 IP</th> <th>{{t .Lang "来源 IP"}}</th>
<th>端口</th> <th>{{t .Lang "端口"}}</th>
<th>用户名</th> <th>{{t .Lang "用户名"}}</th>
<th>TLS</th> <th>TLS</th>
<th>连接时间</th> <th>{{t .Lang "连接时间"}}</th>
<th>时长</th> <th>{{t .Lang "时长"}}</th>
<th>最后活跃</th> <th>{{t .Lang "最后活跃"}}</th>
<th>操作</th> <th>{{t .Lang "操作"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -76,19 +76,19 @@
<td>{{.IP}}</td> <td>{{.IP}}</td>
<td>{{.Port}}</td> <td>{{.Port}}</td>
<td>{{if .User}}{{.User}}{{else}}—{{end}}</td> <td>{{if .User}}{{.User}}{{else}}—{{end}}</td>
<td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">明文</span>{{end}}</td> <td>{{if .TLS}}<span class="badge" style="background:#27ae60;color:#fff;">TLS</span>{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang "明文"}}</span>{{end}}</td>
<td>{{time12 .Connected}}</td> <td>{{time12 $.Lang .Connected}}</td>
<td>{{durationSeconds ($.now.Sub .Connected)}}s</td> <td>{{durationSeconds ($.now.Sub .Connected)}}s</td>
<td>{{time12 .LastActive}}</td> <td>{{time12 $.Lang .LastActive}}</td>
<td> <td>
<form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;" <form method="POST" action="/admin/connections/{{.ID}}/disconnect" style="display:inline;"
onsubmit="return confirm('确定要断开 IP {{.IP}} 的所有连接并加入黑名单(180 天)吗?');"> onsubmit="return confirm('{{tf $.Lang "确定要断开 IP %s 的所有连接并加入黑名单180 " .IP}}');">
<button type="submit" class="btn btn-sm btn-danger">断开并封禁</button> <button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "断开并封禁"}}</button>
</form> </form>
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">当前没有活动连接</td></tr> <tr><td colspan="10" style="text-align:center;color:#7f8c8d;">{{t $.Lang "当前没有活动连接"}}</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
+34 -34
View File
@@ -1,10 +1,10 @@
{{define "admin_dashboard"}} {{define "admin_dashboard"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>管理后台 - MailGo</title> <title>{{t .Lang "管理后台"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,74 +12,74 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<h2 style="margin-bottom:24px;">管理后台</h2> <h2 style="margin-bottom:24px;">{{t .Lang "管理后台"}}</h2>
<div style="margin-bottom:24px;"> <div style="margin-bottom:24px;">
<div class="stat-card"> <div class="stat-card">
<h3>{{.domainCount}}</h3> <h3>{{.domainCount}}</h3>
<p>域名数</p> <p>{{t .Lang "域名数"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{.userCount}}</h3> <h3>{{.userCount}}</h3>
<p>用户数</p> <p>{{t .Lang "用户数"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{.totalMails}}</h3> <h3>{{.totalMails}}</h3>
<p>邮件总数</p> <p>{{t .Lang "邮件总数"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{.banCount}}</h3> <h3>{{.banCount}}</h3>
<p>被封IP</p> <p>{{t .Lang "被封IP"}}</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h3>邮件分布</h3> <h3>{{t .Lang "邮件分布"}}</h3>
<table style="margin-top:12px;"> <table style="margin-top:12px;">
<thead> <thead>
<tr> <tr>
<th>文件夹</th> <th>{{t .Lang "文件夹"}}</th>
<th>邮件数</th> <th>{{t .Lang "邮件数"}}</th>
<th>占用空间</th> <th>{{t .Lang "占用空间"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr> <tr><td>{{folderLabel .Lang "INBOX"}} (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr> <tr><td>{{folderLabel .Lang "Sent"}} (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr> <tr><td>{{folderLabel .Lang "Drafts"}} (Drafts)</td><td>{{.draftsCount}}</td><td></td></tr>
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td></td></tr> <tr><td>{{folderLabel .Lang "Trash"}} (Trash)</td><td>{{.trashCount}}</td><td></td></tr>
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr> <tr style="font-weight:bold;"><td>{{t .Lang "合计"}}</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h3>收发统计</h3> <h3>{{t .Lang "收发统计"}}</h3>
<table style="margin-top:12px;"> <table style="margin-top:12px;">
<thead> <thead>
<tr><th>时间段</th><th>收件</th><th>发件</th></tr> <tr><th>{{t .Lang "时间段"}}</th><th>{{t .Lang "收件"}}</th><th>{{t .Lang "发件"}}</th></tr>
</thead> </thead>
<tbody> <tbody>
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr> <tr><td>{{t .Lang "今日"}}</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr> <tr><td>{{t .Lang "近 7 天"}}</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="card"> <div class="card">
<h3>快捷操作</h3> <h3>{{t .Lang "快捷操作"}}</h3>
<p style="margin-top:12px;"> <p style="margin-top:12px;">
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a> <a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
<a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">新增用户</a> <a href="/admin/users/new" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "新增用户"}}</a>
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">查看所有邮件</a> <a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "查看所有邮件"}}</a>
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">IP黑名单</a> <a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">{{t .Lang "IP黑名单"}}</a>
</p> </p>
</div> </div>
</div> </div>
+19 -19
View File
@@ -1,10 +1,10 @@
{{define "admin_dns_hint"}} {{define "admin_dns_hint"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>DNS配置 - MailGo</title> <title>{{t .Lang "DNS配置"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,39 +12,39 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<h2 style="margin-bottom:16px;">DNS 配置提示 — {{.domain.Name}}</h2> <h2 style="margin-bottom:16px;">{{tf .Lang "DNS 配置提示 — %s" .domain.Name}}</h2>
<p style="margin-bottom:16px;color:#7f8c8d;">请在您的 DNS 服务商处添加以下记录:</p> <p style="margin-bottom:16px;color:#7f8c8d;">{{t .Lang "请在您的 DNS 服务商处添加以下记录:"}}</p>
<h4 style="margin-bottom:8px;">MX 记录</h4> <h4 style="margin-bottom:8px;">{{t .Lang "MX 记录"}}</h4>
<div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div> <div class="dns-record">@ IN MX 10 mail.{{.domain.Name}}.</div>
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4> <h4 style="margin-bottom:8px;">{{t .Lang "SPF 记录 (TXT)"}}</h4>
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div> <div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4> <h4 style="margin-bottom:8px;">{{t .Lang "DKIM 记录 (TXT)"}}</h4>
{{if .domain.DkimPublicKey}} {{if .domain.DkimPublicKey}}
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div> <div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
{{else}} {{else}}
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div> <div class="dns-record" style="color:#e67e22;">{{t .Lang "⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。"}}</div>
{{end}} {{end}}
<h4 style="margin-bottom:8px;">DMARC 记录</h4> <h4 style="margin-bottom:8px;">{{t .Lang "DMARC 记录"}}</h4>
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div> <div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
<div style="margin-top:24px;"> <div style="margin-top:24px;">
<a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">返回域名列表</a> <a href="/admin/domains" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回域名列表"}}</a>
</div> </div>
</div> </div>
</div> </div>
+34 -34
View File
@@ -1,10 +1,10 @@
{{define "admin_domain_form"}} {{define "admin_domain_form"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑域名{{else}}新增域名{{end}} - MailGo</title> <title>{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,24 +12,24 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑域名{{else}}新增域名{{end}}</h2> <h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑域名"}}{{else}}{{t .Lang "新增域名"}}{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .caddyMsg}}<div class="alert {{if eq .caddyMsgType "success"}}alert-success{{else}}alert-error{{end}}">{{.caddyMsg}}</div>{{end}} {{if .caddyMsg}}<div class="alert {{if eq .caddyMsgType "success"}}alert-success{{else}}alert-error{{end}}">{{.caddyMsg}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}"> <form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
<div class="form-group"> <div class="form-group">
<label>域名</label> <label>{{t .Lang "域名"}}</label>
{{if .isEdit}} {{if .isEdit}}
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;"> <input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
{{else}} {{else}}
@@ -37,45 +37,45 @@
{{end}} {{end}}
</div> </div>
<div class="form-group"> <div class="form-group">
<label>SMTP 端口</label> <label>{{t .Lang "SMTP 端口"}}</label>
<input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25"> <input type="number" name="smtp_port" value="{{.domain.SmtpPort}}" placeholder="25">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>IMAP 端口</label> <label>{{t .Lang "IMAP 端口"}}</label>
<input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143"> <input type="number" name="imap_port" value="{{.domain.ImapPort}}" placeholder="143">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>POP3 端口</label> <label>{{t .Lang "POP3 端口"}}</label>
<input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110"> <input type="number" name="pop3_port" value="{{.domain.Pop3Port}}" placeholder="110">
</div> </div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()"> <input type="checkbox" name="tls_enabled" id="tls_enabled" {{if .domain.TlsEnabled}}checked{{end}} onchange="togglePorts()">
启用 TLS {{t .Lang "启用 TLS"}}
</label> </label>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">勾选后端口将自动切换为 SSL/TLS 标准端口</p> <p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "勾选后端口将自动切换为 SSL/TLS 标准端口"}}</p>
</div> </div>
{{if .isEdit}} {{if .isEdit}}
<div id="tls_cert_fields" style="display:none;"> <div id="tls_cert_fields" style="display:none;">
<div class="form-group"> <div class="form-group">
<label>TLS 私钥 PEM</label> <label>{{t .Lang "TLS 私钥 PEM"}}</label>
<textarea name="tls_private_key" rows="8" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" style="font-family:monospace;"></textarea> <textarea name="tls_private_key" rows="8" placeholder="-----BEGIN PRIVATE KEY-----&#10;...&#10;-----END PRIVATE KEY-----" style="font-family:monospace;"></textarea>
<p style="color:#7f8c8d;font-size:12px;margin-top:4px;">私钥不会回显;留空则保留现有私钥。</p> <p style="color:#7f8c8d;font-size:12px;margin-top:4px;">{{t .Lang "私钥不会回显;留空则保留现有私钥。"}}</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>TLS 公钥证书 PEM</label> <label>{{t .Lang "TLS 公钥证书 PEM"}}</label>
<textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea> <textarea name="tls_public_cert" rows="8" placeholder="-----BEGIN CERTIFICATE-----&#10;...&#10;-----END CERTIFICATE-----" style="font-family:monospace;">{{.tlsPublicCert}}</textarea>
{{if .tlsCertConfigured}} {{if .tlsCertConfigured}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ TLS 证书已配置;上传新证书后自动热加载生效。</p> <p style="color:#27ae60;font-size:12px;margin-top:4px;">{{t .Lang "✅ TLS 证书已配置;上传新证书后自动热加载生效。"}}</p>
{{else}} {{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。</p> <p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ TLS 证书未配置,启用 TLS 时必须同时填写私钥和证书。"}}</p>
{{end}} {{end}}
</div> </div>
</div> </div>
<div class="form-group" style="margin-top:4px;"> <div class="form-group" style="margin-top:4px;">
<label>从 Caddy 获取证书</label> <label>{{t .Lang "从 Caddy 获取证书"}}</label>
<p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。</p> <p style="color:#7f8c8d;font-size:12px;margin-top:2px;margin-bottom:8px;">{{t .Lang "若该域名已由本机 Caddy 托管 HTTPS(自动签发证书),可一键导入其证书与私钥,并自动启用 TLS;证书热加载,无需重启服务。"}}</p>
<button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">🔒 从 Caddy 获取证书</button> <button type="button" class="btn" id="btn_fetch_caddy" onclick="fetchCaddyCert()" style="background:#2e86de;color:#fff;">{{t .Lang "🔒 从 Caddy 获取证书"}}</button>
<span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span> <span id="caddy_fetch_msg" style="margin-left:10px;font-size:12px;color:#7f8c8d;"></span>
<script> <script>
async function fetchCaddyCert() { async function fetchCaddyCert() {
@@ -83,7 +83,7 @@
var msg = document.getElementById('caddy_fetch_msg'); var msg = document.getElementById('caddy_fetch_msg');
btn.disabled = true; btn.disabled = true;
var oldText = btn.textContent; var oldText = btn.textContent;
btn.textContent = '获取中…'; btn.textContent = '{{t .Lang "获取中…"}}';
msg.textContent = ''; msg.textContent = '';
try { try {
var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' }); var resp = await fetch('/admin/domains/{{.domain.ID}}/fetch-caddy-cert', { method: 'POST' });
@@ -91,9 +91,9 @@
window.location.href = resp.url; window.location.href = resp.url;
return; return;
} }
msg.textContent = '获取失败: ' + await resp.text(); msg.textContent = '{{t .Lang "获取失败"}}: ' + await resp.text();
} catch (e) { } catch (e) {
msg.textContent = '请求失败: ' + e; msg.textContent = '{{t .Lang "请求失败"}}: ' + e;
} }
btn.disabled = false; btn.disabled = false;
btn.textContent = oldText; btn.textContent = oldText;
@@ -118,17 +118,17 @@
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" name="regenerate_dkim"> <input type="checkbox" name="regenerate_dkim">
重新生成 DKIM 密钥 {{t .Lang "重新生成 DKIM 密钥"}}
</label> </label>
{{if .domain.DkimPublicKey}} {{if .domain.DkimPublicKey}}
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ DKIM 密钥已配置</p> <p style="color:#27ae60;font-size:12px;margin-top:4px;">{{t .Lang "✅ DKIM 密钥已配置"}}</p>
{{else}} {{else}}
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ DKIM 密钥未配置</p> <p style="color:#e67e22;font-size:12px;margin-top:4px;">{{t .Lang "⚠️ DKIM 密钥未配置"}}</p>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button> <button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建域名"}}{{end}}</button>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a> <a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form> </form>
</div> </div>
</div> </div>
+22 -22
View File
@@ -1,10 +1,10 @@
{{define "admin_domains"}} {{define "admin_domains"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>域名管理 - MailGo</title> <title>{{t .Lang "域名管理"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,35 +12,35 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>域名列表</h2> <h2>{{t .Lang "域名列表"}}</h2>
<a href="/admin/domains/new" class="btn btn-primary">新增域名</a> <a href="/admin/domains/new" class="btn btn-primary">{{t .Lang "新增域名"}}</a>
</div> </div>
{{if not .domains}} {{if not .domains}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无域名</p> <p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无域名"}}</p>
{{else}} {{else}}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>域名</th> <th>{{t $.Lang "域名"}}</th>
<th>SMTP</th> <th>SMTP</th>
<th>IMAP</th> <th>IMAP</th>
<th>POP3</th> <th>POP3</th>
<th>TLS</th> <th>TLS</th>
<th>操作</th> <th>{{t $.Lang "操作"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -53,11 +53,11 @@
<td>{{.Pop3Port}}</td> <td>{{.Pop3Port}}</td>
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td> <td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</td>
<td> <td>
<a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a> <a href="/admin/domains/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a> <a href="/admin/domains/{{.ID}}/dns" class="btn btn-primary btn-sm">DNS</a>
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;" <form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');"> onsubmit="return confirm('{{tf $.Lang "确定要删除域名 %s 删除域名将导致关联用户无法收发邮件" .Name}}');">
<button type="submit" class="btn btn-danger btn-sm">删除</button> <button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -69,11 +69,11 @@
{{if .totalPages}} {{if .totalPages}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}} {{if gt .page 1}}
<a href="/admin/domains?page={{sub .page 1}}">上一页</a> <a href="/admin/domains?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}} {{end}}
<span>第 {{.page}} / {{.totalPages}}</span> <span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a href="/admin/domains?page={{add .page 1}}">下一页</a> <a href="/admin/domains?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+20 -20
View File
@@ -1,10 +1,10 @@
{{define "admin_mail_view"}} {{define "admin_mail_view"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>查看邮件 - MailGo 管理后台</title> <title>{{t .Lang "查看邮件"}} - MailGo {{t .Lang "管理后台"}}</title>
{{template "styles" .}} {{template "styles" .}}
<style> <style>
.mail-body-iframe { .mail-body-iframe {
@@ -21,29 +21,29 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" class="active">所有邮件</a> <a href="/admin/mails" class="active">{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">返回邮件列表</a> <a href="/admin/mails" class="btn" style="background:#bdc3c7;color:#fff;">{{t .Lang "返回邮件列表"}}</a>
</div> </div>
<h2>{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h2> <h2>{{if .message.Subject}}{{.message.Subject}}{{else}}{{t .Lang "(无主题)"}}{{end}}</h2>
<div class="mail-meta" style="margin-top:12px;"> <div class="mail-meta" style="margin-top:12px;">
<p><strong>发件人:</strong> {{decodeHeader .message.FromAddr}}</p> <p><strong>{{t .Lang "发件人:"}}</strong> {{decodeHeader .message.FromAddr}}</p>
<p><strong>收件人:</strong> {{.message.ToAddr}}</p> <p><strong>{{t .Lang "收件人:"}}</strong> {{.message.ToAddr}}</p>
{{if .message.CcAddr}}<p><strong>抄送:</strong> {{.message.CcAddr}}</p>{{end}} {{if .message.CcAddr}}<p><strong>{{t .Lang "抄送:"}}</strong> {{.message.CcAddr}}</p>{{end}}
<p><strong>所属用户:</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p> <p><strong>{{t .Lang "所属用户:"}}</strong> {{if .message.User.ID}}{{.message.User.Username}}{{else}}—{{end}}</p>
<p><strong>文件夹:</strong> {{.message.Folder}}</p> <p><strong>{{t .Lang "文件夹:"}}</strong> {{.message.Folder}}</p>
<p><strong>时间:</strong> {{time12 .message.Date}}</p> <p><strong>{{t .Lang "时间:"}}</strong> {{time12 .Lang .message.Date}}</p>
</div> </div>
<div class="mail-body"> <div class="mail-body">
{{if .message.HtmlBody}} {{if .message.HtmlBody}}
@@ -54,7 +54,7 @@
</div> </div>
{{if .attachments}} {{if .attachments}}
<div class="attachment-list"> <div class="attachment-list">
<h4 style="margin-bottom:8px;">附件</h4> <h4 style="margin-bottom:8px;">{{t .Lang "附件列表"}}</h4>
{{range .attachments}} {{range .attachments}}
<div class="attachment-item"> <div class="attachment-item">
📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a> 📎 <a href="/admin/attachment/{{.ID}}">{{.FileName}}</a>
+24 -24
View File
@@ -1,10 +1,10 @@
{{define "admin_mails"}} {{define "admin_mails"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>所有邮件 - MailGo</title> <title>{{t .Lang "所有邮件"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,38 +12,38 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<h2 style="margin-bottom:16px;">所有邮件</h2> <h2 style="margin-bottom:16px;">{{t .Lang "所有邮件"}}</h2>
<div style="margin-bottom:16px;"> <div style="margin-bottom:16px;">
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>全部</a> <a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>{{t .Lang "全部"}}</a>
<a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a> <a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a>
<a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a> <a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a>
<a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a> <a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a>
<a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a> <a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a>
</div> </div>
{{if not .messages}} {{if not .messages}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p> <p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无邮件"}}</p>
{{else}} {{else}}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>发件人</th> <th>{{t $.Lang "发件人"}}</th>
<th>收件人</th> <th>{{t $.Lang "收件人"}}</th>
<th>主题</th> <th>{{t $.Lang "主题"}}</th>
<th>所属用户</th> <th>{{t $.Lang "所属用户"}}</th>
<th>文件夹</th> <th>{{t $.Lang "文件夹"}}</th>
<th>日期</th> <th>{{t $.Lang "日期"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -54,7 +54,7 @@
<td>{{.Subject}}</td> <td>{{.Subject}}</td>
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td> <td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
<td>{{.Folder}}</td> <td>{{.Folder}}</td>
<td>{{time12m .Date}}</td> <td>{{time12m $.Lang .Date}}</td>
</tr> </tr>
{{end}} {{end}}
</tbody> </tbody>
@@ -64,11 +64,11 @@
{{if .totalPages}} {{if .totalPages}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}} {{if gt .page 1}}
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">上一页</a> <a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "上一页"}}</a>
{{end}} {{end}}
<span>第 {{.page}} / {{.totalPages}}</span> <span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">下一页</a> <a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">{{t $.Lang "下一页"}}</a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+44 -44
View File
@@ -1,10 +1,10 @@
{{define "admin_outbound"}} {{define "admin_outbound"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>外发队列 - MailGo</title> <title>{{t .Lang "外发队列"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,60 +12,60 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<h2 style="margin-bottom:24px;">外发队列</h2> <h2 style="margin-bottom:24px;">{{t .Lang "外发队列"}}</h2>
<div style="margin-bottom:24px;"> <div style="margin-bottom:24px;">
<div class="stat-card"> <div class="stat-card">
<h3>{{index .statCounts "pending"}}</h3> <h3>{{index .statCounts "pending"}}</h3>
<p>待发送</p> <p>{{t .Lang "待发送"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{index .statCounts "deferred"}}</h3> <h3>{{index .statCounts "deferred"}}</h3>
<p>等待重试</p> <p>{{t .Lang "等待重试"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{index .statCounts "sent"}}</h3> <h3>{{index .statCounts "sent"}}</h3>
<p>已送达</p> <p>{{t .Lang "已送达"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{index .statCounts "failed"}}</h3> <h3>{{index .statCounts "failed"}}</h3>
<p>失败</p> <p>{{t .Lang "失败"}}</p>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div style="margin-bottom:12px;"> <div style="margin-bottom:12px;">
<a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">全部</a> <a href="/admin/outbound" class="btn btn-sm {{if eq .status ""}}btn-primary{{end}}" style="background:{{if eq .status ""}}#3498db{{else}}#ecf0f1{{end}};color:{{if eq .status ""}}#fff{{else}}#333{{end}};">{{t .Lang "全部"}}</a>
<a href="/admin/outbound?status=pending" class="btn btn-sm">待发送</a> <a href="/admin/outbound?status=pending" class="btn btn-sm">{{t .Lang "待发送"}}</a>
<a href="/admin/outbound?status=deferred" class="btn btn-sm">等待重试</a> <a href="/admin/outbound?status=deferred" class="btn btn-sm">{{t .Lang "等待重试"}}</a>
<a href="/admin/outbound?status=sent" class="btn btn-sm">已送达</a> <a href="/admin/outbound?status=sent" class="btn btn-sm">{{t .Lang "已送达"}}</a>
<a href="/admin/outbound?status=failed" class="btn btn-sm">失败</a> <a href="/admin/outbound?status=failed" class="btn btn-sm">{{t .Lang "失败"}}</a>
<a href="/admin/outbound?status=canceled" class="btn btn-sm">已取消</a> <a href="/admin/outbound?status=canceled" class="btn btn-sm">{{t .Lang "已取消"}}</a>
</div> </div>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>发件人</th> <th>{{t .Lang "发件人"}}</th>
<th>收件人</th> <th>{{t .Lang "收件人"}}</th>
<th>状态</th> <th>{{t .Lang "状态"}}</th>
<th>尝试次数</th> <th>{{t .Lang "尝试次数"}}</th>
<th>下次重试</th> <th>{{t .Lang "下次重试"}}</th>
<th>最后响应 / 错误</th> <th>{{t .Lang "最后响应 / 错误"}}</th>
<th>创建时间</th> <th>{{t .Lang "创建时间"}}</th>
<th>操作</th> <th>{{t .Lang "操作"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -75,40 +75,40 @@
<td>{{.FromAddr}}</td> <td>{{.FromAddr}}</td>
<td>{{.ToAddr}}</td> <td>{{.ToAddr}}</td>
<td> <td>
{{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{call $.statusText .Status}}</span> {{if eq .Status "pending"}}<span class="badge" style="background:#f39c12;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{call $.statusText .Status}}</span> {{else if eq .Status "deferred"}}<span class="badge" style="background:#e67e22;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{call $.statusText .Status}}</span> {{else if eq .Status "sent"}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>
{{else if eq .Status "failed"}}<span class="badge badge-unread">{{call $.statusText .Status}}</span> {{else if eq .Status "failed"}}<span class="badge badge-unread">{{t $.Lang (call $.statusText .Status)}}</span>
{{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{call $.statusText .Status}}</span>{{end}} {{else}}<span class="badge" style="background:#95a5a6;color:#fff;">{{t $.Lang (call $.statusText .Status)}}</span>{{end}}
</td> </td>
<td>{{.Attempts}}</td> <td>{{.Attempts}}</td>
<td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m .NextAttemptAt}}{{else}}—{{end}}</td> <td>{{if or (eq .Status "pending") (eq .Status "deferred")}}{{time12m $.Lang .NextAttemptAt}}{{else}}—{{end}}</td>
<td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td> <td style="max-width:280px;word-break:break-all;">{{if .LastResponse}}{{.LastResponse}}{{else}}{{.LastError}}{{end}}</td>
<td>{{time12m .CreatedAt}}</td> <td>{{time12m $.Lang .CreatedAt}}</td>
<td> <td>
{{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}} {{if or (eq .Status "failed") (eq .Status "deferred") (eq .Status "canceled") (eq .Status "pending")}}
<form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;"> <form method="POST" action="/admin/outbound/{{.ID}}/retry" style="display:inline;">
<button type="submit" class="btn btn-sm btn-primary">重试</button> <button type="submit" class="btn btn-sm btn-primary">{{t $.Lang "重试"}}</button>
</form> </form>
{{end}} {{end}}
{{if or (eq .Status "pending") (eq .Status "deferred")}} {{if or (eq .Status "pending") (eq .Status "deferred")}}
<form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('确认取消该投递任务?');"> <form method="POST" action="/admin/outbound/{{.ID}}/cancel" style="display:inline;" onsubmit="return confirm('{{t $.Lang "确认取消该投递任务"}}');">
<button type="submit" class="btn btn-sm btn-danger">取消</button> <button type="submit" class="btn btn-sm btn-danger">{{t $.Lang "取消"}}</button>
</form> </form>
{{end}} {{end}}
</td> </td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="9" style="text-align:center;color:#7f8c8d;">队列为空</td></tr> <tr><td colspan="9" style="text-align:center;color:#7f8c8d;">{{t $.Lang "队列为空"}}</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{if gt .totalPages 1}} {{if gt .totalPages 1}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">上一页</a>{{end}} {{if gt .page 1}}<a href="/admin/outbound?page={{sub .page 1}}&status={{.status}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}}</span> <span class="current">{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">下一页</a>{{end}} {{if lt .page .totalPages}}<a href="/admin/outbound?page={{add .page 1}}&status={{.status}}">{{t $.Lang "下一页"}}</a>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
+44 -44
View File
@@ -1,10 +1,10 @@
{{define "admin_protocol_logs"}} {{define "admin_protocol_logs"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>协议日志 - MailGo</title> <title>{{t .Lang "协议日志"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,41 +12,41 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>协议日志(SMTP / IMAP / POP3</h2> <h2>{{t .Lang "协议日志(SMTP / IMAP / POP3"}}</h2>
<form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;" <form method="POST" action="/admin/protocol-logs/cleanup" style="display:inline;"
onsubmit="return confirm('确认清理 {{.keepDays}} 天前的协议日志?');"> onsubmit="return confirm('{{tf .Lang "确认清理 %d 天前的协议日志" .keepDays}}');">
<button type="submit" class="btn btn-primary">清理旧日志</button> <button type="submit" class="btn btn-primary">{{t .Lang "清理旧日志"}}</button>
</form> </form>
</div> </div>
<div style="margin-bottom:24px;"> <div style="margin-bottom:24px;">
<div class="stat-card"> <div class="stat-card">
<h3>{{index .todayStats "smtp" "fail"}}</h3> <h3>{{index .todayStats "smtp" "fail"}}</h3>
<p>今日 SMTP 失败</p> <p>{{t .Lang "今日 SMTP 失败"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{index .todayStats "imap" "fail"}}</h3> <h3>{{index .todayStats "imap" "fail"}}</h3>
<p>今日 IMAP 失败</p> <p>{{t .Lang "今日 IMAP 失败"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{index .todayStats "pop3" "fail"}}</h3> <h3>{{index .todayStats "pop3" "fail"}}</h3>
<p>今日 POP3 失败</p> <p>{{t .Lang "今日 POP3 失败"}}</p>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3> <h3>{{add (add (index .allStats "smtp" "fail") (index .allStats "imap" "fail")) (index .allStats "pop3" "fail")}}</h3>
<p>历史失败(全部)</p> <p>{{t .Lang "历史失败(全部)"}}</p>
</div> </div>
</div> </div>
@@ -54,45 +54,45 @@
<form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;"> <form method="GET" action="/admin/protocol-logs" style="margin-bottom:16px;">
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;"> <div style="display:flex;flex-wrap:wrap;gap:12px;align-items:center;">
<select name="protocol" style="padding:6px 10px;"> <select name="protocol" style="padding:6px 10px;">
<option value="">全部协议</option> <option value="">{{t .Lang "全部协议"}}</option>
<option value="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option> <option value="smtp" {{if eq .filter.protocol "smtp"}}selected{{end}}>SMTP</option>
<option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</option> <option value="imap" {{if eq .filter.protocol "imap"}}selected{{end}}>IMAP</option>
<option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option> <option value="pop3" {{if eq .filter.protocol "pop3"}}selected{{end}}>POP3</option>
</select> </select>
<select name="success" style="padding:6px 10px;"> <select name="success" style="padding:6px 10px;">
<option value="">全部状态</option> <option value="">{{t .Lang "全部状态"}}</option>
<option value="success" {{if eq .filter.success "success"}}selected{{end}}>成功</option> <option value="success" {{if eq .filter.success "success"}}selected{{end}}>{{t .Lang "成功"}}</option>
<option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>失败</option> <option value="fail" {{if eq .filter.success "fail"}}selected{{end}}>{{t .Lang "失败"}}</option>
</select> </select>
<input type="text" name="ip" placeholder="来源 IP(模糊)" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;"> <input type="text" name="ip" placeholder="{{t .Lang "来源 IP模糊"}}" value="{{.filter.ip}}" style="padding:6px 10px;width:160px;">
<input type="text" name="username" placeholder="用户名(模糊)" value="{{.filter.username}}" style="padding:6px 10px;width:160px;"> <input type="text" name="username" placeholder="{{t .Lang "用户名模糊"}}" value="{{.filter.username}}" style="padding:6px 10px;width:160px;">
<input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;"> <input type="date" name="from" value="{{.filter.from}}" style="padding:6px 10px;">
<span></span> <span>{{t .Lang "至"}}</span>
<input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;"> <input type="date" name="to" value="{{.filter.to}}" style="padding:6px 10px;">
<button type="submit" class="btn btn-sm btn-primary">筛选</button> <button type="submit" class="btn btn-sm btn-primary">{{t .Lang "筛选"}}</button>
<a href="/admin/protocol-logs" class="btn btn-sm">重置</a> <a href="/admin/protocol-logs" class="btn btn-sm">{{t .Lang "重置"}}</a>
</div> </div>
</form> </form>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>时间</th> <th>{{t .Lang "时间"}}</th>
<th>协议</th> <th>{{t .Lang "协议"}}</th>
<th>端口</th> <th>{{t .Lang "端口"}}</th>
<th>来源 IP</th> <th>{{t .Lang "来源 IP"}}</th>
<th>用户名</th> <th>{{t .Lang "用户名"}}</th>
<th>状态</th> <th>{{t .Lang "状态"}}</th>
<th>失败原因</th> <th>{{t .Lang "失败原因"}}</th>
<th>操作摘要</th> <th>{{t .Lang "操作摘要"}}</th>
<th>消息数</th> <th>{{t .Lang "消息数"}}</th>
<th>时长</th> <th>{{t .Lang "时长"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{{range .logs}} {{range .logs}}
<tr> <tr>
<td style="white-space:nowrap;">{{time12 .CreatedAt}}</td> <td style="white-space:nowrap;">{{time12 $.Lang .CreatedAt}}</td>
<td> <td>
{{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span> {{if eq .Protocol "smtp"}}<span class="badge" style="background:#3498db;color:#fff;">SMTP</span>
{{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span> {{else if eq .Protocol "imap"}}<span class="badge" style="background:#9b59b6;color:#fff;">IMAP</span>
@@ -102,8 +102,8 @@
<td>{{.ClientIP}}</td> <td>{{.ClientIP}}</td>
<td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td> <td>{{if .Username}}{{.Username}}{{else}}—{{end}}</td>
<td> <td>
{{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">成功</span> {{if .Success}}<span class="badge" style="background:#27ae60;color:#fff;">{{t $.Lang "成功"}}</span>
{{else}}<span class="badge badge-unread">失败</span>{{end}} {{else}}<span class="badge badge-unread">{{t $.Lang "失败"}}</span>{{end}}
</td> </td>
<td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{end}}</td> <td style="max-width:200px;">{{if .FailReason}}{{.FailReason}}{{else}}—{{end}}</td>
<td style="max-width:320px;word-break:break-all;font-size:13px;color:#555;">{{.Detail}}</td> <td style="max-width:320px;word-break:break-all;font-size:13px;color:#555;">{{.Detail}}</td>
@@ -111,16 +111,16 @@
<td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td> <td>{{if .DurationMs}}{{div .DurationMs 1000}}s{{else}}—{{end}}</td>
</tr> </tr>
{{else}} {{else}}
<tr><td colspan="10" style="text-align:center;color:#7f8c8d;">暂无记录</td></tr> <tr><td colspan="10" style="text-align:center;color:#7f8c8d;">{{t $.Lang "暂无记录"}}</td></tr>
{{end}} {{end}}
</tbody> </tbody>
</table> </table>
{{if gt .totalPages 1}} {{if gt .totalPages 1}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">上一页</a>{{end}} {{if gt .page 1}}<a href="/admin/protocol-logs?page={{sub .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">{{t $.Lang "上一页"}}</a>{{end}}
<span class="current">第 {{.page}} / {{.totalPages}} 页(共 {{.total}} 条)</span> <span class="current">{{tf $.Lang "第 %d / %d 页(共 %d 条)" .page .totalPages .total}}</span>
{{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">下一页</a>{{end}} {{if lt .page .totalPages}}<a href="/admin/protocol-logs?page={{add .page 1}}&protocol={{.filter.protocol}}&success={{.filter.success}}&ip={{.filter.ip}}&username={{.filter.username}}&from={{.filter.from}}&to={{.filter.to}}">{{t $.Lang "下一页"}}</a>{{end}}
</div> </div>
{{end}} {{end}}
</div> </div>
+31 -22
View File
@@ -1,10 +1,10 @@
{{define "admin_user_form"}} {{define "admin_user_form"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{if .isEdit}}编辑用户{{else}}新增用户{{end}} - MailGo</title> <title>{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,58 +12,67 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<h2 style="margin-bottom:16px;">{{if .isEdit}}编辑用户{{else}}新增用户{{end}}</h2> <h2 style="margin-bottom:16px;">{{if .isEdit}}{{t .Lang "编辑用户"}}{{else}}{{t .Lang "新增用户"}}{{end}}</h2>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}"> <form method="POST" action="{{if .isEdit}}/admin/users/{{.user.ID}}{{else}}/admin/users{{end}}">
<div class="form-group"> <div class="form-group">
<label>用户名</label> <label>{{t .Lang "用户名"}}</label>
<input type="text" name="username" required value="{{.user.Username}}" placeholder="username"> <input type="text" name="username" required value="{{.user.Username}}" placeholder="username">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>{{if .isEdit}}新密码(留空则不修改){{else}}密码{{end}}</label> <label>{{if .isEdit}}{{t .Lang "新密码(留空则不修改)"}}{{else}}{{t .Lang "密码"}}{{end}}</label>
<input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="请输入密码"> <input type="password" name="password" {{if not .isEdit}}required{{end}} placeholder="{{t .Lang "请输入密码"}}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>域名</label> <label>{{t .Lang "界面语言"}}</label>
<select name="language">
<option value="auto" {{if eq .user.Language "auto"}}selected{{end}}>{{t .Lang "自动(跟随浏览器)"}}</option>
<option value="en" {{if eq .user.Language "en"}}selected{{end}}>English</option>
<option value="zh" {{if eq .user.Language "zh"}}selected{{end}}>中文</option>
<option value="ja" {{if eq .user.Language "ja"}}selected{{end}}>日本語</option>
</select>
</div>
<div class="form-group">
<label>{{t .Lang "域名"}}</label>
<select name="domain_id" required> <select name="domain_id" required>
<option value="">请选择域名</option> <option value="">{{t .Lang "请选择域名"}}</option>
{{range .domains}} {{range .domains}}
<option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option> <option value="{{.ID}}" {{if eq .ID $.user.DomainID}}selected{{end}}>{{.Name}}</option>
{{end}} {{end}}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>配额 (GB)</label> <label>{{t .Lang "配额 (GB)"}}</label>
<input type="number" name="quota_gb" min="1" value="5" placeholder="5"> <input type="number" name="quota_gb" min="1" value="5" placeholder="5">
</div> </div>
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}> <input type="checkbox" name="is_admin" {{if .user.IsAdmin}}checked{{end}}>
管理员 {{t .Lang "管理员"}}
</label> </label>
</div> </div>
{{if .isEdit}} {{if .isEdit}}
<div class="form-group"> <div class="form-group">
<label> <label>
<input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}> <input type="checkbox" name="is_active" {{if .user.IsActive}}checked{{end}}>
启用账户 {{t .Lang "启用账户"}}
</label> </label>
</div> </div>
{{end}} {{end}}
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建用户{{end}}</button> <button type="submit" class="btn btn-primary">{{if .isEdit}}{{t .Lang "保存更改"}}{{else}}{{t .Lang "创建用户"}}{{end}}</button>
<a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a> <a href="/admin/users" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">{{t .Lang "取消"}}</a>
</form> </form>
</div> </div>
</div> </div>
+27 -27
View File
@@ -1,10 +1,10 @@
{{define "admin_users"}} {{define "admin_users"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>用户管理 - MailGo</title> <title>{{t .Lang "用户管理"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body> <body>
@@ -12,36 +12,36 @@
<div class="container"> <div class="container">
<div class="clearfix"> <div class="clearfix">
<div class="sidebar"> <div class="sidebar">
<a href="/inbox">返回邮箱</a> <a href="/inbox">{{t .Lang "返回邮箱"}}</a>
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a> <a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>{{t .Lang "控制面板"}}</a>
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a> <a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>{{t .Lang "域名管理"}}</a>
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a> <a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>{{t .Lang "用户管理"}}</a>
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a> <a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>{{t .Lang "所有邮件"}}</a>
<a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>外发队列</a> <a href="/admin/outbound" {{if eq .activeFolder "outbound"}}class="active"{{end}}>{{t .Lang "外发队列"}}</a>
<a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>协议日志</a> <a href="/admin/protocol-logs" {{if eq .activeFolder "protocol-logs"}}class="active"{{end}}>{{t .Lang "协议日志"}}</a>
<a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>当前连接</a> <a href="/admin/connections" {{if eq .activeFolder "connections"}}class="active"{{end}}>{{t .Lang "当前连接"}}</a>
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a> <a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>{{t .Lang "IP黑名单"}}</a>
</div> </div>
<div class="content"> <div class="content">
<div class="card"> <div class="card">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h2>用户列表</h2> <h2>{{t .Lang "用户列表"}}</h2>
<a href="/admin/users/new" class="btn btn-primary">新增用户</a> <a href="/admin/users/new" class="btn btn-primary">{{t .Lang "新增用户"}}</a>
</div> </div>
{{if not .users}} {{if not .users}}
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无用户</p> <p style="color:#7f8c8d;text-align:center;padding:40px 0;">{{t $.Lang "暂无用户"}}</p>
{{else}} {{else}}
<table> <table>
<thead> <thead>
<tr> <tr>
<th>ID</th> <th>ID</th>
<th>用户名</th> <th>{{t $.Lang "用户名"}}</th>
<th>域名ID</th> <th>{{t $.Lang "域名ID"}}</th>
<th>配额</th> <th>{{t $.Lang "配额"}}</th>
<th>已用</th> <th>{{t $.Lang "已用"}}</th>
<th>状态</th> <th>{{t $.Lang "状态"}}</th>
<th>管理员</th> <th>{{t $.Lang "管理员"}}</th>
<th>操作</th> <th>{{t $.Lang "操作"}}</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -55,10 +55,10 @@
<td>{{if .IsActive}}✅{{else}}❌{{end}}</td> <td>{{if .IsActive}}✅{{else}}❌{{end}}</td>
<td>{{if .IsAdmin}}✅{{else}}—{{end}}</td> <td>{{if .IsAdmin}}✅{{else}}—{{end}}</td>
<td> <td>
<a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">编辑</a> <a href="/admin/users/{{.ID}}/edit" class="btn btn-primary btn-sm">{{t $.Lang "编辑"}}</a>
<form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;" <form method="POST" action="/admin/users/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除用户 {{.Username}} 吗?');"> onsubmit="return confirm('{{tf $.Lang "确定要删除用户 %s " .Username}}');">
<button type="submit" class="btn btn-danger btn-sm">删除</button> <button type="submit" class="btn btn-danger btn-sm">{{t $.Lang "删除"}}</button>
</form> </form>
</td> </td>
</tr> </tr>
@@ -70,11 +70,11 @@
{{if .totalPages}} {{if .totalPages}}
<div class="pagination"> <div class="pagination">
{{if gt .page 1}} {{if gt .page 1}}
<a href="/admin/users?page={{sub .page 1}}">上一页</a> <a href="/admin/users?page={{sub .page 1}}">{{t $.Lang "上一页"}}</a>
{{end}} {{end}}
<span>第 {{.page}} / {{.totalPages}}</span> <span>{{tf $.Lang "第 %d / %d 页" .page .totalPages}}</span>
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a href="/admin/users?page={{add .page 1}}">下一页</a> <a href="/admin/users?page={{add .page 1}}">{{t $.Lang "下一页"}}</a>
{{end}} {{end}}
</div> </div>
{{end}} {{end}}
+15 -15
View File
@@ -1,10 +1,10 @@
{{define "banned"}} {{define "banned"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>访问被禁止 - MailGo</title> <title>{{t .Lang "访问被禁止"}} - MailGo</title>
<style> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } * { margin: 0; padding: 0; box-sizing: border-box; }
body { body {
@@ -43,34 +43,34 @@
<body> <body>
<div class="banned-card"> <div class="banned-card">
<div class="banned-icon">🚫</div> <div class="banned-icon">🚫</div>
<h1>您的 IP 已被暂时禁止访问</h1> <h1>{{t .Lang "您的 IP 已被暂时禁止访问"}}</h1>
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p> <p>{{t .Lang "由于登录失败次数过多,您的 IP 地址已被暂时封禁。"}}</p>
{{if .entry}} {{if .entry}}
<div class="detail"> <div class="detail">
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">IP 地址</span> <span class="detail-label">{{t .Lang "IP 地址"}}</span>
<span class="detail-value">{{.entry.IPAddress}}</span> <span class="detail-value">{{.entry.IPAddress}}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">封禁档位</span> <span class="detail-label">{{t .Lang "封禁档位"}}</span>
<span class="detail-value">{{if gt .entry.BanCount 3}}{{sub .entry.BanCount 3}} 次封禁{{else}}—{{end}}</span> <span class="detail-value">{{if gt .entry.BanCount 3}}{{tf .Lang "第 %d 次封禁" (sub .entry.BanCount 3)}}{{else}}—{{end}}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">原因</span> <span class="detail-label">{{t .Lang "原因"}}</span>
<span class="detail-value">{{.entry.Reason}}</span> <span class="detail-value">{{t .Lang .entry.Reason}}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">失败次数</span> <span class="detail-label">{{t .Lang "失败次数"}}</span>
<span class="detail-value">{{.entry.FailCount}}</span> <span class="detail-value">{{tf .Lang "%d 次" .entry.FailCount}}</span>
</div> </div>
<div class="detail-row"> <div class="detail-row">
<span class="detail-label">解封时间</span> <span class="detail-label">{{t .Lang "解封时间"}}</span>
<span class="detail-value">{{time12 .entry.ExpiresAt}}</span> <span class="detail-value">{{time12 .Lang .entry.ExpiresAt}}</span>
</div> </div>
</div> </div>
{{end}} {{end}}
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p> <p style="font-size:13px;">{{t .Lang "请在解封时间后重试,或联系管理员。"}}</p>
<a href="/login" class="back-link">返回登录页</a> <a href="/login" class="back-link">{{t .Lang "返回登录页"}}</a>
</div> </div>
</body> </body>
</html> </html>
+9 -9
View File
@@ -433,22 +433,22 @@
<div class="topbar-left"> <div class="topbar-left">
<a class="logo" href="/inbox"> <a class="logo" href="/inbox">
<span class="logo-icon"></span> <span class="logo-icon"></span>
<span class="logo-text">MailGo<em>邮箱</em></span> <span class="logo-text">MailGo<em>{{t .Lang "邮箱"}}</em></span>
</a> </a>
</div> </div>
<div class="topbar-search"> <div class="topbar-search">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input id="mail-search" type="text" placeholder="搜索邮件(发件人 / 主题)" autocomplete="off"> <input id="mail-search" type="text" placeholder="{{t .Lang "搜索邮件发件人 / 主题"}}" autocomplete="off">
</div> </div>
<div class="topbar-right"> <div class="topbar-right">
{{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">管理后台</a>{{end}} {{if .currentUser.IsAdmin}}<a class="topbar-link" href="/admin">{{t .Lang "管理后台"}}</a>{{end}}
<a class="topbar-link" href="/settings">设置</a> <a class="topbar-link" href="/settings">{{t .Lang "设置"}}</a>
<span class="user-chip"> <span class="user-chip">
<span class="avatar avatar-sm" style="{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span> <span class="avatar avatar-sm" style="{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<span class="user-email" title="{{.currentUser.Username}}@{{.currentUser.Domain.Name}}">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span> <span class="user-email" title="{{.currentUser.Username}}@{{.currentUser.Domain.Name}}">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</span>
</span> </span>
<form method="POST" action="/logout" class="logout-form"> <form method="POST" action="/logout" class="logout-form">
<button type="submit" class="icon-btn" title="退出登录"> <button type="submit" class="icon-btn" title="{{t .Lang "退出登录"}}">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
</button> </button>
</form> </form>
@@ -461,7 +461,7 @@
<aside class="mail-sidebar"> <aside class="mail-sidebar">
<a href="/compose" class="compose-btn"> <a href="/compose" class="compose-btn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
写信 {{t .Lang "写信"}}
</a> </a>
<nav class="folder-nav"> <nav class="folder-nav">
{{range .folders}} {{range .folders}}
@@ -477,7 +477,7 @@
{{else}} {{else}}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
{{end}} {{end}}
{{folderLabel .Name}} {{folderLabel $.Lang .Name}}
{{if eq .Name "INBOX"}} {{if eq .Name "INBOX"}}
{{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}} {{if .Unseen}}<span class="badge">{{.Unseen}}</span>{{end}}
{{else if .Total}}<span class="count">{{.Total}}</span>{{end}} {{else if .Total}}<span class="count">{{.Total}}</span>{{end}}
@@ -488,7 +488,7 @@
{{if .currentUser.IsAdmin}} {{if .currentUser.IsAdmin}}
<a class="folder" href="/admin"> <a class="folder" href="/admin">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
管理后台 {{t .Lang "管理后台"}}
</a> </a>
{{end}} {{end}}
</div> </div>
@@ -541,7 +541,7 @@
var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; }) var ids = rows.filter(function (r) { return r.isConnected && r.querySelector('.row-check').checked; })
.map(function (r) { return r.dataset.id; }); .map(function (r) { return r.dataset.id; });
if (!ids.length) return; if (!ids.length) return;
if (!confirm('确定要删除选中的 ' + ids.length + ' 封邮件吗?')) return; if (!confirm('{{t .Lang "确定要删除选中的 %s 封邮件吗?"}}'.replace('%s', ids.length))) return;
var done = 0; var done = 0;
ids.forEach(function (id) { ids.forEach(function (id) {
fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() }) fetch('/mail/delete/' + id, { method: 'POST', body: new FormData() })
+16 -16
View File
@@ -1,10 +1,10 @@
{{define "compose"}} {{define "compose"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>写信 - MailGo</title> <title>{{t .Lang "写信"}} - MailGo</title>
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet"> <link href="/static/vendor/quill/quill.snow.css" rel="stylesheet">
{{template "styles" .}} {{template "styles" .}}
</head> </head>
@@ -16,44 +16,44 @@
<div class="compose-toolbar"> <div class="compose-toolbar">
<button type="submit" form="compose-form" class="btn btn-primary"> <button type="submit" form="compose-form" class="btn btn-primary">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
发送 {{t .Lang "发送"}}
</button> </button>
<label class="tb-btn" style="cursor:pointer;"> <label class="tb-btn" style="cursor:pointer;">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
附件 {{t .Lang "附件"}}
<input type="file" name="attachments" id="attach-input" multiple style="display:none;"> <input type="file" name="attachments" id="attach-input" multiple style="display:none;">
</label> </label>
<a href="/inbox" class="tb-btn">取消</a> <a href="/inbox" class="tb-btn">{{t .Lang "取消"}}</a>
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<span class="page-info">撰写新邮件</span> <span class="page-info">{{t .Lang "撰写新邮件"}}</span>
</div> </div>
{{if .error}}<div class="alert alert-error" style="margin:12px 18px 0;">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-error" style="margin:12px 18px 0;">{{.error}}</div>{{end}}
<form id="compose-form" class="compose-form" method="POST" action="/compose" enctype="multipart/form-data"> <form id="compose-form" class="compose-form" method="POST" action="/compose" enctype="multipart/form-data">
<div class="compose-field"> <div class="compose-field">
<label>收件人</label> <label>{{t .Lang "收件人"}}</label>
<input type="email" name="to" required value="{{.to}}" placeholder="输入收件人邮箱地址"> <input type="email" name="to" required value="{{.to}}" placeholder="{{t .Lang "输入收件人邮箱地址"}}">
</div> </div>
<div class="compose-field"> <div class="compose-field">
<label>抄送</label> <label>{{t .Lang "抄送"}}</label>
<input type="text" name="cc" value="{{.cc}}" placeholder="多个地址用逗号分隔(可选)"> <input type="text" name="cc" value="{{.cc}}" placeholder="{{t .Lang "多个地址用逗号分隔可选"}}">
</div> </div>
<div class="compose-field"> <div class="compose-field">
<label>主题</label> <label>{{t .Lang "主题"}}</label>
<input type="text" name="subject" value="{{.subject}}" placeholder="输入邮件主题"> <input type="text" name="subject" value="{{.subject}}" placeholder="{{t .Lang "输入邮件主题"}}">
</div> </div>
<div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div> <div id="attach-chips" class="attach-chips" style="{{if not .attachments}}display:none;{{end}}"></div>
<div class="editor-wrap"> <div class="editor-wrap">
<div id="editor" data-placeholder="请输入邮件内容..."></div> <div id="editor" data-placeholder="{{t .Lang "请输入邮件内容..."}}"></div>
<input type="hidden" name="body" id="body-hidden"> <input type="hidden" name="body" id="body-hidden">
<input type="hidden" name="html_body" id="html-body-hidden"> <input type="hidden" name="html_body" id="html-body-hidden">
</div> </div>
<div class="compose-footer"> <div class="compose-footer">
<span>附件配额</span> <span>{{t .Lang "附件配额"}}</span>
<span class="quota-bar" id="quota-bar" data-used="{{.usedBytes}}" data-quota="{{.quotaBytes}}"><i></i></span> <span class="quota-bar" id="quota-bar" data-used="{{.usedBytes}}" data-quota="{{.quotaBytes}}"><i></i></span>
<span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span> <span id="quota-text">{{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}</span>
</div> </div>
@@ -64,7 +64,7 @@
<script> <script>
var quill = new Quill('#editor', { var quill = new Quill('#editor', {
theme: 'snow', theme: 'snow',
placeholder: document.getElementById('editor').dataset.placeholder || '请输入邮件内容...', placeholder: document.getElementById('editor').dataset.placeholder || '{{t .Lang "请输入邮件内容..."}}',
modules: { modules: {
toolbar: [ toolbar: [
[{ 'header': [1, 2, 3, false] }], [{ 'header': [1, 2, 3, false] }],
@@ -96,7 +96,7 @@
var chip = document.createElement('span'); var chip = document.createElement('span');
chip.className = 'attach-chip'; chip.className = 'attach-chip';
chip.innerHTML = '📎 ' + f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)' + chip.innerHTML = '📎 ' + f.name + ' (' + (f.size / 1024).toFixed(1) + ' KB)' +
'<button type="button" class="chip-del" data-i="' + i + '" title="移除">✕</button>'; '<button type="button" class="chip-del" data-i="' + i + '" title="{{t .Lang "移除"}}">✕</button>';
chipsBox.appendChild(chip); chipsBox.appendChild(chip);
}); });
} }
+25 -25
View File
@@ -1,10 +1,10 @@
{{define "folder"}} {{define "folder"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>{{folderLabel .folder}} - MailGo</title> <title>{{folderLabel .Lang .folder}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="page-list"> <body class="page-list">
@@ -13,31 +13,31 @@
{{template "sidebar" .}} {{template "sidebar" .}}
<main class="mail-main"> <main class="mail-main">
<div class="list-toolbar"> <div class="list-toolbar">
<label class="check-all" title="全选/取消全选"> <label class="check-all" title="{{t .Lang "全选/取消全选"}}">
<input type="checkbox" id="select-all"> <input type="checkbox" id="select-all">
全选 {{t .Lang "全选"}}
</label> </label>
<button type="button" class="tb-btn" id="btn-refresh" title="刷新"> <button type="button" class="tb-btn" id="btn-refresh" title="{{t .Lang "刷新"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
刷新 {{t .Lang "刷新"}}
</button> </button>
<button type="button" class="tb-btn danger" id="btn-delete" disabled> <button type="button" class="tb-btn danger" id="btn-delete" disabled>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
{{if .isTrash}}彻底删除{{else}}删除{{end}} {{if .isTrash}}{{t $.Lang "彻底删除"}}{{else}}{{t $.Lang "删除"}}{{end}}
</button> </button>
{{if .isTrash}} {{if .isTrash}}
<form method="POST" action="/folder/Trash/empty" style="display:inline;" <form method="POST" action="/folder/Trash/empty" style="display:inline;"
onsubmit="return confirm('确定要清空「已删除」文件夹吗?此操作不可恢复。');"> onsubmit="return confirm('{{t $.Lang "确定要清空已删除文件夹吗此操作不可恢复"}}');">
<button type="submit" class="tb-btn danger">清空文件夹</button> <button type="submit" class="tb-btn danger">{{t $.Lang "清空文件夹"}}</button>
</form> </form>
{{end}} {{end}}
<div class="toolbar-spacer"></div> <div class="toolbar-spacer"></div>
<span class="page-info">{{.total}}</span> <span class="page-info">{{tf .Lang "共 %d 封" .total}}</span>
</div> </div>
{{if not .messages}} {{if not .messages}}
<div class="mail-list"> <div class="mail-list">
<div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel .folder}}暂无邮件</div> <div class="empty-tip"><span class="empty-icon">{{if .isTrash}}🗑{{else}}📭{{end}}</span>{{folderLabel $.Lang $.folder}} · {{t $.Lang "暂无邮件"}}</div>
</div> </div>
{{else}} {{else}}
<ul class="mail-list"> <ul class="mail-list">
@@ -53,9 +53,9 @@
<span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span> <span class="avatar" style="{{avatarStyle .FromAddr}}">{{initial (mailName (decodeHeader .FromAddr))}}</span>
{{end}} {{end}}
</span> </span>
<span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="收件人:{{.ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}> <span class="cell-from" {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}title="{{tf $.Lang "收件人%s" .ToAddr}}"{{else}}title="{{decodeHeader .FromAddr}}"{{end}}>
{{if or (eq $.folder "Sent") (eq $.folder "Drafts")}} {{if or (eq $.folder "Sent") (eq $.folder "Drafts")}}
{{if eq $.folder "Drafts"}}致:{{end}}{{mailName .ToAddr}} {{if eq $.folder "Drafts"}}{{t $.Lang "致:"}}{{end}}{{mailName .ToAddr}}
{{else}} {{else}}
{{mailName (decodeHeader .FromAddr)}} {{mailName (decodeHeader .FromAddr)}}
{{end}} {{end}}
@@ -63,24 +63,24 @@
<span class="cell-subject-wrap"> <span class="cell-subject-wrap">
{{if not .IsRead}}<span class="unread-dot"></span>{{end}} {{if not .IsRead}}<span class="unread-dot"></span>{{end}}
<a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}"> <a class="cell-subject" href="/folder/{{urlPath $.folder}}/{{.ID}}">
{{if .Subject}}{{.Subject}}{{else}}(无主题){{end}} {{if .Subject}}{{.Subject}}{{else}}{{t $.Lang "(无主题)"}}{{end}}
</a> </a>
</span> </span>
<span class="cell-snippet"> <span class="cell-snippet">
{{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}[HTML 邮件]{{end}} {{if .TextBody}}{{truncate .TextBody 80}}{{else if .HtmlBody}}{{t $.Lang "[HTML 邮件]"}}{{end}}
</span> </span>
<span class="cell-date">{{shortDate .Date}}</span> <span class="cell-date">{{shortDate $.Lang .Date}}</span>
{{if $.isTrash}} {{if $.isTrash}}
<form method="POST" action="/mail/restore/{{.ID}}" class="row-del" <form method="POST" action="/mail/restore/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要恢复这封邮件吗?');"> onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要恢复这封邮件吗"}}');">
<button type="submit" class="icon-btn" title="恢复到收件箱"> <button type="submit" class="icon-btn" title="{{t $.Lang "恢复到收件箱"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
</button> </button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/mail/delete/{{.ID}}" class="row-del" <form method="POST" action="/mail/delete/{{.ID}}" class="row-del"
onsubmit="event.stopPropagation(); return confirm('确定要删除这封邮件吗?');"> onsubmit="event.stopPropagation(); return confirm('{{t $.Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="icon-btn" title="删除"> <button type="submit" class="icon-btn" title="{{t $.Lang "删除"}}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
</button> </button>
</form> </form>
@@ -91,23 +91,23 @@
{{end}} {{end}}
<div class="list-footer"> <div class="list-footer">
<span class="page-num">第 {{.page}} / {{if .totalPages}}{{.totalPages}}{{else}}1{{end}} 页</span> <span class="page-num">{{tf .Lang "第 %d / %d 页" .page (or .totalPages 1)}}</span>
<div class="pager"> <div class="pager">
{{if gt .page 1}} {{if gt .page 1}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}"> <a class="page-btn" href="/folder/{{urlPath .folder}}?page={{sub .page 1}}">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
上一页 {{t $.Lang "上一页"}}
</a> </a>
{{else}} {{else}}
<span class="page-btn disabled">上一页</span> <span class="page-btn disabled">{{t $.Lang "上一页"}}</span>
{{end}} {{end}}
{{if lt .page .totalPages}} {{if lt .page .totalPages}}
<a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}"> <a class="page-btn" href="/folder/{{urlPath .folder}}?page={{add .page 1}}">
下一页 {{t $.Lang "下一页"}}
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</a> </a>
{{else}} {{else}}
<span class="page-btn disabled">下一页</span> <span class="page-btn disabled">{{t $.Lang "下一页"}}</span>
{{end}} {{end}}
</div> </div>
</div> </div>
+15 -15
View File
@@ -1,55 +1,55 @@
{{define "login"}} {{define "login"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>登录 - MailGo 邮箱</title> <title>{{t .Lang "登录"}} - MailGo {{t .Lang "邮箱"}}</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="login-page"> <body class="login-page">
<div class="login-card"> <div class="login-card">
<div class="login-logo"> <div class="login-logo">
<span class="logo-icon" style="width:44px;height:44px;font-size:22px;border-radius:12px;"></span> <span class="logo-icon" style="width:44px;height:44px;font-size:22px;border-radius:12px;"></span>
<span style="font-size:26px;font-weight:700;color:var(--primary);">MailGo<em style="font-style:normal;font-weight:400;font-size:15px;color:var(--text-2);margin-left:4px;">邮箱</em></span> <span style="font-size:26px;font-weight:700;color:var(--primary);">MailGo<em style="font-style:normal;font-weight:400;font-size:15px;color:var(--text-2);margin-left:4px;">{{t .Lang "邮箱"}}</em></span>
</div> </div>
<div class="login-sub">登录您的邮箱账户</div> <div class="login-sub">{{t .Lang "登录您的邮箱账户"}}</div>
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
<form method="POST" action="/login"> <form method="POST" action="/login">
<div class="form-group"> <div class="form-group">
<label>邮箱地址</label> <label>{{t .Lang "邮箱地址"}}</label>
<input type="email" name="email" required autofocus placeholder="user@example.com" style="padding:11px 14px;border-radius:8px;"> <input type="email" name="email" required autofocus placeholder="user@example.com" style="padding:11px 14px;border-radius:8px;">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>密码</label> <label>{{t .Lang "密码"}}</label>
<input type="password" name="password" required placeholder="请输入密码" style="padding:11px 14px;border-radius:8px;"> <input type="password" name="password" required placeholder="{{t .Lang "请输入密码"}}" style="padding:11px 14px;border-radius:8px;">
</div> </div>
<button type="submit" class="btn btn-primary" style="width:100%;padding:11px 16px;font-size:15px;border-radius:8px;">登 录</button> <button type="submit" class="btn btn-primary" style="width:100%;padding:11px 16px;font-size:15px;border-radius:8px;">{{t .Lang "登 录"}}</button>
</form> </form>
{{if or .oauth2Enabled .ldapEnabled}} {{if or .oauth2Enabled .ldapEnabled}}
<div class="divider"></div> <div class="divider">{{t .Lang "或"}}</div>
{{end}} {{end}}
{{if .ldapEnabled}} {{if .ldapEnabled}}
<form method="POST" action="/login/ldap" style="margin-bottom:10px;"> <form method="POST" action="/login/ldap" style="margin-bottom:10px;">
<div class="form-group"> <div class="form-group">
<label>LDAP 用户名</label> <label>{{t .Lang "LDAP 用户名"}}</label>
<input type="text" name="username" placeholder="LDAP 用户名"> <input type="text" name="username" placeholder="{{t .Lang "LDAP 用户名"}}">
</div> </div>
<div class="form-group"> <div class="form-group">
<label>LDAP 密码</label> <label>{{t .Lang "LDAP 密码"}}</label>
<input type="password" name="password" placeholder="LDAP 密码"> <input type="password" name="password" placeholder="{{t .Lang "LDAP 密码"}}">
</div> </div>
<button type="submit" class="btn" style="width:100%;background:#7b5cd6;color:#fff;">LDAP 登录</button> <button type="submit" class="btn" style="width:100%;background:#7b5cd6;color:#fff;">{{t .Lang "LDAP 登录"}}</button>
</form> </form>
{{end}} {{end}}
{{if .oauth2Enabled}} {{if .oauth2Enabled}}
<a href="/auth/oauth2" class="btn btn-primary" style="width:100%;text-align:center;display:block;margin-top:4px;"> <a href="/auth/oauth2" class="btn btn-primary" style="width:100%;text-align:center;display:block;margin-top:4px;">
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录 {{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} {{t .Lang "登录"}}
</a> </a>
{{end}} {{end}}
</div> </div>
+38 -22
View File
@@ -1,10 +1,10 @@
{{define "settings"}} {{define "settings"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>设置 - MailGo</title> <title>{{t .Lang "设置"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="page-settings"> <body class="page-settings">
@@ -15,21 +15,21 @@
{{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}} {{if .error}}<div class="alert alert-error">{{.error}}</div>{{end}}
{{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}} {{if .success}}<div class="alert alert-success">{{.success}}</div>{{end}}
{{if .mustChange}}<div class="alert" style="border:1px solid #ffa940;background:#fff7e6;color:#d46b08;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13.5px;"> {{if .mustChange}}<div class="alert" style="border:1px solid #ffa940;background:#fff7e6;color:#d46b08;border-radius:8px;padding:12px 16px;margin-bottom:16px;font-size:13.5px;">
⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。 {{t .Lang "⚠️ 首次登录/密码已被重置,请立即修改密码后再继续使用邮箱功能。"}}
</div>{{end}} </div>{{end}}
<div class="card" style="max-width:720px;"> <div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;"> <h2 style="font-size:17px;margin-bottom:18px;display:flex;align-items:center;gap:10px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#1677ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
账号信息 {{t .Lang "账号信息"}}
</h2> </h2>
<div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;"> <div style="display:flex;align-items:center;gap:14px;margin-bottom:20px;">
<span class="avatar" style="width:52px;height:52px;font-size:22px;{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span> <span class="avatar" style="width:52px;height:52px;font-size:22px;{{avatarStyle .currentUser.Username}}">{{initial .currentUser.Username}}</span>
<div> <div>
<div style="font-size:16px;font-weight:600;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</div> <div style="font-size:16px;font-weight:600;">{{.currentUser.Username}}@{{.currentUser.Domain.Name}}</div>
<div style="color:var(--text-3);font-size:12.5px;margin-top:3px;"> <div style="color:var(--text-3);font-size:12.5px;margin-top:3px;">
已用 {{formatBytes .currentUser.UsedBytes}} / 配额 {{formatBytes .currentUser.QuotaBytes}} {{tf .Lang "已用 %s / 配额 %s" (formatBytes .currentUser.UsedBytes) (formatBytes .currentUser.QuotaBytes)}}
{{if .currentUser.IsAdmin}} · 管理员{{end}} {{if .currentUser.IsAdmin}} · {{t .Lang "管理员"}}{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -37,30 +37,46 @@
</div> </div>
<div class="card" style="max-width:720px;"> <div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:18px;">修改密码</h2> <h2 style="font-size:17px;margin-bottom:18px;">{{t .Lang "界面语言"}}</h2>
<form method="POST" action="/settings" style="max-width:420px;"> <form method="POST" action="/settings" style="max-width:420px;">
<div class="form-group"> <div class="form-group">
<label>当前密码</label> <label>{{t .Lang "界面语言"}}</label>
<input type="password" name="old_password" required placeholder="请输入当前密码" autocomplete="current-password"> <select name="language">
<option value="auto" {{if eq .currentUser.Language "auto"}}selected{{end}}>{{t .Lang "自动(跟随浏览器)"}}</option>
<option value="en" {{if eq .currentUser.Language "en"}}selected{{end}}>English</option>
<option value="zh" {{if eq .currentUser.Language "zh"}}selected{{end}}>中文</option>
<option value="ja" {{if eq .currentUser.Language "ja"}}selected{{end}}>日本語</option>
</select>
</div> </div>
<div class="form-group"> <button type="submit" class="btn btn-primary">{{t .Lang "保存"}}</button>
<label>新密码</label>
<input type="password" name="new_password" required placeholder="请输入新密码" autocomplete="new-password">
</div>
<div class="form-group">
<label>确认新密码</label>
<input type="password" name="confirm_password" required placeholder="请再次输入新密码" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">修改密码</button>
</form> </form>
</div> </div>
<div class="card" style="max-width:720px;"> <div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:10px;">帮助</h2> <h2 style="font-size:17px;margin-bottom:18px;">{{t .Lang "修改密码"}}</h2>
<form method="POST" action="/settings" style="max-width:420px;">
<div class="form-group">
<label>{{t .Lang "当前密码"}}</label>
<input type="password" name="old_password" required placeholder="{{t .Lang "请输入当前密码"}}" autocomplete="current-password">
</div>
<div class="form-group">
<label>{{t .Lang "新密码"}}</label>
<input type="password" name="new_password" required placeholder="{{t .Lang "请输入新密码"}}" autocomplete="new-password">
</div>
<div class="form-group">
<label>{{t .Lang "确认新密码"}}</label>
<input type="password" name="confirm_password" required placeholder="{{t .Lang "请再次输入新密码"}}" autocomplete="new-password">
</div>
<button type="submit" class="btn btn-primary">{{t .Lang "修改密码"}}</button>
</form>
</div>
<div class="card" style="max-width:720px;">
<h2 style="font-size:17px;margin-bottom:10px;">{{t .Lang "帮助"}}</h2>
<p style="color:var(--text-2);font-size:13.5px;line-height:1.9;"> <p style="color:var(--text-2);font-size:13.5px;line-height:1.9;">
客户端收发信(IMAP / SMTP)配置:<br> {{t .Lang "客户端收发信(IMAP / SMTP)配置:"}}<br>
IMAP 服务器:{{.currentUser.Domain.Name}} 端口 143 / SSL 993<br> {{tf .Lang "IMAP 服务器:%s 端口 %d / SSL %d" .currentUser.Domain.Name 143 993}}<br>
SMTP 服务器:{{.currentUser.Domain.Name}} 端口 587(提交)/ SSL 465 {{tf .Lang "SMTP 服务器:%s 端口 %d(提交)/ SSL %d" .currentUser.Domain.Name 587 465}}
</p> </p>
</div> </div>
</main> </main>
+18 -18
View File
@@ -1,10 +1,10 @@
{{define "view"}} {{define "view"}}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="{{htmlLang .Lang}}">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>查看邮件 - MailGo</title> <title>{{t .Lang "查看邮件"}} - MailGo</title>
{{template "styles" .}} {{template "styles" .}}
</head> </head>
<body class="page-view"> <body class="page-view">
@@ -15,50 +15,50 @@
<div class="view-toolbar"> <div class="view-toolbar">
<a href="javascript:history.back()" class="tb-btn"> <a href="javascript:history.back()" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
返回 {{t .Lang "返回"}}
</a> </a>
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="tb-btn"> <a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复 {{t .Lang "回复"}}
</a> </a>
{{if .inTrash}} {{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;"> <form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="tb-btn"> <button type="submit" class="tb-btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复 {{t .Lang "恢复"}}
</button> </button>
</form> </form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗此操作不可恢复。');"> onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗此操作不可恢复"}}');">
<button type="submit" class="tb-btn danger"> <button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除 {{t .Lang "彻底删除"}}
</button> </button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');"> onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="tb-btn danger"> <button type="submit" class="tb-btn danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除 {{t .Lang "删除"}}
</button> </button>
</form> </form>
{{end}} {{end}}
</div> </div>
<div class="mail-head"> <div class="mail-head">
<h1 class="mail-title">{{if .message.Subject}}{{.message.Subject}}{{else}}(无主题){{end}}</h1> <h1 class="mail-title">{{if .message.Subject}}{{.message.Subject}}{{else}}{{t .Lang "(无主题)"}}{{end}}</h1>
<div class="mail-from-row"> <div class="mail-from-row">
<span class="avatar" style="{{avatarStyle .message.FromAddr}}">{{initial (mailName (decodeHeader .message.FromAddr))}}</span> <span class="avatar" style="{{avatarStyle .message.FromAddr}}">{{initial (mailName (decodeHeader .message.FromAddr))}}</span>
<div> <div>
<div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div> <div class="mail-from-name">{{mailName (decodeHeader .message.FromAddr)}}</div>
<div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div> <div class="mail-from-addr" title="{{decodeHeader .message.FromAddr}}">{{mailEmail .message.FromAddr}}</div>
</div> </div>
<span class="mail-date">{{time12 .message.Date}}</span> <span class="mail-date">{{time12 .Lang .message.Date}}</span>
</div> </div>
{{if .message.CcAddr}} {{if .message.CcAddr}}
<div class="mail-from-addr" style="margin:-8px 0 16px 48px;"> <div class="mail-from-addr" style="margin:-8px 0 16px 48px;">
抄送:{{.message.CcAddr}} {{t .Lang "抄送:"}}{{.message.CcAddr}}
</div> </div>
{{end}} {{end}}
</div> </div>
@@ -88,28 +88,28 @@
<div class="view-actions"> <div class="view-actions">
<a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary"> <a href="/compose?to={{mailEmail .message.FromAddr}}&subject={{if .message.Subject}}Re: {{.message.Subject}}{{end}}" class="btn btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>
回复 {{t .Lang "回复"}}
</a> </a>
{{if .inTrash}} {{if .inTrash}}
<form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;"> <form method="POST" action="/mail/restore/{{.message.ID}}" style="display:inline;">
<button type="submit" class="btn"> <button type="submit" class="btn">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>
恢复到收件箱 {{t .Lang "恢复到收件箱"}}
</button> </button>
</form> </form>
<form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/purge/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要彻底删除这封邮件吗此操作不可恢复。');"> onsubmit="return confirm('{{t .Lang "确定要彻底删除这封邮件吗此操作不可恢复"}}');">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
彻底删除 {{t .Lang "彻底删除"}}
</button> </button>
</form> </form>
{{else}} {{else}}
<form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;" <form method="POST" action="/mail/delete/{{.message.ID}}" style="display:inline;"
onsubmit="return confirm('确定要删除这封邮件吗?');"> onsubmit="return confirm('{{t .Lang "确定要删除这封邮件吗"}}');">
<button type="submit" class="btn btn-danger"> <button type="submit" class="btn btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
删除邮件 {{t .Lang "删除邮件"}}
</button> </button>
</form> </form>
{{end}} {{end}}
+5 -5
View File
@@ -33,7 +33,7 @@ func TestTime12Format(t *testing.T) {
{"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"}, {"凌晨", time.Date(2026, 8, 20, 16, 0, 0, 0, time.UTC), "2026-08-21 上午 12:00:00"},
} }
for _, tc := range cases { for _, tc := range cases {
if got := time12(tc.in); got != tc.want { if got := time12("zh", tc.in); got != tc.want {
t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want) t.Errorf("time12(%s) = %q, want %q", tc.name, got, tc.want)
} }
} }
@@ -42,7 +42,7 @@ func TestTime12Format(t *testing.T) {
func TestTime12mNoSeconds(t *testing.T) { func TestTime12mNoSeconds(t *testing.T) {
setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600)) setWebTZForTest(t, time.FixedZone("UTC+8", 8*3600))
in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC) in := time.Date(2026, 8, 20, 10, 30, 45, 0, time.UTC)
if got, want := time12m(in), "2026-08-20 下午 6:30"; got != want { if got, want := time12m("zh", in), "2026-08-20 下午 6:30"; got != want {
t.Fatalf("time12m = %q, want %q", got, want) t.Fatalf("time12m = %q, want %q", got, want)
} }
} }
@@ -53,7 +53,7 @@ func TestShortDate12Hour(t *testing.T) {
// 今天 → 「下午 2:35」(无日期) // 今天 → 「下午 2:35」(无日期)
today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC() today := time.Date(now.Year(), now.Month(), now.Day(), 14, 35, 0, 0, webTZ).UTC()
if got, want := shortDate(today), "下午 2:35"; got != want { if got, want := shortDate("zh", today), "下午 2:35"; got != want {
t.Fatalf("shortDate(today) = %q, want %q", got, want) t.Fatalf("shortDate(today) = %q, want %q", got, want)
} }
@@ -62,13 +62,13 @@ func TestShortDate12Hour(t *testing.T) {
if day.YearDay() == now.YearDay() { if day.YearDay() == now.YearDay() {
day = day.AddDate(0, 0, 1) day = day.AddDate(0, 0, 1)
} }
if got, want := shortDate(day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want { if got, want := shortDate("zh", day.UTC()), day.Format("01-02")+" 上午 9:05"; got != want {
t.Fatalf("shortDate(thisYear) = %q, want %q", got, want) t.Fatalf("shortDate(thisYear) = %q, want %q", got, want)
} }
// 往年 → 「YYYY-MM-DD 上午 9:05」 // 往年 → 「YYYY-MM-DD 上午 9:05」
older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC() older := time.Date(now.Year()-1, 12, 1, 9, 5, 0, 0, webTZ).UTC()
if got, want := shortDate(older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want { if got, want := shortDate("zh", older), fmt.Sprintf("%d-12-01 上午 9:05", now.Year()-1); got != want {
t.Fatalf("shortDate(older) = %q, want %q", got, want) t.Fatalf("shortDate(older) = %q, want %q", got, want)
} }
} }