二阶段差不多
This commit is contained in:
@@ -17,3 +17,21 @@
|
|||||||
- IMAP v2 beta API不稳定 → 切换v1
|
- IMAP v2 beta API不稳定 → 切换v1
|
||||||
- 模板 {{template .VarName .}} 不支持 → 重构为自包含模式
|
- 模板 {{template .VarName .}} 不支持 → 重构为自包含模式
|
||||||
- 补充 /drafts、/settings 路由及密码修改功能
|
- 补充 /drafts、/settings 路由及密码修改功能
|
||||||
|
|
||||||
|
## 增强需求实现(v2)
|
||||||
|
- DKIM私钥自动生成: 域名创建时自动生成RSA 2048密钥对,DNS提示页显示DKIM TXT记录
|
||||||
|
- 域名编辑: /admin/domains/:id/edit,可修改端口/TLS/重新生成DKIM
|
||||||
|
- 附件配额限制: 上传时检查用户QuotaBytes vs UsedBytes,SMTP收信也更新配额
|
||||||
|
- 富文本编辑器: compose页集成Quill.js(CDN),支持HTML邮件(multipart/alternative)
|
||||||
|
- OAuth2/LDAP认证: 默认关闭,配置文件控制开关;internal/auth/包(provider+ldap+oauth2)
|
||||||
|
- 新增依赖: github.com/go-ldap/ldap/v3, golang.org/x/oauth2
|
||||||
|
- go build + go vet 通过
|
||||||
|
|
||||||
|
## 增强需求实现(v3)
|
||||||
|
- Banlist系统: BanEntry模型+BanStore+BanMiddleware,登录失败N次ban IP,admin /admin/bans 查看/解ban
|
||||||
|
- 管理仪表盘增强: 邮件分布(INBOX/Sent/Drafts/Trash计数+大小)、今日/7日收发统计、ban计数
|
||||||
|
- 全量邮件查看: /admin/mails 支持文件夹筛选+分页,MailStore新增ListAll/ListAllByFolder
|
||||||
|
- Config新增: Ban BanConfig (max_fail_attempts默认5, ban_duration_min默认30分钟)
|
||||||
|
- 新建5文件: ban_store.go, ban.go中间件, banned.html, admin/bans.html, admin/mails.html
|
||||||
|
- 修改13文件: models.go, db.go, config.go, stores.go, mail_store.go, auth.go, admin.go, server.go, main.go + 5个admin模板
|
||||||
|
- go build + go vet 通过
|
||||||
|
|||||||
@@ -17,6 +17,28 @@
|
|||||||
- Windows路径回退: ./win/etc/mail_go 和 ./win/srv/mail_go
|
- Windows路径回退: ./win/etc/mail_go 和 ./win/srv/mail_go
|
||||||
- 默认管理员: admin@example.com / admin
|
- 默认管理员: admin@example.com / admin
|
||||||
|
|
||||||
|
## 增强功能(v2)
|
||||||
|
- DKIM: 创建域名时自动生成RSA 2048密钥对,DNS提示页显示DKIM TXT记录,internal/dkim/keys.go
|
||||||
|
- 域名编辑: /admin/domains/:id/edit,可改端口/TLS/重新生成DKIM
|
||||||
|
- 附件配额: 上传时检查用户QuotaBytes,同步更新UsedBytes,SMTP收信也更新
|
||||||
|
- 富文本: compose页集成Quill.js(CDN),保存纯文本+HTML到数据库,发送multipart/alternative
|
||||||
|
- OAuth2/LDAP: 默认关闭,配置文件控制;internal/auth/ 包(provider.go/ldap.go/oauth2.go)
|
||||||
|
- 依赖: github.com/go-ldap/ldap/v3, golang.org/x/oauth2
|
||||||
|
- Domain模型新增: DkimSelector, DkimPrivateKey, DkimPublicKey
|
||||||
|
- Config新增: Auth AuthConfig(OAuth2+LDAP各项配置)
|
||||||
|
|
||||||
|
## 增强功能(v3)
|
||||||
|
- Banlist系统: 登录失败N次ban IP,次数/时长后台可配(BanConfig),admin /admin/bans 查看/解ban
|
||||||
|
- BanEntry模型、BanStore、BanMiddleware、banned.html、admin/bans.html
|
||||||
|
- AuthHandler新增banCfg字段,DoLogin/LDAPLogin集成ban检查
|
||||||
|
- 配置: max_fail_attempts(默认5), ban_duration_min(默认30分钟)
|
||||||
|
- 管理仪表盘增强: 邮件分布表(INBOX/Sent/Drafts/Trash计数+大小)、今日/7日收发统计、ban计数
|
||||||
|
- MailStore新增: CountByFolder, CountAll, TotalSizeByFolder, TotalSize, CountByFolderSince
|
||||||
|
- 全量邮件查看: /admin/mails 支持文件夹筛选(INBOX/Sent/Drafts/Trash),分页
|
||||||
|
- MailStore新增: ListAll, ListAllByFolder
|
||||||
|
- admin/mails.html
|
||||||
|
- Admin sidebar统一: 控制面板/域名管理/用户管理/所有邮件/IP黑名单
|
||||||
|
|
||||||
## 已知坑
|
## 已知坑
|
||||||
- go-imap v2 是beta,API不稳定,必须用v1
|
- go-imap v2 是beta,API不稳定,必须用v1
|
||||||
- Go filepath.Glob 不支持 ** 递归匹配,模板分两轮加载
|
- Go filepath.Glob 不支持 ** 递归匹配,模板分两轮加载
|
||||||
|
|||||||
@@ -52,6 +52,31 @@ type POP3Config struct {
|
|||||||
TLSKey string `toml:"tls_key"`
|
TLSKey string `toml:"tls_key"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuthConfig holds external authentication settings (OAuth2, LDAP).
|
||||||
|
type AuthConfig struct {
|
||||||
|
// OAuth2 configuration
|
||||||
|
OAuth2Enabled bool `toml:"oauth2_enabled"`
|
||||||
|
OAuth2Provider string `toml:"oauth2_provider"` // google, github, gitlab
|
||||||
|
OAuth2ClientID string `toml:"oauth2_client_id"`
|
||||||
|
OAuth2ClientSecret string `toml:"oauth2_client_secret"`
|
||||||
|
OAuth2RedirectURL string `toml:"oauth2_redirect_url"`
|
||||||
|
|
||||||
|
// LDAP configuration
|
||||||
|
LDAPEnabled bool `toml:"ldap_enabled"`
|
||||||
|
LDAPServer string `toml:"ldap_server"` // e.g. ldap://localhost:389
|
||||||
|
LDAPBindDN string `toml:"ldap_bind_dn"` // e.g. cn=admin,dc=example,dc=com
|
||||||
|
LDAPBindPassword string `toml:"ldap_bind_password"`
|
||||||
|
LDAPSearchBase string `toml:"ldap_search_base"` // e.g. ou=users,dc=example,dc=com
|
||||||
|
LDAPSearchFilter string `toml:"ldap_search_filter"` // e.g. (uid=%s)
|
||||||
|
LDAPUseTLS bool `toml:"ldap_use_tls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BanConfig holds IP ban settings for failed login attempts.
|
||||||
|
type BanConfig struct {
|
||||||
|
MaxFailAttempts int `toml:"max_fail_attempts"` // Default: 5
|
||||||
|
BanDurationMin int `toml:"ban_duration_min"` // Default: 30 (minutes)
|
||||||
|
}
|
||||||
|
|
||||||
// Config is the top-level configuration structure.
|
// Config is the top-level configuration structure.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Database DatabaseConfig `toml:"database"`
|
Database DatabaseConfig `toml:"database"`
|
||||||
@@ -60,6 +85,8 @@ type Config struct {
|
|||||||
SMTP SMTPConfig `toml:"smtp"`
|
SMTP SMTPConfig `toml:"smtp"`
|
||||||
IMAP IMAPConfig `toml:"imap"`
|
IMAP IMAPConfig `toml:"imap"`
|
||||||
POP3 POP3Config `toml:"pop3"`
|
POP3 POP3Config `toml:"pop3"`
|
||||||
|
Auth AuthConfig `toml:"auth"`
|
||||||
|
Ban BanConfig `toml:"ban"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// isWindows returns true if the current OS is Windows.
|
// isWindows returns true if the current OS is Windows.
|
||||||
@@ -120,6 +147,14 @@ func defaultConfig() *Config {
|
|||||||
Addr: fmt.Sprintf(":%d", DefaultPOP3Port),
|
Addr: fmt.Sprintf(":%d", DefaultPOP3Port),
|
||||||
TLSAddr: fmt.Sprintf(":%d", DefaultPOP3TLSPort),
|
TLSAddr: fmt.Sprintf(":%d", DefaultPOP3TLSPort),
|
||||||
},
|
},
|
||||||
|
Auth: AuthConfig{
|
||||||
|
OAuth2Enabled: false,
|
||||||
|
LDAPEnabled: false,
|
||||||
|
},
|
||||||
|
Ban: BanConfig{
|
||||||
|
MaxFailAttempts: 5,
|
||||||
|
BanDurationMin: 30,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,6 +204,14 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config {
|
|||||||
if cfg.POP3.TLSAddr == "" {
|
if cfg.POP3.TLSAddr == "" {
|
||||||
cfg.POP3.TLSAddr = defaults.POP3.TLSAddr
|
cfg.POP3.TLSAddr = defaults.POP3.TLSAddr
|
||||||
}
|
}
|
||||||
|
// Auth defaults: no merging needed since booleans default to false
|
||||||
|
// and string fields are intentionally empty when disabled
|
||||||
|
if cfg.Ban.MaxFailAttempts == 0 {
|
||||||
|
cfg.Ban.MaxFailAttempts = defaults.Ban.MaxFailAttempts
|
||||||
|
}
|
||||||
|
if cfg.Ban.BanDurationMin == 0 {
|
||||||
|
cfg.Ban.BanDurationMin = defaults.Ban.BanDurationMin
|
||||||
|
}
|
||||||
return cfg
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0 // indirect
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
@@ -24,6 +26,8 @@ require (
|
|||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
@@ -50,6 +54,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.51.0 // indirect
|
golang.org/x/net v0.51.0 // indirect
|
||||||
|
golang.org/x/oauth2 v0.36.0 // indirect
|
||||||
golang.org/x/sys v0.41.0 // indirect
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
golang.org/x/text v0.35.0 // indirect
|
golang.org/x/text v0.35.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc=
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
||||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
@@ -30,6 +34,10 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
@@ -118,6 +126,8 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
|
|||||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"mail_go/config"
|
||||||
|
|
||||||
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LDAPProvider 实现 LDAP 认证
|
||||||
|
type LDAPProvider struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLDAPProvider 创建 LDAP 认证提供者
|
||||||
|
func NewLDAPProvider(cfg config.AuthConfig) *LDAPProvider {
|
||||||
|
return &LDAPProvider{cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 返回提供者名称
|
||||||
|
func (p *LDAPProvider) Name() string { return "ldap" }
|
||||||
|
|
||||||
|
// Authenticate 使用 LDAP 验证用户凭据,返回邮箱地址
|
||||||
|
func (p *LDAPProvider) Authenticate(credentials map[string]string) (string, error) {
|
||||||
|
username := credentials["username"]
|
||||||
|
password := credentials["password"]
|
||||||
|
if username == "" || password == "" {
|
||||||
|
return "", fmt.Errorf("用户名和密码不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 go-ldap 库连接
|
||||||
|
l, err := ldap.DialURL(p.cfg.LDAPServer)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("LDAP 连接失败: %w", err)
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
|
||||||
|
if p.cfg.LDAPUseTLS {
|
||||||
|
if err := l.StartTLS(&tls.Config{}); err != nil {
|
||||||
|
return "", fmt.Errorf("LDAP TLS 握手失败: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用绑定DN搜索用户
|
||||||
|
if err := l.Bind(p.cfg.LDAPBindDN, p.cfg.LDAPBindPassword); err != nil {
|
||||||
|
return "", fmt.Errorf("LDAP 绑定失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索用户
|
||||||
|
filter := fmt.Sprintf(p.cfg.LDAPSearchFilter, ldap.EscapeFilter(username))
|
||||||
|
searchReq := ldap.NewSearchRequest(
|
||||||
|
p.cfg.LDAPSearchBase,
|
||||||
|
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false,
|
||||||
|
filter,
|
||||||
|
[]string{"mail", "uid", "cn"},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
sr, err := l.Search(searchReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("LDAP 搜索失败: %w", err)
|
||||||
|
}
|
||||||
|
if len(sr.Entries) == 0 {
|
||||||
|
return "", fmt.Errorf("LDAP 用户不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := sr.Entries[0]
|
||||||
|
userDN := entry.DN
|
||||||
|
email := entry.GetAttributeValue("mail")
|
||||||
|
|
||||||
|
// 验证用户密码
|
||||||
|
if err := l.Bind(userDN, password); err != nil {
|
||||||
|
return "", fmt.Errorf("LDAP 认证失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有邮箱属性,尝试从搜索基准拼凑
|
||||||
|
if email == "" {
|
||||||
|
// 从 LDAPSearchBase 提取域名部分,如 ou=users,dc=example,dc=com -> example.com
|
||||||
|
parts := strings.Split(p.cfg.LDAPSearchBase, ",")
|
||||||
|
var dcParts []string
|
||||||
|
for _, part := range parts {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if strings.HasPrefix(part, "dc=") {
|
||||||
|
dcParts = append(dcParts, strings.TrimPrefix(part, "dc="))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dcParts) > 0 {
|
||||||
|
email = username + "@" + strings.Join(dcParts, ".")
|
||||||
|
} else {
|
||||||
|
email = username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"mail_go/config"
|
||||||
|
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/github"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OAuth2Provider 实现 OAuth2 认证
|
||||||
|
type OAuth2Provider struct {
|
||||||
|
cfg config.AuthConfig
|
||||||
|
config *oauth2.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOAuth2Provider 创建 OAuth2 认证提供者
|
||||||
|
func NewOAuth2Provider(cfg config.AuthConfig) *OAuth2Provider {
|
||||||
|
p := &OAuth2Provider{cfg: cfg}
|
||||||
|
|
||||||
|
var endpoint oauth2.Endpoint
|
||||||
|
switch cfg.OAuth2Provider {
|
||||||
|
case "google":
|
||||||
|
endpoint = google.Endpoint
|
||||||
|
case "github":
|
||||||
|
endpoint = github.Endpoint
|
||||||
|
default:
|
||||||
|
endpoint = oauth2.Endpoint{
|
||||||
|
AuthURL: fmt.Sprintf("https://%s/login/oauth/authorize", cfg.OAuth2Provider),
|
||||||
|
TokenURL: fmt.Sprintf("https://%s/login/oauth/access_token", cfg.OAuth2Provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.config = &oauth2.Config{
|
||||||
|
ClientID: cfg.OAuth2ClientID,
|
||||||
|
ClientSecret: cfg.OAuth2ClientSecret,
|
||||||
|
RedirectURL: cfg.OAuth2RedirectURL,
|
||||||
|
Scopes: []string{"email", "profile"},
|
||||||
|
Endpoint: endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name 返回提供者名称
|
||||||
|
func (p *OAuth2Provider) Name() string { return "oauth2" }
|
||||||
|
|
||||||
|
// GetAuthURL 返回 OAuth2 授权重定向URL
|
||||||
|
func (p *OAuth2Provider) GetAuthURL(state string) string {
|
||||||
|
return p.config.AuthCodeURL(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCallback 处理 OAuth2 回调,返回用户邮箱
|
||||||
|
func (p *OAuth2Provider) HandleCallback(code string) (string, error) {
|
||||||
|
token, err := p.config.Exchange(context.Background(), code)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("OAuth2 token 交换失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := p.fetchUserEmail(token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchUserEmail 从 OAuth2 提供者获取用户邮箱
|
||||||
|
func (p *OAuth2Provider) fetchUserEmail(token *oauth2.Token) (string, error) {
|
||||||
|
client := p.config.Client(context.Background(), token)
|
||||||
|
|
||||||
|
var userinfoURL string
|
||||||
|
switch p.cfg.OAuth2Provider {
|
||||||
|
case "google":
|
||||||
|
userinfoURL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
||||||
|
case "github":
|
||||||
|
userinfoURL = "https://api.github.com/user/emails"
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("不支持的 OAuth2 提供者: %s", p.cfg.OAuth2Provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(userinfoURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("获取用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("读取用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cfg.OAuth2Provider == "google" {
|
||||||
|
var userInfo struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &userInfo); err != nil {
|
||||||
|
return "", fmt.Errorf("解析Google用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
if userInfo.Email == "" {
|
||||||
|
return "", fmt.Errorf("Google账户未关联邮箱")
|
||||||
|
}
|
||||||
|
return userInfo.Email, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.cfg.OAuth2Provider == "github" {
|
||||||
|
var emails []struct {
|
||||||
|
Email string `json:"email"`
|
||||||
|
Primary bool `json:"primary"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &emails); err != nil {
|
||||||
|
return "", fmt.Errorf("解析GitHub用户信息失败: %w", err)
|
||||||
|
}
|
||||||
|
for _, e := range emails {
|
||||||
|
if e.Primary {
|
||||||
|
return e.Email, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(emails) > 0 {
|
||||||
|
return emails[0].Email, nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("GitHub账户未关联邮箱")
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", fmt.Errorf("OAuth2 邮箱获取尚未实现")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate 实现 Provider 接口(OAuth2 不使用此方法,通过回调流程认证)
|
||||||
|
func (p *OAuth2Provider) Authenticate(credentials map[string]string) (string, error) {
|
||||||
|
return "", fmt.Errorf("OAuth2 不支持直接认证,请使用回调流程")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure interfaces are satisfied
|
||||||
|
var (
|
||||||
|
_ Provider = (*LDAPProvider)(nil)
|
||||||
|
_ Provider = (*OAuth2Provider)(nil)
|
||||||
|
)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
// Provider 定义外部认证接口
|
||||||
|
type Provider interface {
|
||||||
|
// Authenticate 验证外部凭据,返回邮箱地址
|
||||||
|
Authenticate(credentials map[string]string) (email string, err error)
|
||||||
|
// Name 返回提供者名称
|
||||||
|
Name() string
|
||||||
|
}
|
||||||
+1
-1
@@ -46,7 +46,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-migrate all models
|
// Auto-migrate all models
|
||||||
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}); err != nil {
|
if err := db.AutoMigrate(&User{}, &Domain{}, &Message{}, &Attachment{}, &BanEntry{}); err != nil {
|
||||||
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
return nil, fmt.Errorf("数据库迁移失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+27
-10
@@ -26,16 +26,19 @@ func (User) TableName() string {
|
|||||||
|
|
||||||
// Domain represents a mail domain in the system.
|
// Domain represents a mail domain in the system.
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
|
Name string `gorm:"size:255;uniqueIndex;not null" json:"name"`
|
||||||
SmtpPort int `gorm:"default:25" json:"smtp_port"`
|
SmtpPort int `gorm:"default:25" json:"smtp_port"`
|
||||||
ImapPort int `gorm:"default:143" json:"imap_port"`
|
ImapPort int `gorm:"default:143" json:"imap_port"`
|
||||||
Pop3Port int `gorm:"default:110" json:"pop3_port"`
|
Pop3Port int `gorm:"default:110" json:"pop3_port"`
|
||||||
TlsCertPath string `gorm:"size:512" json:"tls_cert_path"`
|
TlsCertPath string `gorm:"size:512" json:"tls_cert_path"`
|
||||||
TlsKeyPath string `gorm:"size:512" json:"tls_key_path"`
|
TlsKeyPath string `gorm:"size:512" json:"tls_key_path"`
|
||||||
TlsEnabled bool `gorm:"default:false" json:"tls_enabled"`
|
TlsEnabled bool `gorm:"default:false" json:"tls_enabled"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
DkimSelector string `gorm:"size:64;default:default" json:"dkim_selector"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
DkimPrivateKey string `gorm:"size:4096" json:"-"`
|
||||||
|
DkimPublicKey string `gorm:"size:1024" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TableName specifies the table name for Domain.
|
// TableName specifies the table name for Domain.
|
||||||
@@ -67,6 +70,20 @@ func (Message) TableName() string {
|
|||||||
return "messages"
|
return "messages"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BanEntry represents an IP address that has been banned due to excessive login failures.
|
||||||
|
type BanEntry struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
IPAddress string `gorm:"size:45;index;not null" json:"ip_address"`
|
||||||
|
Reason string `gorm:"size:255" json:"reason"`
|
||||||
|
FailCount int `gorm:"default:0" json:"fail_count"`
|
||||||
|
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName specifies the table name for BanEntry.
|
||||||
|
func (BanEntry) TableName() string { return "ban_entries" }
|
||||||
|
|
||||||
// Attachment represents a file attached to an email message.
|
// Attachment represents a file attached to an email message.
|
||||||
type Attachment struct {
|
type Attachment struct {
|
||||||
ID uint `gorm:"primaryKey" json:"id"`
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package dkim
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateKeyPair 生成 2048 位 RSA 密钥对,返回 PEM 编码的私钥和公钥
|
||||||
|
func GenerateKeyPair() (privateKeyPEM, publicKeyPEM string, err error) {
|
||||||
|
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("生成RSA密钥对失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
privBytes := x509.MarshalPKCS1PrivateKey(key)
|
||||||
|
privPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: privBytes})
|
||||||
|
|
||||||
|
pubBytes, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("编码公钥失败: %w", err)
|
||||||
|
}
|
||||||
|
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubBytes})
|
||||||
|
|
||||||
|
return string(privPEM), string(pubPEM), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDKIMDNSRecord 生成 DKIM DNS TXT 记录值
|
||||||
|
// 格式: v=DKIM1; k=rsa; p=<base64公钥>
|
||||||
|
func GetDKIMDNSRecord(publicKeyPEM string) string {
|
||||||
|
if publicKeyPEM == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
block, _ := pem.Decode([]byte(publicKeyPEM))
|
||||||
|
if block == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("v=DKIM1; k=rsa; p=%s", base64.StdEncoding.EncodeToString(block.Bytes))
|
||||||
|
}
|
||||||
@@ -279,6 +279,8 @@ func (s *smtpSession) Data(r io.Reader) error {
|
|||||||
if attErr := s.backend.server.stores.Attachments.Create(&attCopy); attErr != nil {
|
if attErr := s.backend.server.stores.Attachments.Create(&attCopy); attErr != nil {
|
||||||
log.Printf("SMTP: failed to create attachment record: %v", attErr)
|
log.Printf("SMTP: failed to create attachment record: %v", attErr)
|
||||||
}
|
}
|
||||||
|
// Update user used bytes for received attachments
|
||||||
|
_ = s.backend.server.stores.Users.UpdateUsedBytes(user.ID, attCopy.FileSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("SMTP: message delivered to %s (ID=%d)", rcpt, msg.ID)
|
log.Printf("SMTP: message delivered to %s (ID=%d)", rcpt, msg.ID)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mail_go/internal/db"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BanStore defines the interface for IP ban operations.
|
||||||
|
type BanStore interface {
|
||||||
|
Create(entry *db.BanEntry) error
|
||||||
|
GetByIP(ip string) (*db.BanEntry, error)
|
||||||
|
Delete(id uint) error
|
||||||
|
List(page, size int) ([]db.BanEntry, int64, error)
|
||||||
|
IsBanned(ip string) (bool, *db.BanEntry)
|
||||||
|
IncrementFail(ip string) (int, error)
|
||||||
|
ResetFail(ip string) error
|
||||||
|
Cleanup() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// banStoreGorm implements BanStore using GORM.
|
||||||
|
type banStoreGorm struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBanStore creates a new GORM-backed BanStore.
|
||||||
|
func newBanStore(database *gorm.DB) BanStore {
|
||||||
|
return &banStoreGorm{db: database}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create inserts a new ban entry record.
|
||||||
|
func (s *banStoreGorm) Create(entry *db.BanEntry) error {
|
||||||
|
return s.db.Create(entry).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByIP retrieves the most recent ban entry for a given IP address.
|
||||||
|
func (s *banStoreGorm) GetByIP(ip string) (*db.BanEntry, error) {
|
||||||
|
var entry db.BanEntry
|
||||||
|
if err := s.db.Where("ip_address = ?", ip).Order("id DESC").First(&entry).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes a ban entry by ID.
|
||||||
|
func (s *banStoreGorm) Delete(id uint) error {
|
||||||
|
return s.db.Delete(&db.BanEntry{}, id).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// List retrieves a paginated list of ban entries.
|
||||||
|
func (s *banStoreGorm) List(page, size int) ([]db.BanEntry, int64, error) {
|
||||||
|
var entries []db.BanEntry
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
if err := s.db.Model(&db.BanEntry{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * size
|
||||||
|
if err := s.db.Order("id DESC").Offset(offset).Limit(size).Find(&entries).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return entries, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBanned checks whether an IP address is currently banned.
|
||||||
|
// An IP is considered banned if there is a record with expires_at in the future.
|
||||||
|
func (s *banStoreGorm) IsBanned(ip string) (bool, *db.BanEntry) {
|
||||||
|
var entry db.BanEntry
|
||||||
|
if err := s.db.Where("ip_address = ? AND expires_at > ?", ip, time.Now()).First(&entry).Error; err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return true, &entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrementFail increments the fail count for an IP address.
|
||||||
|
// If no record exists, it creates one with fail_count=1 and a zero expires_at.
|
||||||
|
// Returns the updated fail count.
|
||||||
|
func (s *banStoreGorm) IncrementFail(ip string) (int, error) {
|
||||||
|
var entry db.BanEntry
|
||||||
|
err := s.db.Where("ip_address = ?", ip).First(&entry).Error
|
||||||
|
if err != nil {
|
||||||
|
// No record exists, create a new one
|
||||||
|
entry = db.BanEntry{
|
||||||
|
IPAddress: ip,
|
||||||
|
FailCount: 1,
|
||||||
|
ExpiresAt: time.Time{}, // Zero time, not yet banned
|
||||||
|
}
|
||||||
|
if createErr := s.db.Create(&entry).Error; createErr != nil {
|
||||||
|
return 0, createErr
|
||||||
|
}
|
||||||
|
return 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record exists, increment fail count
|
||||||
|
newCount := entry.FailCount + 1
|
||||||
|
if updateErr := s.db.Model(&entry).Update("fail_count", newCount).Error; updateErr != nil {
|
||||||
|
return 0, updateErr
|
||||||
|
}
|
||||||
|
return newCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetFail resets the fail count for an IP address by deleting its record.
|
||||||
|
func (s *banStoreGorm) ResetFail(ip string) error {
|
||||||
|
return s.db.Where("ip_address = ?", ip).Delete(&db.BanEntry{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup removes expired ban entries.
|
||||||
|
// It deletes records where expires_at is in the past and is not zero
|
||||||
|
// (preserving records that have fail counts but are not yet banned).
|
||||||
|
func (s *banStoreGorm) Cleanup() error {
|
||||||
|
return s.db.Where("expires_at < ? AND expires_at > ?", time.Now(), time.Time{}).Delete(&db.BanEntry{}).Error
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package store
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
|
||||||
@@ -28,6 +29,13 @@ type MailStore interface {
|
|||||||
MoveToFolder(id uint, folder string) error
|
MoveToFolder(id uint, folder string) error
|
||||||
Delete(id uint) error
|
Delete(id uint) error
|
||||||
CountUnread(userID uint, folder string) (int64, error)
|
CountUnread(userID uint, folder string) (int64, error)
|
||||||
|
CountByFolder(folder string) (int64, error)
|
||||||
|
CountAll() (int64, error)
|
||||||
|
TotalSizeByFolder(folder string) (int64, error)
|
||||||
|
TotalSize() (int64, error)
|
||||||
|
CountByFolderSince(folder string, since time.Time) (int64, error)
|
||||||
|
ListAll(page, size int) ([]db.Message, int64, error)
|
||||||
|
ListAllByFolder(folder string, page, size int) ([]db.Message, int64, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mailStoreGorm implements MailStore using GORM.
|
// mailStoreGorm implements MailStore using GORM.
|
||||||
@@ -123,3 +131,90 @@ func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64,
|
|||||||
}
|
}
|
||||||
return count, nil
|
return count, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountByFolder returns the total count of messages in a given folder.
|
||||||
|
func (s *mailStoreGorm) CountByFolder(folder string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&db.Message{}).Where("folder = ?", folder).Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountAll returns the total count of all messages.
|
||||||
|
func (s *mailStoreGorm) CountAll() (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&db.Message{}).Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalSizeByFolder returns the total size (in bytes) of message bodies in a given folder.
|
||||||
|
func (s *mailStoreGorm) TotalSizeByFolder(folder string) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := s.db.Model(&db.Message{}).
|
||||||
|
Where("folder = ?", folder).
|
||||||
|
Select("COALESCE(SUM(LENGTH(text_body) + LENGTH(html_body)), 0)").
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TotalSize returns the total size (in bytes) of all message bodies.
|
||||||
|
func (s *mailStoreGorm) TotalSize() (int64, error) {
|
||||||
|
var total int64
|
||||||
|
err := s.db.Model(&db.Message{}).
|
||||||
|
Select("COALESCE(SUM(LENGTH(text_body) + LENGTH(html_body)), 0)").
|
||||||
|
Scan(&total).Error
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountByFolderSince returns the count of messages in a folder since a given time.
|
||||||
|
func (s *mailStoreGorm) CountByFolderSince(folder string, since time.Time) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.Model(&db.Message{}).
|
||||||
|
Where("folder = ? AND created_at >= ?", folder, since).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAll retrieves a paginated list of all messages across all users.
|
||||||
|
func (s *mailStoreGorm) ListAll(page, size int) ([]db.Message, int64, error) {
|
||||||
|
var messages []db.Message
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
if err := s.db.Model(&db.Message{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * size
|
||||||
|
if err := s.db.Preload("User").Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return messages, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllByFolder retrieves a paginated list of all messages in a given folder across all users.
|
||||||
|
func (s *mailStoreGorm) ListAllByFolder(folder string, page, size int) ([]db.Message, int64, error) {
|
||||||
|
var messages []db.Message
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
query := s.db.Where("folder = ?", folder)
|
||||||
|
if err := query.Model(&db.Message{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (page - 1) * size
|
||||||
|
if err := s.db.Preload("User").Where("folder = ?", folder).Order("date DESC").Offset(offset).Limit(size).Find(&messages).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return messages, total, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ type Stores struct {
|
|||||||
Mails MailStore
|
Mails MailStore
|
||||||
Domains DomainStore
|
Domains DomainStore
|
||||||
Attachments AttachmentStore
|
Attachments AttachmentStore
|
||||||
|
Bans BanStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
// NewStores creates a new Stores instance with all GORM-backed implementations.
|
||||||
@@ -21,6 +22,7 @@ func NewStores(database *gorm.DB) *Stores {
|
|||||||
Mails: newMailStore(database),
|
Mails: newMailStore(database),
|
||||||
Domains: newDomainStore(database),
|
Domains: newDomainStore(database),
|
||||||
Attachments: newAttachmentStore(database),
|
Attachments: newAttachmentStore(database),
|
||||||
|
Bans: newBanStore(database),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,3 +31,4 @@ var _ = db.User{}
|
|||||||
var _ = db.Domain{}
|
var _ = db.Domain{}
|
||||||
var _ = db.Message{}
|
var _ = db.Message{}
|
||||||
var _ = db.Attachment{}
|
var _ = db.Attachment{}
|
||||||
|
var _ = db.BanEntry{}
|
||||||
|
|||||||
+239
-17
@@ -2,10 +2,13 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"mail_go/internal/db"
|
"mail_go/internal/db"
|
||||||
|
"mail_go/internal/dkim"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -27,13 +30,55 @@ func (h *AdminHandler) Dashboard(c *gin.Context) {
|
|||||||
_, domainCount, _ := h.stores.Domains.List(1, 1)
|
_, domainCount, _ := h.stores.Domains.List(1, 1)
|
||||||
_, userCount, _ := h.stores.Users.ListAll(1, 1)
|
_, userCount, _ := h.stores.Users.ListAll(1, 1)
|
||||||
|
|
||||||
|
// Mail statistics
|
||||||
|
totalMails, _ := h.stores.Mails.CountAll()
|
||||||
|
inboxCount, _ := h.stores.Mails.CountByFolder("INBOX")
|
||||||
|
sentCount, _ := h.stores.Mails.CountByFolder("Sent")
|
||||||
|
draftsCount, _ := h.stores.Mails.CountByFolder("Drafts")
|
||||||
|
trashCount, _ := h.stores.Mails.CountByFolder("Trash")
|
||||||
|
|
||||||
|
totalSize, _ := h.stores.Mails.TotalSize()
|
||||||
|
inboxSize, _ := h.stores.Mails.TotalSizeByFolder("INBOX")
|
||||||
|
sentSize, _ := h.stores.Mails.TotalSizeByFolder("Sent")
|
||||||
|
|
||||||
|
// Today and weekly statistics
|
||||||
|
todayStart := time.Now().Truncate(24 * time.Hour)
|
||||||
|
weekStart := time.Now().AddDate(0, 0, -7)
|
||||||
|
|
||||||
|
todayReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", todayStart)
|
||||||
|
todaySent, _ := h.stores.Mails.CountByFolderSince("Sent", todayStart)
|
||||||
|
weekReceived, _ := h.stores.Mails.CountByFolderSince("INBOX", weekStart)
|
||||||
|
weekSent, _ := h.stores.Mails.CountByFolderSince("Sent", weekStart)
|
||||||
|
|
||||||
|
// Ban count: number of currently banned IPs
|
||||||
|
bans, _, _ := h.stores.Bans.List(1, 1000)
|
||||||
|
var banCount int64
|
||||||
|
for _, b := range bans {
|
||||||
|
if b.ExpiresAt.After(time.Now()) {
|
||||||
|
banCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
c.HTML(200, "admin_dashboard", gin.H{
|
c.HTML(200, "admin_dashboard", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"domainCount": domainCount,
|
"domainCount": domainCount,
|
||||||
"userCount": userCount,
|
"userCount": userCount,
|
||||||
"activeFolder": "admin",
|
"totalMails": totalMails,
|
||||||
|
"inboxCount": inboxCount,
|
||||||
|
"sentCount": sentCount,
|
||||||
|
"draftsCount": draftsCount,
|
||||||
|
"trashCount": trashCount,
|
||||||
|
"totalSize": totalSize,
|
||||||
|
"inboxSize": inboxSize,
|
||||||
|
"sentSize": sentSize,
|
||||||
|
"todayReceived": todayReceived,
|
||||||
|
"todaySent": todaySent,
|
||||||
|
"weekReceived": weekReceived,
|
||||||
|
"weekSent": weekSent,
|
||||||
|
"banCount": banCount,
|
||||||
|
"activeFolder": "admin",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +108,7 @@ func (h *AdminHandler) ListDomains(c *gin.Context) {
|
|||||||
"page": page,
|
"page": page,
|
||||||
"pageSize": 20,
|
"pageSize": 20,
|
||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "domains",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +118,7 @@ func (h *AdminHandler) NewDomain(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(200, "admin_domain_form", gin.H{
|
c.HTML(200, "admin_domain_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "domains",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": &db.Domain{},
|
"domain": &db.Domain{},
|
||||||
@@ -92,7 +137,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "domains",
|
||||||
"error": "请输入域名",
|
"error": "请输入域名",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": &db.Domain{
|
"domain": &db.Domain{
|
||||||
@@ -114,11 +159,21 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
TlsEnabled: tlsEnabled,
|
TlsEnabled: tlsEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 自动生成 DKIM 密钥对
|
||||||
|
privKey, pubKey, err := dkim.GenerateKeyPair()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("DKIM密钥生成失败: %v", err)
|
||||||
|
} else {
|
||||||
|
domain.DkimSelector = "default"
|
||||||
|
domain.DkimPrivateKey = privKey
|
||||||
|
domain.DkimPublicKey = pubKey
|
||||||
|
}
|
||||||
|
|
||||||
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", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "domains",
|
||||||
"error": fmt.Sprintf("创建域名失败: %v", err),
|
"error": fmt.Sprintf("创建域名失败: %v", err),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
@@ -129,6 +184,74 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/admin/domains")
|
c.Redirect(http.StatusFound, "/admin/domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EditDomain 渲染编辑域名表单
|
||||||
|
func (h *AdminHandler) EditDomain(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "域名不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
c.HTML(200, "admin_domain_form", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"activeFolder": "domains",
|
||||||
|
"error": "",
|
||||||
|
"isEdit": true,
|
||||||
|
"domain": domain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDomain 处理编辑域名表单提交
|
||||||
|
func (h *AdminHandler) UpdateDomain(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "无效的域名ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain, err := h.stores.Domains.GetByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusNotFound, "域名不存在")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
domain.SmtpPort = formIntOrDefault(c, "smtp_port", domain.SmtpPort)
|
||||||
|
domain.ImapPort = formIntOrDefault(c, "imap_port", domain.ImapPort)
|
||||||
|
domain.Pop3Port = formIntOrDefault(c, "pop3_port", domain.Pop3Port)
|
||||||
|
domain.TlsEnabled = c.PostForm("tls_enabled") == "on"
|
||||||
|
|
||||||
|
// 重新生成DKIM
|
||||||
|
if c.PostForm("regenerate_dkim") == "on" {
|
||||||
|
privKey, pubKey, err := dkim.GenerateKeyPair()
|
||||||
|
if err == nil {
|
||||||
|
domain.DkimPrivateKey = privKey
|
||||||
|
domain.DkimPublicKey = pubKey
|
||||||
|
domain.DkimSelector = "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Domains.Update(domain); err != nil {
|
||||||
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"activeFolder": "domains",
|
||||||
|
"error": fmt.Sprintf("更新域名失败: %v", err),
|
||||||
|
"isEdit": true,
|
||||||
|
"domain": domain,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/domains")
|
||||||
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -163,8 +286,9 @@ func (h *AdminHandler) DNSHint(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(200, "admin_dns_hint", gin.H{
|
c.HTML(200, "admin_dns_hint", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "domains",
|
||||||
"domain": domain,
|
"domain": domain,
|
||||||
|
"dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,7 +323,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) {
|
|||||||
"page": page,
|
"page": page,
|
||||||
"pageSize": 20,
|
"pageSize": 20,
|
||||||
"totalPages": totalPages,
|
"totalPages": totalPages,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +335,7 @@ func (h *AdminHandler) NewUser(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(200, "admin_user_form", gin.H{
|
c.HTML(200, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -232,7 +356,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": "请填写所有必填字段",
|
"error": "请填写所有必填字段",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -252,7 +376,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": "密码加密失败",
|
"error": "密码加密失败",
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -282,7 +406,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": fmt.Sprintf("创建用户失败: %v", err),
|
"error": fmt.Sprintf("创建用户失败: %v", err),
|
||||||
"isEdit": false,
|
"isEdit": false,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -335,7 +459,7 @@ func (h *AdminHandler) EditUser(c *gin.Context) {
|
|||||||
|
|
||||||
c.HTML(200, "admin_user_form", gin.H{
|
c.HTML(200, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": "",
|
"error": "",
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -380,7 +504,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": "密码加密失败",
|
"error": "密码加密失败",
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -396,7 +520,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "admin",
|
"activeFolder": "users",
|
||||||
"error": fmt.Sprintf("更新用户失败: %v", err),
|
"error": fmt.Sprintf("更新用户失败: %v", err),
|
||||||
"isEdit": true,
|
"isEdit": true,
|
||||||
"domains": domains,
|
"domains": domains,
|
||||||
@@ -408,6 +532,104 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) {
|
|||||||
c.Redirect(http.StatusFound, "/admin/users")
|
c.Redirect(http.StatusFound, "/admin/users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListBans renders the IP ban list page.
|
||||||
|
func (h *AdminHandler) ListBans(c *gin.Context) {
|
||||||
|
// Clean up expired entries first
|
||||||
|
h.stores.Bans.Cleanup()
|
||||||
|
|
||||||
|
page := getPageParam(c, "page", 1)
|
||||||
|
|
||||||
|
bans, total, err := h.stores.Bans.List(page, 20)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "加载黑名单失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
|
totalPages := int(total) / 20
|
||||||
|
if int(total)%20 > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(200, "admin_bans", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"bans": bans,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": 20,
|
||||||
|
"totalPages": totalPages,
|
||||||
|
"activeFolder": "bans",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbanIP removes a ban entry by ID.
|
||||||
|
func (h *AdminHandler) UnbanIP(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusBadRequest, "无效的记录ID")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.stores.Bans.Delete(uint(id)); err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "解封失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(http.StatusFound, "/admin/bans")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupBans removes all expired ban entries.
|
||||||
|
func (h *AdminHandler) CleanupBans(c *gin.Context) {
|
||||||
|
h.stores.Bans.Cleanup()
|
||||||
|
c.Redirect(http.StatusFound, "/admin/bans")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMails renders the admin mail list page showing all messages across all users.
|
||||||
|
func (h *AdminHandler) ListMails(c *gin.Context) {
|
||||||
|
page := getPageParam(c, "page", 1)
|
||||||
|
folder := c.Query("folder")
|
||||||
|
|
||||||
|
var messages []db.Message
|
||||||
|
var total int64
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if folder != "" {
|
||||||
|
messages, total, err = h.stores.Mails.ListAllByFolder(folder, page, 20)
|
||||||
|
} else {
|
||||||
|
messages, total, err = h.stores.Mails.ListAll(page, 20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
c.String(http.StatusInternalServerError, "加载邮件列表失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
|
totalPages := int(total) / 20
|
||||||
|
if int(total)%20 > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(200, "admin_mails", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"messages": messages,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pageSize": 20,
|
||||||
|
"totalPages": totalPages,
|
||||||
|
"folder": folder,
|
||||||
|
"activeFolder": "mails",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
// formIntOrDefault extracts an integer from a form field, returning the default if missing/invalid.
|
||||||
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
func formIntOrDefault(c *gin.Context, key string, defaultVal int) int {
|
||||||
val := c.PostForm(key)
|
val := c.PostForm(key)
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"mail_go/config"
|
||||||
|
"mail_go/internal/auth"
|
||||||
|
"mail_go/internal/db"
|
||||||
"mail_go/internal/store"
|
"mail_go/internal/store"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AuthHandler handles authentication-related routes (login, logout).
|
// AuthHandler handles authentication-related routes (login, logout, LDAP, OAuth2).
|
||||||
type AuthHandler struct {
|
type AuthHandler struct {
|
||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
|
authCfg config.AuthConfig
|
||||||
|
banCfg config.BanConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthHandler creates a new AuthHandler with the given stores.
|
// NewAuthHandler creates a new AuthHandler with the given stores, auth config, and ban config.
|
||||||
func NewAuthHandler(stores *store.Stores) *AuthHandler {
|
func NewAuthHandler(stores *store.Stores, authCfg config.AuthConfig, banCfg config.BanConfig) *AuthHandler {
|
||||||
return &AuthHandler{stores: stores}
|
return &AuthHandler{stores: stores, authCfg: authCfg, banCfg: banCfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShowLogin renders the login page.
|
// ShowLogin renders the login page.
|
||||||
@@ -26,7 +36,10 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "",
|
"error": "",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,20 +47,239 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) {
|
|||||||
// It authenticates the user with email and password, sets session data
|
// It authenticates the user with email and password, sets session data
|
||||||
// on success, or re-renders the login page with an error on failure.
|
// on success, or re-renders the login page with an error on failure.
|
||||||
func (h *AuthHandler) DoLogin(c *gin.Context) {
|
func (h *AuthHandler) DoLogin(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
|
||||||
|
// Check if IP is banned
|
||||||
|
banned, entry := h.stores.Bans.IsBanned(ip)
|
||||||
|
if banned {
|
||||||
|
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
email := c.PostForm("email")
|
email := c.PostForm("email")
|
||||||
password := c.PostForm("password")
|
password := c.PostForm("password")
|
||||||
|
|
||||||
if email == "" || password == "" {
|
if email == "" || password == "" {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "请输入邮箱和密码",
|
"error": "请输入邮箱和密码",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.stores.Users.Authenticate(email, password)
|
user, err := h.stores.Users.Authenticate(email, password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
failCount, _ := h.stores.Bans.IncrementFail(ip)
|
||||||
|
|
||||||
|
if failCount >= h.banCfg.MaxFailAttempts {
|
||||||
|
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
|
||||||
|
banEntry := &db.BanEntry{
|
||||||
|
IPAddress: ip,
|
||||||
|
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
|
||||||
|
FailCount: failCount,
|
||||||
|
ExpiresAt: time.Now().Add(banDuration),
|
||||||
|
}
|
||||||
|
h.stores.Bans.Create(banEntry)
|
||||||
|
|
||||||
|
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := h.banCfg.MaxFailAttempts - failCount
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "用户名或密码错误",
|
"error": fmt.Sprintf("用户名或密码错误,还剩 %d 次尝试机会", remaining),
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login successful: reset fail count
|
||||||
|
h.stores.Bans.ResetFail(ip)
|
||||||
|
|
||||||
|
// Set session values
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("userID", user.ID)
|
||||||
|
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||||
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "会话保存失败,请重试",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(302, "/inbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// LDAPLogin handles LDAP authentication form submission.
|
||||||
|
func (h *AuthHandler) LDAPLogin(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
|
||||||
|
// Check if IP is banned
|
||||||
|
banned, entry := h.stores.Bans.IsBanned(ip)
|
||||||
|
if banned {
|
||||||
|
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": entry})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := c.PostForm("username")
|
||||||
|
password := c.PostForm("password")
|
||||||
|
|
||||||
|
if username == "" || password == "" {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "请输入LDAP用户名和密码",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := auth.NewLDAPProvider(h.authCfg)
|
||||||
|
email, err := provider.Authenticate(map[string]string{
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("LDAP 认证失败: %v", err)
|
||||||
|
|
||||||
|
failCount, _ := h.stores.Bans.IncrementFail(ip)
|
||||||
|
if failCount >= h.banCfg.MaxFailAttempts {
|
||||||
|
banDuration := time.Duration(h.banCfg.BanDurationMin) * time.Minute
|
||||||
|
banEntry := &db.BanEntry{
|
||||||
|
IPAddress: ip,
|
||||||
|
Reason: fmt.Sprintf("登录失败次数过多 (%d次)", failCount),
|
||||||
|
FailCount: failCount,
|
||||||
|
ExpiresAt: time.Now().Add(banDuration),
|
||||||
|
}
|
||||||
|
h.stores.Bans.Create(banEntry)
|
||||||
|
|
||||||
|
c.HTML(http.StatusForbidden, "banned", gin.H{"entry": banEntry})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := h.banCfg.MaxFailAttempts - failCount
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": fmt.Sprintf("LDAP 认证失败,还剩 %d 次尝试机会: %v", remaining, err),
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up or auto-create user by email
|
||||||
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": fmt.Sprintf("LDAP 用户 %s 在系统中不存在", email),
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "用户已被禁用",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login successful: reset fail count
|
||||||
|
h.stores.Bans.ResetFail(ip)
|
||||||
|
|
||||||
|
// Set session values
|
||||||
|
session := sessions.Default(c)
|
||||||
|
session.Set("userID", user.ID)
|
||||||
|
session.Set("userEmail", user.Username+"@"+user.Domain.Name)
|
||||||
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
|
if err := session.Save(); err != nil {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "会话保存失败,请重试",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Redirect(302, "/inbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Start redirects to the OAuth2 provider's authorization page.
|
||||||
|
func (h *AuthHandler) OAuth2Start(c *gin.Context) {
|
||||||
|
if !h.authCfg.OAuth2Enabled {
|
||||||
|
c.String(http.StatusBadRequest, "OAuth2 未启用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := auth.NewOAuth2Provider(h.authCfg)
|
||||||
|
// Use a simple state for CSRF protection (in production, use a random token)
|
||||||
|
state := "mailgo_oauth2_state"
|
||||||
|
c.Redirect(http.StatusFound, provider.GetAuthURL(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OAuth2Callback handles the OAuth2 provider's callback after user authorization.
|
||||||
|
func (h *AuthHandler) OAuth2Callback(c *gin.Context) {
|
||||||
|
if !h.authCfg.OAuth2Enabled {
|
||||||
|
c.String(http.StatusBadRequest, "OAuth2 未启用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
code := c.Query("code")
|
||||||
|
if code == "" {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "OAuth2 授权码缺失",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
provider := auth.NewOAuth2Provider(h.authCfg)
|
||||||
|
email, err := provider.HandleCallback(code)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("OAuth2 回调失败: %v", err)
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": fmt.Sprintf("OAuth2 认证失败: %v", err),
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up user by email
|
||||||
|
user, err := h.stores.Users.GetByEmail(email)
|
||||||
|
if err != nil {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": fmt.Sprintf("OAuth2 用户 %s 在系统中不存在", email),
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !user.IsActive {
|
||||||
|
c.HTML(200, "login", gin.H{
|
||||||
|
"error": "用户已被禁用",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -59,7 +291,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) {
|
|||||||
session.Set("isAdmin", user.IsAdmin)
|
session.Set("isAdmin", user.IsAdmin)
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
c.HTML(200, "login", gin.H{
|
c.HTML(200, "login", gin.H{
|
||||||
"error": "会话保存失败,请重试",
|
"error": "会话保存失败,请重试",
|
||||||
|
"oauth2Enabled": h.authCfg.OAuth2Enabled,
|
||||||
|
"ldapEnabled": h.authCfg.LDAPEnabled,
|
||||||
|
"oauth2Provider": h.authCfg.OAuth2Provider,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,14 +108,27 @@ func (h *MailHandler) View(c *gin.Context) {
|
|||||||
|
|
||||||
// Compose renders the email composition page.
|
// Compose renders the email composition page.
|
||||||
func (h *MailHandler) Compose(c *gin.Context) {
|
func (h *MailHandler) Compose(c *gin.Context) {
|
||||||
|
userID := c.GetUint("userID")
|
||||||
currentUser, _ := c.Get("currentUser")
|
currentUser, _ := c.Get("currentUser")
|
||||||
|
|
||||||
|
// Get user quota info for display
|
||||||
|
user, _ := h.stores.Users.GetByID(userID)
|
||||||
|
var usedBytes int64
|
||||||
|
var quotaBytes int64
|
||||||
|
if user != nil {
|
||||||
|
usedBytes = user.UsedBytes
|
||||||
|
quotaBytes = user.QuotaBytes
|
||||||
|
}
|
||||||
|
|
||||||
c.HTML(200, "compose", gin.H{
|
c.HTML(200, "compose", gin.H{
|
||||||
"currentUser": currentUser,
|
"currentUser": currentUser,
|
||||||
"activeFolder": "compose",
|
"activeFolder": "compose",
|
||||||
"error": "",
|
"error": "",
|
||||||
"to": c.Query("to"),
|
"to": c.Query("to"),
|
||||||
"subject": c.Query("subject"),
|
"subject": c.Query("subject"),
|
||||||
|
"bodyContent": "",
|
||||||
|
"usedBytes": usedBytes,
|
||||||
|
"quotaBytes": quotaBytes,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +142,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
to := c.PostForm("to")
|
to := c.PostForm("to")
|
||||||
subject := c.PostForm("subject")
|
subject := c.PostForm("subject")
|
||||||
body := c.PostForm("body")
|
body := c.PostForm("body")
|
||||||
|
htmlBody := c.PostForm("html_body")
|
||||||
cc := c.PostForm("cc")
|
cc := c.PostForm("cc")
|
||||||
|
|
||||||
if to == "" {
|
if to == "" {
|
||||||
@@ -139,10 +153,43 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"cc": cc,
|
"cc": cc,
|
||||||
|
"bodyContent": htmlBody,
|
||||||
|
"usedBytes": currentUser.UsedBytes,
|
||||||
|
"quotaBytes": currentUser.QuotaBytes,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle attachments and check quota
|
||||||
|
form, multipartErr := c.MultipartForm()
|
||||||
|
if multipartErr == nil {
|
||||||
|
files := form.File["attachments"]
|
||||||
|
if len(files) > 0 {
|
||||||
|
// Check attachment quota before saving
|
||||||
|
user, _ := h.stores.Users.GetByID(userID)
|
||||||
|
if user != nil {
|
||||||
|
var totalNewSize int64
|
||||||
|
for _, file := range files {
|
||||||
|
totalNewSize += file.Size
|
||||||
|
}
|
||||||
|
if user.UsedBytes+totalNewSize > user.QuotaBytes {
|
||||||
|
c.HTML(http.StatusBadRequest, "compose", gin.H{
|
||||||
|
"currentUser": currentUser,
|
||||||
|
"activeFolder": "compose",
|
||||||
|
"error": fmt.Sprintf("附件超出配额限制。已用 %s / 总配额 %s", formatBytes(user.UsedBytes), formatBytes(user.QuotaBytes)),
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"cc": cc,
|
||||||
|
"bodyContent": htmlBody,
|
||||||
|
"usedBytes": user.UsedBytes,
|
||||||
|
"quotaBytes": user.QuotaBytes,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build the email content
|
// Build the email content
|
||||||
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
|
fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -159,9 +206,24 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
sb.WriteString(fmt.Sprintf("Message-ID: %s\r\n", messageID))
|
||||||
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
sb.WriteString(fmt.Sprintf("Date: %s\r\n", now.Format(time.RFC1123Z)))
|
||||||
sb.WriteString("MIME-Version: 1.0\r\n")
|
sb.WriteString("MIME-Version: 1.0\r\n")
|
||||||
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
|
||||||
sb.WriteString("\r\n")
|
// Build message body with multipart/alternative if HTML is present
|
||||||
sb.WriteString(body)
|
if htmlBody != "" {
|
||||||
|
boundary := fmt.Sprintf("----=_Part_%s", uuid.New().String())
|
||||||
|
sb.WriteString(fmt.Sprintf("Content-Type: multipart/alternative; boundary=\"%s\"\r\n", boundary))
|
||||||
|
sb.WriteString("\r\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("--%s\r\n", boundary))
|
||||||
|
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n")
|
||||||
|
sb.WriteString(body)
|
||||||
|
sb.WriteString(fmt.Sprintf("\r\n--%s\r\n", boundary))
|
||||||
|
sb.WriteString("Content-Type: text/html; charset=utf-8\r\n\r\n")
|
||||||
|
sb.WriteString(htmlBody)
|
||||||
|
sb.WriteString(fmt.Sprintf("\r\n--%s--\r\n", boundary))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
|
||||||
|
sb.WriteString("\r\n")
|
||||||
|
sb.WriteString(body)
|
||||||
|
}
|
||||||
|
|
||||||
// Send via local SMTP
|
// Send via local SMTP
|
||||||
err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String()))
|
err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String()))
|
||||||
@@ -180,6 +242,7 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
CcAddr: cc,
|
CcAddr: cc,
|
||||||
Subject: subject,
|
Subject: subject,
|
||||||
TextBody: body,
|
TextBody: body,
|
||||||
|
HtmlBody: htmlBody,
|
||||||
Date: now,
|
Date: now,
|
||||||
IsRead: true,
|
IsRead: true,
|
||||||
}
|
}
|
||||||
@@ -192,13 +255,15 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
"to": to,
|
"to": to,
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"cc": cc,
|
"cc": cc,
|
||||||
|
"bodyContent": htmlBody,
|
||||||
|
"usedBytes": currentUser.UsedBytes,
|
||||||
|
"quotaBytes": currentUser.QuotaBytes,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle attachments
|
// Handle attachments
|
||||||
form, err := c.MultipartForm()
|
if multipartErr == nil {
|
||||||
if err == nil {
|
|
||||||
files := form.File["attachments"]
|
files := form.File["attachments"]
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
// Read file content
|
// Read file content
|
||||||
@@ -233,6 +298,8 @@ func (h *MailHandler) DoSend(c *gin.Context) {
|
|||||||
FileSize: file.Size,
|
FileSize: file.Size,
|
||||||
}
|
}
|
||||||
_ = h.stores.Attachments.Create(att)
|
_ = h.stores.Attachments.Create(att)
|
||||||
|
// Update user used bytes
|
||||||
|
_ = h.stores.Users.UpdateUsedBytes(userID, att.FileSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,10 +373,11 @@ func (h *MailHandler) Delete(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete attachments on disk and in DB
|
// Delete attachments on disk and in DB, and decrease UsedBytes
|
||||||
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
attachments, _ := h.stores.Attachments.ListByMessage(uint(id))
|
||||||
for _, att := range attachments {
|
for _, att := range attachments {
|
||||||
_ = h.storage.Delete(att.FilePath)
|
_ = h.storage.Delete(att.FilePath)
|
||||||
|
_ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize)
|
||||||
}
|
}
|
||||||
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
_ = h.stores.Attachments.DeleteByMessage(uint(id))
|
||||||
_ = h.stores.Mails.Delete(uint(id))
|
_ = h.stores.Mails.Delete(uint(id))
|
||||||
@@ -519,3 +587,18 @@ func resolveActiveFolder(folder string) string {
|
|||||||
return folder
|
return folder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// formatBytes converts a file size in bytes to a human-readable string.
|
||||||
|
// This is a handler-level utility that reuses the web package function.
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
const unit = 1024
|
||||||
|
if b < unit {
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
div, exp := int64(unit), 0
|
||||||
|
for n := b / unit; n >= unit; n /= unit {
|
||||||
|
div *= unit
|
||||||
|
exp++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"mail_go/internal/store"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BanMiddleware checks if the client IP is currently banned.
|
||||||
|
// If banned, it renders the "banned" template and aborts the request.
|
||||||
|
func BanMiddleware(stores *store.Stores) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
ip := c.ClientIP()
|
||||||
|
banned, entry := stores.Bans.IsBanned(ip)
|
||||||
|
if banned {
|
||||||
|
c.HTML(http.StatusForbidden, "banned", gin.H{
|
||||||
|
"entry": entry,
|
||||||
|
})
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-2
@@ -36,6 +36,8 @@ type WebServer struct {
|
|||||||
stores *store.Stores
|
stores *store.Stores
|
||||||
storage *storage.AttachmentStorage
|
storage *storage.AttachmentStorage
|
||||||
cfg config.WebConfig
|
cfg config.WebConfig
|
||||||
|
authCfg config.AuthConfig
|
||||||
|
banCfg config.BanConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// templateFuncs returns custom template functions for rendering.
|
// templateFuncs returns custom template functions for rendering.
|
||||||
@@ -60,6 +62,9 @@ func templateFuncs() template.FuncMap {
|
|||||||
"safeHTML": func(s string) template.HTML {
|
"safeHTML": func(s string) template.HTML {
|
||||||
return template.HTML(s)
|
return template.HTML(s)
|
||||||
},
|
},
|
||||||
|
"safeJS": func(s string) template.JS {
|
||||||
|
return template.JS(s)
|
||||||
|
},
|
||||||
"formatBytes": func(b int64) string {
|
"formatBytes": func(b int64) string {
|
||||||
return formatBytes(b)
|
return formatBytes(b)
|
||||||
},
|
},
|
||||||
@@ -68,7 +73,7 @@ func templateFuncs() template.FuncMap {
|
|||||||
|
|
||||||
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
// NewWebServer creates a new WebServer, initializes the Gin engine,
|
||||||
// configures sessions, middleware, and registers all routes.
|
// configures sessions, middleware, and registers all routes.
|
||||||
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *WebServer {
|
func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, authCfg config.AuthConfig, banCfg config.BanConfig) *WebServer {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
engine := gin.New()
|
engine := gin.New()
|
||||||
engine.Use(gin.Logger())
|
engine.Use(gin.Logger())
|
||||||
@@ -95,6 +100,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
|||||||
stores: stores,
|
stores: stores,
|
||||||
storage: attStorage,
|
storage: attStorage,
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
authCfg: authCfg,
|
||||||
|
banCfg: banCfg,
|
||||||
}
|
}
|
||||||
|
|
||||||
ws.registerRoutes()
|
ws.registerRoutes()
|
||||||
@@ -103,13 +110,19 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag
|
|||||||
|
|
||||||
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
// registerRoutes sets up all HTTP routes with their handlers and middleware.
|
||||||
func (ws *WebServer) registerRoutes() {
|
func (ws *WebServer) registerRoutes() {
|
||||||
authHandler := handlers.NewAuthHandler(ws.stores)
|
authHandler := handlers.NewAuthHandler(ws.stores, ws.authCfg, ws.banCfg)
|
||||||
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
mailHandler := handlers.NewMailHandler(ws.stores, ws.storage)
|
||||||
adminHandler := handlers.NewAdminHandler(ws.stores)
|
adminHandler := handlers.NewAdminHandler(ws.stores)
|
||||||
|
|
||||||
|
// Apply BanMiddleware globally before public routes
|
||||||
|
ws.engine.Use(middleware.BanMiddleware(ws.stores))
|
||||||
|
|
||||||
// Public routes (no auth required)
|
// Public routes (no auth required)
|
||||||
ws.engine.GET("/login", authHandler.ShowLogin)
|
ws.engine.GET("/login", authHandler.ShowLogin)
|
||||||
ws.engine.POST("/login", authHandler.DoLogin)
|
ws.engine.POST("/login", authHandler.DoLogin)
|
||||||
|
ws.engine.POST("/login/ldap", authHandler.LDAPLogin)
|
||||||
|
ws.engine.GET("/auth/oauth2", authHandler.OAuth2Start)
|
||||||
|
ws.engine.GET("/auth/oauth2/callback", authHandler.OAuth2Callback)
|
||||||
|
|
||||||
// Auth-protected routes
|
// Auth-protected routes
|
||||||
auth := ws.engine.Group("")
|
auth := ws.engine.Group("")
|
||||||
@@ -146,6 +159,8 @@ func (ws *WebServer) registerRoutes() {
|
|||||||
admin.GET("/domains", adminHandler.ListDomains)
|
admin.GET("/domains", adminHandler.ListDomains)
|
||||||
admin.GET("/domains/new", adminHandler.NewDomain)
|
admin.GET("/domains/new", adminHandler.NewDomain)
|
||||||
admin.POST("/domains", adminHandler.CreateDomain)
|
admin.POST("/domains", adminHandler.CreateDomain)
|
||||||
|
admin.GET("/domains/:id/edit", adminHandler.EditDomain)
|
||||||
|
admin.POST("/domains/:id", adminHandler.UpdateDomain)
|
||||||
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
admin.POST("/domains/:id/delete", adminHandler.DeleteDomain)
|
||||||
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
admin.GET("/domains/:id/dns", adminHandler.DNSHint)
|
||||||
admin.GET("/users", adminHandler.ListUsers)
|
admin.GET("/users", adminHandler.ListUsers)
|
||||||
@@ -154,6 +169,10 @@ func (ws *WebServer) registerRoutes() {
|
|||||||
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
admin.POST("/users/:id/delete", adminHandler.DeleteUser)
|
||||||
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
admin.GET("/users/:id/edit", adminHandler.EditUser)
|
||||||
admin.POST("/users/:id", adminHandler.UpdateUser)
|
admin.POST("/users/:id", adminHandler.UpdateUser)
|
||||||
|
admin.GET("/mails", adminHandler.ListMails)
|
||||||
|
admin.GET("/bans", adminHandler.ListBans)
|
||||||
|
admin.POST("/bans/:id/unban", adminHandler.UnbanIP)
|
||||||
|
admin.POST("/bans/cleanup", adminHandler.CleanupBans)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
{{define "admin_bans"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>IP黑名单 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
|
||||||
|
<h2>IP 黑名单</h2>
|
||||||
|
<form method="POST" action="/admin/bans/cleanup" style="display:inline;">
|
||||||
|
<button type="submit" class="btn btn-primary">清理过期记录</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{{if not .bans}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无被封禁的 IP</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>IP 地址</th>
|
||||||
|
<th>失败次数</th>
|
||||||
|
<th>原因</th>
|
||||||
|
<th>到期时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .bans}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.ID}}</td>
|
||||||
|
<td>{{.IPAddress}}</td>
|
||||||
|
<td>{{.FailCount}}</td>
|
||||||
|
<td>{{.Reason}}</td>
|
||||||
|
<td>{{.ExpiresAt.Format "2006-01-02 15:04:05"}}</td>
|
||||||
|
<td>
|
||||||
|
<form method="POST" action="/admin/bans/{{.ID}}/unban" style="display:inline;"
|
||||||
|
onsubmit="return confirm('确定要解封 IP {{.IPAddress}} 吗?');">
|
||||||
|
<button type="submit" class="btn btn-primary btn-sm">解封</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/admin/bans?page={{sub .page 1}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/admin/bans?page={{add .page 1}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin" class="active">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<h2 style="margin-bottom:24px;">管理后台</h2>
|
<h2 style="margin-bottom:24px;">管理后台</h2>
|
||||||
@@ -28,12 +30,53 @@
|
|||||||
<h3>{{.userCount}}</h3>
|
<h3>{{.userCount}}</h3>
|
||||||
<p>用户数</p>
|
<p>用户数</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.totalMails}}</h3>
|
||||||
|
<p>邮件总数</p>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<h3>{{.banCount}}</h3>
|
||||||
|
<p>被封IP</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>邮件分布</h3>
|
||||||
|
<table style="margin-top:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>文件夹</th>
|
||||||
|
<th>邮件数</th>
|
||||||
|
<th>占用空间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>收件箱 (INBOX)</td><td>{{.inboxCount}}</td><td>{{formatBytes .inboxSize}}</td></tr>
|
||||||
|
<tr><td>发件箱 (Sent)</td><td>{{.sentCount}}</td><td>{{formatBytes .sentSize}}</td></tr>
|
||||||
|
<tr><td>草稿箱 (Drafts)</td><td>{{.draftsCount}}</td><td>—</td></tr>
|
||||||
|
<tr><td>垃圾箱 (Trash)</td><td>{{.trashCount}}</td><td>—</td></tr>
|
||||||
|
<tr style="font-weight:bold;"><td>合计</td><td>{{.totalMails}}</td><td>{{formatBytes .totalSize}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>收发统计</h3>
|
||||||
|
<table style="margin-top:12px;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>时间段</th><th>收件</th><th>发件</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td>今日</td><td>{{.todayReceived}}</td><td>{{.todaySent}}</td></tr>
|
||||||
|
<tr><td>近 7 天</td><td>{{.weekReceived}}</td><td>{{.weekSent}}</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3>快捷操作</h3>
|
<h3>快捷操作</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">新增域名</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;">新增用户</a>
|
||||||
|
<a href="/admin/mails" class="btn btn-primary" style="margin-left:8px;">查看所有邮件</a>
|
||||||
|
<a href="/admin/bans" class="btn btn-primary" style="margin-left:8px;">IP黑名单</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains" class="active">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -28,8 +30,12 @@
|
|||||||
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
|
<h4 style="margin-bottom:8px;">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 记录</h4>
|
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4>
|
||||||
<div class="dns-record" style="color:#e67e22;">⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。</div>
|
{{if .domain.DkimPublicKey}}
|
||||||
|
<div class="dns-record">default._domainkey.{{.domain.Name}}. IN TXT "{{.dkimRecord}}"</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="dns-record" style="color:#e67e22;">⚠️ DKIM 密钥尚未生成,请编辑域名重新生成。</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
<h4 style="margin-bottom:8px;">DMARC 记录</h4>
|
<h4 style="margin-bottom:8px;">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>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains" class="active">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -24,7 +26,11 @@
|
|||||||
<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>域名</label>
|
||||||
|
{{if .isEdit}}
|
||||||
|
<input type="text" name="name" value="{{.domain.Name}}" disabled style="background:#f5f5f5;">
|
||||||
|
{{else}}
|
||||||
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
<input type="text" name="name" required value="{{.domain.Name}}" placeholder="example.com">
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>SMTP 端口</label>
|
<label>SMTP 端口</label>
|
||||||
@@ -44,6 +50,19 @@
|
|||||||
启用 TLS
|
启用 TLS
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .isEdit}}
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" name="regenerate_dkim">
|
||||||
|
重新生成 DKIM 密钥
|
||||||
|
</label>
|
||||||
|
{{if .domain.DkimPublicKey}}
|
||||||
|
<p style="color:#27ae60;font-size:12px;margin-top:4px;">✅ DKIM 密钥已配置</p>
|
||||||
|
{{else}}
|
||||||
|
<p style="color:#e67e22;font-size:12px;margin-top:4px;">⚠️ DKIM 密钥未配置</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
<button type="submit" class="btn btn-primary">{{if .isEdit}}保存更改{{else}}创建域名{{end}}</button>
|
||||||
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains" class="active">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
<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}}/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('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
{{define "admin_mails"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>所有邮件 - MailGo</title>
|
||||||
|
{{template "styles" .}}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<div class="container">
|
||||||
|
<div class="clearfix">
|
||||||
|
<div class="sidebar">
|
||||||
|
<a href="/inbox">返回邮箱</a>
|
||||||
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<div class="card">
|
||||||
|
<h2 style="margin-bottom:16px;">所有邮件</h2>
|
||||||
|
<div style="margin-bottom:16px;">
|
||||||
|
<a href="/admin/mails" {{if eq .folder ""}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>全部</a>
|
||||||
|
<a href="/admin/mails?folder=INBOX" {{if eq .folder "INBOX"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>INBOX</a>
|
||||||
|
<a href="/admin/mails?folder=Sent" {{if eq .folder "Sent"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Sent</a>
|
||||||
|
<a href="/admin/mails?folder=Drafts" {{if eq .folder "Drafts"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Drafts</a>
|
||||||
|
<a href="/admin/mails?folder=Trash" {{if eq .folder "Trash"}}class="btn btn-primary"{{else}}class="btn" style="background:#ecf0f1;color:#333;"{{end}}>Trash</a>
|
||||||
|
</div>
|
||||||
|
{{if not .messages}}
|
||||||
|
<p style="color:#7f8c8d;text-align:center;padding:40px 0;">暂无邮件</p>
|
||||||
|
{{else}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>发件人</th>
|
||||||
|
<th>收件人</th>
|
||||||
|
<th>主题</th>
|
||||||
|
<th>所属用户</th>
|
||||||
|
<th>文件夹</th>
|
||||||
|
<th>日期</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .messages}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.FromAddr}}</td>
|
||||||
|
<td>{{.ToAddr}}</td>
|
||||||
|
<td>{{.Subject}}</td>
|
||||||
|
<td>{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}</td>
|
||||||
|
<td>{{.Folder}}</td>
|
||||||
|
<td>{{.Date.Format "2006-01-02 15:04"}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{if .totalPages}}
|
||||||
|
<div class="pagination">
|
||||||
|
{{if gt .page 1}}
|
||||||
|
<a href="/admin/mails?page={{sub .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">上一页</a>
|
||||||
|
{{end}}
|
||||||
|
<span>第 {{.page}} / {{.totalPages}} 页</span>
|
||||||
|
{{if lt .page .totalPages}}
|
||||||
|
<a href="/admin/mails?page={{add .page 1}}{{if .folder}}&folder={{.folder}}{{end}}">下一页</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users" class="active">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -13,9 +13,11 @@
|
|||||||
<div class="clearfix">
|
<div class="clearfix">
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<a href="/inbox">返回邮箱</a>
|
<a href="/inbox">返回邮箱</a>
|
||||||
<a href="/admin">控制面板</a>
|
<a href="/admin" {{if eq .activeFolder "admin"}}class="active"{{end}}>控制面板</a>
|
||||||
<a href="/admin/domains">域名管理</a>
|
<a href="/admin/domains" {{if eq .activeFolder "domains"}}class="active"{{end}}>域名管理</a>
|
||||||
<a href="/admin/users" class="active">用户管理</a>
|
<a href="/admin/users" {{if eq .activeFolder "users"}}class="active"{{end}}>用户管理</a>
|
||||||
|
<a href="/admin/mails" {{if eq .activeFolder "mails"}}class="active"{{end}}>所有邮件</a>
|
||||||
|
<a href="/admin/bans" {{if eq .activeFolder "bans"}}class="active"{{end}}>IP黑名单</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{{define "banned"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>访问被禁止 - MailGo</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f5f5f5; color: #333; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
.banned-card { background: #fff; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 48px; text-align: center; max-width: 480px; width: 100%; }
|
||||||
|
.banned-icon { font-size: 64px; margin-bottom: 16px; }
|
||||||
|
h1 { font-size: 24px; color: #c0392b; margin-bottom: 12px; }
|
||||||
|
p { color: #7f8c8d; line-height: 1.6; margin-bottom: 8px; }
|
||||||
|
.detail { background: #f8f9fa; border-radius: 6px; padding: 16px; margin: 16px 0; text-align: left; }
|
||||||
|
.detail-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #eee; }
|
||||||
|
.detail-row:last-child { border-bottom: none; }
|
||||||
|
.detail-label { color: #7f8c8d; font-size: 13px; }
|
||||||
|
.detail-value { color: #2c3e50; font-weight: 600; font-size: 13px; }
|
||||||
|
.back-link { display: inline-block; margin-top: 20px; color: #3498db; text-decoration: none; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="banned-card">
|
||||||
|
<div class="banned-icon">🚫</div>
|
||||||
|
<h1>您的 IP 已被暂时禁止访问</h1>
|
||||||
|
<p>由于登录失败次数过多,您的 IP 地址已被暂时封禁。</p>
|
||||||
|
{{if .entry}}
|
||||||
|
<div class="detail">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">IP 地址</span>
|
||||||
|
<span class="detail-value">{{.entry.IPAddress}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">原因</span>
|
||||||
|
<span class="detail-value">{{.entry.Reason}}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">失败次数</span>
|
||||||
|
<span class="detail-value">{{.entry.FailCount}} 次</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">解封时间</span>
|
||||||
|
<span class="detail-value">{{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<p style="font-size:13px;">请在解封时间后重试,或联系管理员。</p>
|
||||||
|
<a href="/login" class="back-link">返回登录页</a>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>撰写邮件 - MailGo</title>
|
<title>撰写邮件 - MailGo</title>
|
||||||
|
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
|
||||||
{{template "styles" .}}
|
{{template "styles" .}}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -37,12 +38,17 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>正文</label>
|
<label>正文</label>
|
||||||
<textarea name="body" rows="12" placeholder="请输入邮件内容..."></textarea>
|
<div id="editor" style="height:300px;"></div>
|
||||||
|
<input type="hidden" name="body" id="body-hidden">
|
||||||
|
<input type="hidden" name="html_body" id="html-body-hidden">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>附件</label>
|
<label>附件</label>
|
||||||
<input type="file" name="attachments" multiple>
|
<input type="file" name="attachments" multiple>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-group" style="color:#7f8c8d;font-size:12px;">
|
||||||
|
配额: {{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn btn-primary">发送邮件</button>
|
<button type="submit" class="btn btn-primary">发送邮件</button>
|
||||||
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
|
||||||
</form>
|
</form>
|
||||||
@@ -50,6 +56,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="https://cdn.quilljs.com/1.3.7/quill.min.js"></script>
|
||||||
|
<script>
|
||||||
|
var quill = new Quill('#editor', {
|
||||||
|
theme: 'snow',
|
||||||
|
placeholder: '请输入邮件内容...',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
[{ 'color': [] }, { 'background': [] }],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.querySelector('form').addEventListener('submit', function() {
|
||||||
|
document.getElementById('body-hidden').value = quill.getText();
|
||||||
|
document.getElementById('html-body-hidden').value = quill.root.innerHTML;
|
||||||
|
});
|
||||||
|
{{if .bodyContent}}
|
||||||
|
quill.root.innerHTML = {{.bodyContent | safeJS}};
|
||||||
|
{{end}}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -25,6 +25,30 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{{if or .oauth2Enabled .ldapEnabled}}
|
||||||
|
<div style="text-align:center;margin:16px 0;color:#7f8c8d;">─── 或 ───</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ldapEnabled}}
|
||||||
|
<form method="POST" action="/login/ldap">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>LDAP 用户名</label>
|
||||||
|
<input type="text" name="username" placeholder="LDAP 用户名">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>LDAP 密码</label>
|
||||||
|
<input type="password" name="password" placeholder="LDAP 密码">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn" style="width:100%;background:#8e44ad;color:#fff;">LDAP 登录</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .oauth2Enabled}}
|
||||||
|
<a href="/auth/oauth2" class="btn" style="width:100%;background:#3498db;color:#fff;text-align:center;display:block;margin-top:8px;">
|
||||||
|
{{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 9. Start Web server
|
// 9. Start Web server
|
||||||
webServer := web.NewWebServer(cfg.Web, stores, attStorage)
|
webServer := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Auth, cfg.Ban)
|
||||||
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
|
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
|
||||||
go func() {
|
go func() {
|
||||||
if err := webServer.Start(); err != nil {
|
if err := webServer.Start(); err != nil {
|
||||||
|
|||||||
@@ -28,3 +28,21 @@
|
|||||||
tls_addr = ":995"
|
tls_addr = ":995"
|
||||||
tls_cert = ""
|
tls_cert = ""
|
||||||
tls_key = ""
|
tls_key = ""
|
||||||
|
|
||||||
|
[auth]
|
||||||
|
oauth2_enabled = false
|
||||||
|
oauth2_provider = ""
|
||||||
|
oauth2_client_id = ""
|
||||||
|
oauth2_client_secret = ""
|
||||||
|
oauth2_redirect_url = ""
|
||||||
|
ldap_enabled = false
|
||||||
|
ldap_server = ""
|
||||||
|
ldap_bind_dn = ""
|
||||||
|
ldap_bind_password = ""
|
||||||
|
ldap_search_base = ""
|
||||||
|
ldap_search_filter = ""
|
||||||
|
ldap_use_tls = false
|
||||||
|
|
||||||
|
[ban]
|
||||||
|
max_fail_attempts = 5
|
||||||
|
ban_duration_min = 30
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user