diff --git a/.workbuddy/memory/2026-06-01.md b/.workbuddy/memory/2026-06-01.md index 32fe6fb..5cbcd2d 100644 --- a/.workbuddy/memory/2026-06-01.md +++ b/.workbuddy/memory/2026-06-01.md @@ -17,3 +17,21 @@ - IMAP v2 beta API不稳定 → 切换v1 - 模板 {{template .VarName .}} 不支持 → 重构为自包含模式 - 补充 /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 通过 diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index 9090243..aca555b 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -17,6 +17,28 @@ - Windows路径回退: ./win/etc/mail_go 和 ./win/srv/mail_go - 默认管理员: 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 filepath.Glob 不支持 ** 递归匹配,模板分两轮加载 diff --git a/config/config.go b/config/config.go index 9827d0f..8e78ade 100644 --- a/config/config.go +++ b/config/config.go @@ -52,6 +52,31 @@ type POP3Config struct { 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. type Config struct { Database DatabaseConfig `toml:"database"` @@ -60,6 +85,8 @@ type Config struct { SMTP SMTPConfig `toml:"smtp"` IMAP IMAPConfig `toml:"imap"` POP3 POP3Config `toml:"pop3"` + Auth AuthConfig `toml:"auth"` + Ban BanConfig `toml:"ban"` } // isWindows returns true if the current OS is Windows. @@ -120,6 +147,14 @@ func defaultConfig() *Config { Addr: fmt.Sprintf(":%d", DefaultPOP3Port), 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 == "" { 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 } diff --git a/go.mod b/go.mod index 26ef3bc..40f2595 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,8 @@ 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/sonic v1.15.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/gabriel-vasile/mimetype v1.4.12 // 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/universal-translator v0.18.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 golang.org/x/arch v0.22.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/text v0.35.0 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 2bb0d93..9205ac0 100644 --- a/go.sum +++ b/go.sum @@ -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/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 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-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= 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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= 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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/internal/auth/ldap.go b/internal/auth/ldap.go new file mode 100644 index 0000000..3b3ee79 --- /dev/null +++ b/internal/auth/ldap.go @@ -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 +} diff --git a/internal/auth/oauth2.go b/internal/auth/oauth2.go new file mode 100644 index 0000000..2ba49a0 --- /dev/null +++ b/internal/auth/oauth2.go @@ -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) +) diff --git a/internal/auth/provider.go b/internal/auth/provider.go new file mode 100644 index 0000000..170f363 --- /dev/null +++ b/internal/auth/provider.go @@ -0,0 +1,9 @@ +package auth + +// Provider 定义外部认证接口 +type Provider interface { + // Authenticate 验证外部凭据,返回邮箱地址 + Authenticate(credentials map[string]string) (email string, err error) + // Name 返回提供者名称 + Name() string +} diff --git a/internal/db/db.go b/internal/db/db.go index 2c0cf1b..7a3994b 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -46,7 +46,7 @@ func InitDB(cfg config.DatabaseConfig, storageCfg config.StorageConfig) (*gorm.D } // 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) } diff --git a/internal/db/models.go b/internal/db/models.go index 28b3e7c..d64bba4 100644 --- a/internal/db/models.go +++ b/internal/db/models.go @@ -26,16 +26,19 @@ func (User) TableName() string { // Domain represents a mail domain in the system. type Domain struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:255;uniqueIndex;not null" json:"name"` - SmtpPort int `gorm:"default:25" json:"smtp_port"` - ImapPort int `gorm:"default:143" json:"imap_port"` - Pop3Port int `gorm:"default:110" json:"pop3_port"` - TlsCertPath string `gorm:"size:512" json:"tls_cert_path"` - TlsKeyPath string `gorm:"size:512" json:"tls_key_path"` - TlsEnabled bool `gorm:"default:false" json:"tls_enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:255;uniqueIndex;not null" json:"name"` + SmtpPort int `gorm:"default:25" json:"smtp_port"` + ImapPort int `gorm:"default:143" json:"imap_port"` + Pop3Port int `gorm:"default:110" json:"pop3_port"` + TlsCertPath string `gorm:"size:512" json:"tls_cert_path"` + TlsKeyPath string `gorm:"size:512" json:"tls_key_path"` + TlsEnabled bool `gorm:"default:false" json:"tls_enabled"` + DkimSelector string `gorm:"size:64;default:default" json:"dkim_selector"` + 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. @@ -67,6 +70,20 @@ func (Message) TableName() string { 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. type Attachment struct { ID uint `gorm:"primaryKey" json:"id"` diff --git a/internal/dkim/keys.go b/internal/dkim/keys.go new file mode 100644 index 0000000..6ae1907 --- /dev/null +++ b/internal/dkim/keys.go @@ -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= +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)) +} diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index ada4ee3..b31f580 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -279,6 +279,8 @@ func (s *smtpSession) Data(r io.Reader) error { if attErr := s.backend.server.stores.Attachments.Create(&attCopy); attErr != nil { 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) diff --git a/internal/store/ban_store.go b/internal/store/ban_store.go new file mode 100644 index 0000000..50b8a87 --- /dev/null +++ b/internal/store/ban_store.go @@ -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 +} diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go index 3b17599..2b5afcd 100644 --- a/internal/store/mail_store.go +++ b/internal/store/mail_store.go @@ -2,6 +2,7 @@ package store import ( "errors" + "time" "mail_go/internal/db" @@ -28,6 +29,13 @@ type MailStore interface { MoveToFolder(id uint, folder string) error Delete(id uint) 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. @@ -123,3 +131,90 @@ func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64, } 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 +} diff --git a/internal/store/stores.go b/internal/store/stores.go index 202f721..5f296f3 100644 --- a/internal/store/stores.go +++ b/internal/store/stores.go @@ -12,6 +12,7 @@ type Stores struct { Mails MailStore Domains DomainStore Attachments AttachmentStore + Bans BanStore } // NewStores creates a new Stores instance with all GORM-backed implementations. @@ -21,6 +22,7 @@ func NewStores(database *gorm.DB) *Stores { Mails: newMailStore(database), Domains: newDomainStore(database), Attachments: newAttachmentStore(database), + Bans: newBanStore(database), } } @@ -29,3 +31,4 @@ var _ = db.User{} var _ = db.Domain{} var _ = db.Message{} var _ = db.Attachment{} +var _ = db.BanEntry{} diff --git a/internal/web/handlers/admin.go b/internal/web/handlers/admin.go index 2b213bf..f07a39b 100644 --- a/internal/web/handlers/admin.go +++ b/internal/web/handlers/admin.go @@ -2,10 +2,13 @@ package handlers import ( "fmt" + "log" "net/http" "strconv" + "time" "mail_go/internal/db" + "mail_go/internal/dkim" "mail_go/internal/store" "github.com/gin-gonic/gin" @@ -27,13 +30,55 @@ func (h *AdminHandler) Dashboard(c *gin.Context) { _, domainCount, _ := h.stores.Domains.List(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") c.HTML(200, "admin_dashboard", gin.H{ - "currentUser": currentUser, - "domainCount": domainCount, - "userCount": userCount, - "activeFolder": "admin", + "currentUser": currentUser, + "domainCount": domainCount, + "userCount": userCount, + "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, "pageSize": 20, "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{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "domains", "error": "", "isEdit": false, "domain": &db.Domain{}, @@ -92,7 +137,7 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "domains", "error": "请输入域名", "isEdit": false, "domain": &db.Domain{ @@ -114,11 +159,21 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { 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 { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusBadRequest, "admin_domain_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "domains", "error": fmt.Sprintf("创建域名失败: %v", err), "isEdit": false, "domain": domain, @@ -129,6 +184,74 @@ func (h *AdminHandler) CreateDomain(c *gin.Context) { 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. func (h *AdminHandler) DeleteDomain(c *gin.Context) { 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{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "domains", "domain": domain, + "dkimRecord": dkim.GetDKIMDNSRecord(domain.DkimPublicKey), }) } @@ -199,7 +323,7 @@ func (h *AdminHandler) ListUsers(c *gin.Context) { "page": page, "pageSize": 20, "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{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": "", "isEdit": false, "domains": domains, @@ -232,7 +356,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": "请填写所有必填字段", "isEdit": false, "domains": domains, @@ -252,7 +376,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": "密码加密失败", "isEdit": false, "domains": domains, @@ -282,7 +406,7 @@ func (h *AdminHandler) CreateUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": fmt.Sprintf("创建用户失败: %v", err), "isEdit": false, "domains": domains, @@ -335,7 +459,7 @@ func (h *AdminHandler) EditUser(c *gin.Context) { c.HTML(200, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": "", "isEdit": true, "domains": domains, @@ -380,7 +504,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusInternalServerError, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": "密码加密失败", "isEdit": true, "domains": domains, @@ -396,7 +520,7 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { currentUser, _ := c.Get("currentUser") c.HTML(http.StatusBadRequest, "admin_user_form", gin.H{ "currentUser": currentUser, - "activeFolder": "admin", + "activeFolder": "users", "error": fmt.Sprintf("更新用户失败: %v", err), "isEdit": true, "domains": domains, @@ -408,6 +532,104 @@ func (h *AdminHandler) UpdateUser(c *gin.Context) { 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. func formIntOrDefault(c *gin.Context, key string, defaultVal int) int { val := c.PostForm(key) diff --git a/internal/web/handlers/auth.go b/internal/web/handlers/auth.go index 857b000..a57fb17 100644 --- a/internal/web/handlers/auth.go +++ b/internal/web/handlers/auth.go @@ -1,20 +1,30 @@ package handlers import ( + "fmt" + "log" + "net/http" + "time" + + "mail_go/config" + "mail_go/internal/auth" + "mail_go/internal/db" "mail_go/internal/store" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) -// AuthHandler handles authentication-related routes (login, logout). +// AuthHandler handles authentication-related routes (login, logout, LDAP, OAuth2). type AuthHandler struct { - stores *store.Stores + stores *store.Stores + authCfg config.AuthConfig + banCfg config.BanConfig } -// NewAuthHandler creates a new AuthHandler with the given stores. -func NewAuthHandler(stores *store.Stores) *AuthHandler { - return &AuthHandler{stores: stores} +// NewAuthHandler creates a new AuthHandler with the given stores, auth config, and ban config. +func NewAuthHandler(stores *store.Stores, authCfg config.AuthConfig, banCfg config.BanConfig) *AuthHandler { + return &AuthHandler{stores: stores, authCfg: authCfg, banCfg: banCfg} } // ShowLogin renders the login page. @@ -26,7 +36,10 @@ func (h *AuthHandler) ShowLogin(c *gin.Context) { return } 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 // on success, or re-renders the login page with an error on failure. 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") password := c.PostForm("password") if email == "" || password == "" { c.HTML(200, "login", gin.H{ - "error": "请输入邮箱和密码", + "error": "请输入邮箱和密码", + "oauth2Enabled": h.authCfg.OAuth2Enabled, + "ldapEnabled": h.authCfg.LDAPEnabled, + "oauth2Provider": h.authCfg.OAuth2Provider, }) return } user, err := h.stores.Users.Authenticate(email, password) 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{ - "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 } @@ -59,7 +291,10 @@ func (h *AuthHandler) DoLogin(c *gin.Context) { session.Set("isAdmin", user.IsAdmin) if err := session.Save(); err != nil { c.HTML(200, "login", gin.H{ - "error": "会话保存失败,请重试", + "error": "会话保存失败,请重试", + "oauth2Enabled": h.authCfg.OAuth2Enabled, + "ldapEnabled": h.authCfg.LDAPEnabled, + "oauth2Provider": h.authCfg.OAuth2Provider, }) return } diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index 2c0bfb7..0101da6 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -108,14 +108,27 @@ func (h *MailHandler) View(c *gin.Context) { // Compose renders the email composition page. func (h *MailHandler) Compose(c *gin.Context) { + userID := c.GetUint("userID") 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{ "currentUser": currentUser, "activeFolder": "compose", "error": "", "to": c.Query("to"), "subject": c.Query("subject"), + "bodyContent": "", + "usedBytes": usedBytes, + "quotaBytes": quotaBytes, }) } @@ -129,6 +142,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { to := c.PostForm("to") subject := c.PostForm("subject") body := c.PostForm("body") + htmlBody := c.PostForm("html_body") cc := c.PostForm("cc") if to == "" { @@ -139,10 +153,43 @@ func (h *MailHandler) DoSend(c *gin.Context) { "to": to, "subject": subject, "cc": cc, + "bodyContent": htmlBody, + "usedBytes": currentUser.UsedBytes, + "quotaBytes": currentUser.QuotaBytes, }) 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 fromAddr := fmt.Sprintf("%s@%s", currentUser.Username, currentUser.Domain.Name) 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("Date: %s\r\n", now.Format(time.RFC1123Z))) sb.WriteString("MIME-Version: 1.0\r\n") - sb.WriteString("Content-Type: text/plain; charset=utf-8\r\n") - sb.WriteString("\r\n") - sb.WriteString(body) + + // Build message body with multipart/alternative if HTML is present + 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 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, Subject: subject, TextBody: body, + HtmlBody: htmlBody, Date: now, IsRead: true, } @@ -192,13 +255,15 @@ func (h *MailHandler) DoSend(c *gin.Context) { "to": to, "subject": subject, "cc": cc, + "bodyContent": htmlBody, + "usedBytes": currentUser.UsedBytes, + "quotaBytes": currentUser.QuotaBytes, }) return } // Handle attachments - form, err := c.MultipartForm() - if err == nil { + if multipartErr == nil { files := form.File["attachments"] for _, file := range files { // Read file content @@ -233,6 +298,8 @@ func (h *MailHandler) DoSend(c *gin.Context) { FileSize: file.Size, } _ = 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 } - // Delete attachments on disk and in DB + // Delete attachments on disk and in DB, and decrease UsedBytes attachments, _ := h.stores.Attachments.ListByMessage(uint(id)) for _, att := range attachments { _ = h.storage.Delete(att.FilePath) + _ = h.stores.Users.UpdateUsedBytes(userID, -att.FileSize) } _ = h.stores.Attachments.DeleteByMessage(uint(id)) _ = h.stores.Mails.Delete(uint(id)) @@ -519,3 +587,18 @@ func resolveActiveFolder(folder string) string { 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]) +} diff --git a/internal/web/middleware/ban.go b/internal/web/middleware/ban.go new file mode 100644 index 0000000..270907b --- /dev/null +++ b/internal/web/middleware/ban.go @@ -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() + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 432b916..adc2aab 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -36,6 +36,8 @@ type WebServer struct { stores *store.Stores storage *storage.AttachmentStorage cfg config.WebConfig + authCfg config.AuthConfig + banCfg config.BanConfig } // templateFuncs returns custom template functions for rendering. @@ -60,6 +62,9 @@ func templateFuncs() template.FuncMap { "safeHTML": func(s string) template.HTML { return template.HTML(s) }, + "safeJS": func(s string) template.JS { + return template.JS(s) + }, "formatBytes": func(b int64) string { return formatBytes(b) }, @@ -68,7 +73,7 @@ func templateFuncs() template.FuncMap { // NewWebServer creates a new WebServer, initializes the Gin engine, // 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) engine := gin.New() engine.Use(gin.Logger()) @@ -95,6 +100,8 @@ func NewWebServer(cfg config.WebConfig, stores *store.Stores, attStorage *storag stores: stores, storage: attStorage, cfg: cfg, + authCfg: authCfg, + banCfg: banCfg, } 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. 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) adminHandler := handlers.NewAdminHandler(ws.stores) + // Apply BanMiddleware globally before public routes + ws.engine.Use(middleware.BanMiddleware(ws.stores)) + // Public routes (no auth required) ws.engine.GET("/login", authHandler.ShowLogin) 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 := ws.engine.Group("") @@ -146,6 +159,8 @@ func (ws *WebServer) registerRoutes() { admin.GET("/domains", adminHandler.ListDomains) admin.GET("/domains/new", adminHandler.NewDomain) 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.GET("/domains/:id/dns", adminHandler.DNSHint) admin.GET("/users", adminHandler.ListUsers) @@ -154,6 +169,10 @@ func (ws *WebServer) registerRoutes() { admin.POST("/users/:id/delete", adminHandler.DeleteUser) admin.GET("/users/:id/edit", adminHandler.EditUser) 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) } } diff --git a/internal/web/templates/admin/bans.html b/internal/web/templates/admin/bans.html new file mode 100644 index 0000000..445c676 --- /dev/null +++ b/internal/web/templates/admin/bans.html @@ -0,0 +1,80 @@ +{{define "admin_bans"}} + + + + + + IP黑名单 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+
+

IP 黑名单

+
+ +
+
+ {{if not .bans}} +

暂无被封禁的 IP

+ {{else}} + + + + + + + + + + + + + {{range .bans}} + + + + + + + + + {{end}} + +
IDIP 地址失败次数原因到期时间操作
{{.ID}}{{.IPAddress}}{{.FailCount}}{{.Reason}}{{.ExpiresAt.Format "2006-01-02 15:04:05"}} +
+ +
+
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/dashboard.html b/internal/web/templates/admin/dashboard.html index 9c6c46d..4ab9609 100644 --- a/internal/web/templates/admin/dashboard.html +++ b/internal/web/templates/admin/dashboard.html @@ -13,9 +13,11 @@

管理后台

@@ -28,12 +30,53 @@

{{.userCount}}

用户数

+
+

{{.totalMails}}

+

邮件总数

+
+
+

{{.banCount}}

+

被封IP

+
+
+
+

邮件分布

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

收发统计

+ + + + + + + + +
时间段收件发件
今日{{.todayReceived}}{{.todaySent}}
近 7 天{{.weekReceived}}{{.weekSent}}

快捷操作

新增域名 新增用户 + 查看所有邮件 + IP黑名单

diff --git a/internal/web/templates/admin/dns_hint.html b/internal/web/templates/admin/dns_hint.html index 16e18b8..42b4d4b 100644 --- a/internal/web/templates/admin/dns_hint.html +++ b/internal/web/templates/admin/dns_hint.html @@ -13,9 +13,11 @@
@@ -28,8 +30,12 @@

SPF 记录 (TXT)

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

DKIM 记录

-
⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。
+

DKIM 记录 (TXT)

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

DMARC 记录

_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"
diff --git a/internal/web/templates/admin/domain_form.html b/internal/web/templates/admin/domain_form.html index 7994300..d918912 100644 --- a/internal/web/templates/admin/domain_form.html +++ b/internal/web/templates/admin/domain_form.html @@ -13,9 +13,11 @@
@@ -24,7 +26,11 @@
+ {{if .isEdit}} + + {{else}} + {{end}}
@@ -44,6 +50,19 @@ 启用 TLS
+ {{if .isEdit}} +
+ + {{if .domain.DkimPublicKey}} +

✅ DKIM 密钥已配置

+ {{else}} +

⚠️ DKIM 密钥未配置

+ {{end}} +
+ {{end}} 取消
diff --git a/internal/web/templates/admin/domains.html b/internal/web/templates/admin/domains.html index 2e981f8..6569877 100644 --- a/internal/web/templates/admin/domains.html +++ b/internal/web/templates/admin/domains.html @@ -13,9 +13,11 @@
@@ -48,6 +50,7 @@ {{.Pop3Port}} {{if .TlsEnabled}}✅{{else}}❌{{end}} + 编辑 DNS
diff --git a/internal/web/templates/admin/mails.html b/internal/web/templates/admin/mails.html new file mode 100644 index 0000000..fd29d15 --- /dev/null +++ b/internal/web/templates/admin/mails.html @@ -0,0 +1,77 @@ +{{define "admin_mails"}} + + + + + + 所有邮件 - MailGo + {{template "styles" .}} + + + {{template "navbar" .}} +
+
+ +
+
+

所有邮件

+
+ 全部 + INBOX + Sent + Drafts + Trash +
+ {{if not .messages}} +

暂无邮件

+ {{else}} + + + + + + + + + + + + + {{range .messages}} + + + + + + + + + {{end}} + +
发件人收件人主题所属用户文件夹日期
{{.FromAddr}}{{.ToAddr}}{{.Subject}}{{if .User.ID}}{{.User.Username}}{{else}}—{{end}}{{.Folder}}{{.Date.Format "2006-01-02 15:04"}}
+ {{end}} +
+ {{if .totalPages}} + + {{end}} +
+
+
+ + +{{end}} diff --git a/internal/web/templates/admin/user_form.html b/internal/web/templates/admin/user_form.html index cdda3ea..2d9b57f 100644 --- a/internal/web/templates/admin/user_form.html +++ b/internal/web/templates/admin/user_form.html @@ -13,9 +13,11 @@
diff --git a/internal/web/templates/admin/users.html b/internal/web/templates/admin/users.html index 7c83236..7ed6e52 100644 --- a/internal/web/templates/admin/users.html +++ b/internal/web/templates/admin/users.html @@ -13,9 +13,11 @@
diff --git a/internal/web/templates/banned.html b/internal/web/templates/banned.html new file mode 100644 index 0000000..10745fb --- /dev/null +++ b/internal/web/templates/banned.html @@ -0,0 +1,54 @@ +{{define "banned"}} + + + + + + 访问被禁止 - MailGo + + + +
+
🚫
+

您的 IP 已被暂时禁止访问

+

由于登录失败次数过多,您的 IP 地址已被暂时封禁。

+ {{if .entry}} +
+
+ IP 地址 + {{.entry.IPAddress}} +
+
+ 原因 + {{.entry.Reason}} +
+
+ 失败次数 + {{.entry.FailCount}} 次 +
+
+ 解封时间 + {{.entry.ExpiresAt.Format "2006-01-02 15:04:05"}} +
+
+ {{end}} +

请在解封时间后重试,或联系管理员。

+ 返回登录页 +
+ + +{{end}} diff --git a/internal/web/templates/compose.html b/internal/web/templates/compose.html index 9378d7c..30c12cd 100644 --- a/internal/web/templates/compose.html +++ b/internal/web/templates/compose.html @@ -5,6 +5,7 @@ 撰写邮件 - MailGo + {{template "styles" .}} @@ -37,12 +38,17 @@
- +
+ +
+
+ 配额: {{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}} +
取消 @@ -50,6 +56,30 @@
+ + {{end}} diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html index a27c2fa..d3c78b2 100644 --- a/internal/web/templates/login.html +++ b/internal/web/templates/login.html @@ -25,6 +25,30 @@
+ + {{if or .oauth2Enabled .ldapEnabled}} +
─── 或 ───
+ {{end}} + + {{if .ldapEnabled}} +
+
+ + +
+
+ + +
+ +
+ {{end}} + + {{if .oauth2Enabled}} + + {{if eq .oauth2Provider "google"}}Google{{else if eq .oauth2Provider "github"}}GitHub{{else}}OAuth2{{end}} 登录 + + {{end}}
diff --git a/main.go b/main.go index 9d0fb91..237669e 100644 --- a/main.go +++ b/main.go @@ -89,7 +89,7 @@ func main() { } // 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) go func() { if err := webServer.Start(); err != nil { diff --git a/win/etc/mail_go/mail_go.toml b/win/etc/mail_go/mail_go.toml index 3e3e720..eb2322c 100644 --- a/win/etc/mail_go/mail_go.toml +++ b/win/etc/mail_go/mail_go.toml @@ -28,3 +28,21 @@ tls_addr = ":995" tls_cert = "" 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 diff --git a/win/srv/mail_go/mail.db b/win/srv/mail_go/mail.db index ace7cc8..db696ef 100644 Binary files a/win/srv/mail_go/mail.db and b/win/srv/mail_go/mail.db differ