二阶段差不多

This commit is contained in:
2026-06-01 19:46:51 +08:00
parent 9e50d05e71
commit 4e233c82b4
34 changed files with 1631 additions and 67 deletions
+18
View File
@@ -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 UsedBytesSMTP收信也更新配额
- 富文本编辑器: 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 IPadmin /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 通过
+22
View File
@@ -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,同步更新UsedBytesSMTP收信也更新
- 富文本: 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 AuthConfigOAuth2+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 是betaAPI不稳定,必须用v1
- Go filepath.Glob 不支持 ** 递归匹配,模板分两轮加载
+43
View File
@@ -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
}
+5
View File
@@ -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
+10
View File
@@ -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=
+98
View File
@@ -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
}
+142
View File
@@ -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)
)
+9
View File
@@ -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
View File
@@ -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)
}
+27 -10
View File
@@ -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"`
+42
View File
@@ -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))
}
+2
View File
@@ -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)
+115
View File
@@ -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
}
+95
View File
@@ -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
}
+3
View File
@@ -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{}
+239 -17
View File
@@ -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)
+244 -9
View File
@@ -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
}
+89 -6
View File
@@ -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])
}
+26
View File
@@ -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
View File
@@ -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)
}
}
+80
View File
@@ -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}}
+46 -3
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin" class="active">控制面板</a>
<a href="/admin/domains">域名管理</a>
<a href="/admin/users">用户管理</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">
<h2 style="margin-bottom:24px;">管理后台</h2>
@@ -28,12 +30,53 @@
<h3>{{.userCount}}</h3>
<p>用户数</p>
</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 class="card">
<h3>快捷操作</h3>
<p style="margin-top:12px;">
<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/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>
</div>
</div>
+11 -5
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin">控制面板</a>
<a href="/admin/domains" class="active">域名管理</a>
<a href="/admin/users">用户管理</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">
@@ -28,8 +30,12 @@
<h4 style="margin-bottom:8px;">SPF 记录 (TXT)</h4>
<div class="dns-record">@ IN TXT "v=spf1 mx -all"</div>
<h4 style="margin-bottom:8px;">DKIM 记录</h4>
<div class="dns-record" style="color:#e67e22;">⚠️ 请在域名配置页配置 DKIM 私钥路径后,将自动生成 DKIM 公钥记录。</div>
<h4 style="margin-bottom:8px;">DKIM 记录 (TXT)</h4>
{{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>
<div class="dns-record">_dmarc.{{.domain.Name}}. IN TXT "v=DMARC1; p=none; rua=mailto:postmaster@{{.domain.Name}}"</div>
+22 -3
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin">控制面板</a>
<a href="/admin/domains" class="active">域名管理</a>
<a href="/admin/users">用户管理</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">
@@ -24,7 +26,11 @@
<form method="POST" action="{{if .isEdit}}/admin/domains/{{.domain.ID}}{{else}}/admin/domains{{end}}">
<div class="form-group">
<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">
{{end}}
</div>
<div class="form-group">
<label>SMTP 端口</label>
@@ -44,6 +50,19 @@
启用 TLS
</label>
</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>
<a href="/admin/domains" class="btn" style="margin-left:8px;background:#bdc3c7;color:#fff;">取消</a>
</form>
+6 -3
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin">控制面板</a>
<a href="/admin/domains" class="active">域名管理</a>
<a href="/admin/users">用户管理</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">
@@ -48,6 +50,7 @@
<td>{{.Pop3Port}}</td>
<td>{{if .TlsEnabled}}✅{{else}}❌{{end}}</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>
<form method="POST" action="/admin/domains/{{.ID}}/delete" style="display:inline;"
onsubmit="return confirm('确定要删除域名 {{.Name}} 吗?删除域名将导致关联用户无法收发邮件!');">
+77
View File
@@ -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}}
+5 -3
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin">控制面板</a>
<a href="/admin/domains">域名管理</a>
<a href="/admin/users" class="active">用户管理</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">
+5 -3
View File
@@ -13,9 +13,11 @@
<div class="clearfix">
<div class="sidebar">
<a href="/inbox">返回邮箱</a>
<a href="/admin">控制面板</a>
<a href="/admin/domains">域名管理</a>
<a href="/admin/users" class="active">用户管理</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">
+54
View File
@@ -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}}
+31 -1
View File
@@ -5,6 +5,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>撰写邮件 - MailGo</title>
<link href="https://cdn.quilljs.com/1.3.7/quill.snow.css" rel="stylesheet">
{{template "styles" .}}
</head>
<body>
@@ -37,12 +38,17 @@
</div>
<div class="form-group">
<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 class="form-group">
<label>附件</label>
<input type="file" name="attachments" multiple>
</div>
<div class="form-group" style="color:#7f8c8d;font-size:12px;">
配额: {{formatBytes .usedBytes}} / {{formatBytes .quotaBytes}}
</div>
<button type="submit" class="btn btn-primary">发送邮件</button>
<a href="/inbox" class="btn" style="margin-left:8px;">取消</a>
</form>
@@ -50,6 +56,30 @@
</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>
</html>
{{end}}
+24
View File
@@ -25,6 +25,30 @@
</div>
<button type="submit" class="btn btn-primary" style="width:100%;">登录</button>
</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>
+1 -1
View File
@@ -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 {
+18
View File
@@ -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
Binary file not shown.