Files
mailgo/main.go
T
kevin d2b3caa81f fix(quota): 入站投递强制附件配额,删除路径清理附件并回退配额
安全审计 #19/#20(security_todo.md P1):

- 新增 store.PurgeMessages 共享清理入口:删附件文件、回退配额、
  删附件记录、删邮件记录,Web/IMAP/POP3 三条删除路径同源
- IMAP EXPUNGE(含 UID 分支)与 POP3 expungeDeleted 接入,修复
  协议删除只删记录导致的"幽灵配额"与附件文件泄漏
- Web purgeMessages 改为委托同一入口,消除三处重复实现
- SMTP 入站投递前按收件人原子预扣附件配额(TryReserveQuota),
  任一收件人超限整封拒收并返回 452 临时失败(避免 go-smtp 将
  普通 error 映射为 554 永久失败);Sent 副本尽力而为不回滚整封
- saveMessage 返回实际落库字节数,reconcileQuota 对账退还差额
  (覆盖收件人被删/落库失败/部分附件失败)
- NewIMAPServer/NewPOP3Server 注入 AttachmentStorage
- 新增 4 项单测(配额拒收/正常记账/对账回退/POP3 删除清理)
- security_todo.md 勾选 #19/#20,登记存量发现 #27(IncrementFail
  并发首建窗口丢失更新,待决策)
2026-08-28 13:59:50 +08:00

423 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"log"
"math/big"
"net"
"os"
"path/filepath"
"sync"
"time"
"mail_go/config"
"mail_go/internal/connhub"
"mail_go/internal/db"
"mail_go/internal/imap_server"
"mail_go/internal/outbound"
"mail_go/internal/pop3_server"
"mail_go/internal/smtp_server"
"mail_go/internal/storage"
"mail_go/internal/store"
"mail_go/internal/tlsutil"
"mail_go/internal/web"
"golang.org/x/crypto/bcrypt"
)
func applyDomainTLSConfig(stores *store.Stores, cfg *config.Config) {
domain, err := stores.Domains.GetFirstTLSEnabledWithCert()
if err != nil {
return
}
applied := applyTLSCertPaths(cfg, domain.TlsCertPath, domain.TlsKeyPath)
if applied {
log.Printf("使用域名 %s 的 TLS 证书;证书更新后自动热加载,无需重启服务", domain.Name)
}
}
func applyTLSCertPaths(cfg *config.Config, certPath, keyPath string) bool {
applied := false
if cfg.SMTP.TLSCert == "" && cfg.SMTP.TLSKey == "" {
cfg.SMTP.TLSCert = certPath
cfg.SMTP.TLSKey = keyPath
applied = true
}
if cfg.IMAP.TLSCert == "" && cfg.IMAP.TLSKey == "" {
cfg.IMAP.TLSCert = certPath
cfg.IMAP.TLSKey = keyPath
applied = true
}
if cfg.POP3.TLSCert == "" && cfg.POP3.TLSKey == "" {
cfg.POP3.TLSCert = certPath
cfg.POP3.TLSKey = keyPath
applied = true
}
return applied
}
// tlsSource 返回证书路径来源:协议在 toml 中显式配置的证书优先;
// 否则取第一个启用 TLS 且有证书的域名(管理后台一键导入证书后自动
// 切换,无需重启)。结果缓存 10 秒,避免每次握手都查询数据库。
func tlsSource(explicitCert, explicitKey string, stores *store.Stores) tlsutil.Source {
var (
mu sync.Mutex
lastCheck time.Time
cachedCert string
cachedKey string
)
return func() (string, string) {
mu.Lock()
defer mu.Unlock()
if time.Since(lastCheck) < 10*time.Second {
return cachedCert, cachedKey
}
lastCheck = time.Now()
if explicitCert != "" && explicitKey != "" {
cachedCert, cachedKey = explicitCert, explicitKey
} else if d, err := stores.Domains.GetFirstTLSEnabledWithCert(); err == nil {
cachedCert, cachedKey = d.TlsCertPath, d.TlsKeyPath
} else {
cachedCert, cachedKey = "", ""
}
return cachedCert, cachedKey
}
}
// newTLSCertLoader 创建带热加载的 TLS 证书加载器(每次握手自动重载)。
// 初始路径取显式配置或启动时填充的路径;source 允许后续动态切换
// 证书来源。加载失败返回 nil,对应协议将不启用 TLS。
func newTLSCertLoader(explicitCert, explicitKey, initCert, initKey string, stores *store.Stores, proto string) *tlsutil.Loader {
if initCert == "" || initKey == "" {
initCert, initKey = explicitCert, explicitKey
}
loader, err := tlsutil.NewLoader(initCert, initKey, tlsSource(explicitCert, explicitKey, stores), log.Printf)
if err != nil {
log.Printf("%s TLS 证书初始化失败: %v(该协议将不启用 TLS)", proto, err)
return nil
}
return loader
}
func ensureSelfSignedTLSConfig(cfg *config.Config) {
if cfg.SMTP.TLSCert != "" && cfg.SMTP.TLSKey != "" && cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" && cfg.POP3.TLSCert != "" && cfg.POP3.TLSKey != "" {
return
}
certPath := filepath.Join(cfg.Storage.BaseDir, "tls", "self-signed", "cert.pem")
keyPath := filepath.Join(cfg.Storage.BaseDir, "tls", "self-signed", "key.pem")
if err := ensureSelfSignedCert(certPath, keyPath, cfg.SMTP.Domain); err != nil {
log.Printf("生成自签名 TLS 证书失败: %v", err)
return
}
if applyTLSCertPaths(cfg, certPath, keyPath) {
log.Printf("未配置 TLS 证书,已使用自签名证书启动 TLS 端口;正式使用请在后台上传受信任证书")
}
}
func ensureSelfSignedCert(certPath, keyPath, domain string) error {
if _, certErr := os.Stat(certPath); certErr == nil {
if _, keyErr := os.Stat(keyPath); keyErr == nil {
return nil
}
}
if err := os.MkdirAll(filepath.Dir(certPath), 0700); err != nil {
return err
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return err
}
notBefore := time.Now()
serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialLimit)
if err != nil {
return err
}
if domain == "" {
domain = "localhost"
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"MailGo Self-Signed"},
CommonName: domain,
},
NotBefore: notBefore,
NotAfter: notBefore.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{domain, "localhost"},
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return err
}
certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
certFile.Close()
return err
}
if err := certFile.Close(); err != nil {
return err
}
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
if err := pem.Encode(keyFile, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)}); err != nil {
keyFile.Close()
return err
}
return keyFile.Close()
}
func main() {
// 1. Load configuration
cfg, err := config.LoadConfig()
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
fmt.Println("配置加载成功")
// 2. Initialize database
database, err := db.InitDB(cfg.Database, cfg.Storage)
if err != nil {
log.Fatalf("数据库初始化失败: %v", err)
}
fmt.Println("数据库初始化成功")
// 3. Create Store layer
stores := store.NewStores(database)
// 4. Ensure default admin user exists
ensureAdminUser(stores, cfg)
// 5. Initialize attachment storage
attStorage := storage.NewAttachmentStorage(cfg.Storage.AttachDir)
// 记录 toml 中显式配置的证书路径;此后 applyDomainTLSConfig 会用
// 域名证书填充空值,需要原始值来判断“显式配置优先”。
explicitSMTPCert, explicitSMTPKey := cfg.SMTP.TLSCert, cfg.SMTP.TLSKey
explicitIMAPCert, explicitIMAPKey := cfg.IMAP.TLSCert, cfg.IMAP.TLSKey
explicitPOP3Cert, explicitPOP3Key := cfg.POP3.TLSCert, cfg.POP3.TLSKey
applyDomainTLSConfig(stores, cfg)
ensureSelfSignedTLSConfig(cfg)
// 证书热加载器:每次 TLS 握手自动重载证书文件,证书更新后无需重启
smtpTLS := newTLSCertLoader(explicitSMTPCert, explicitSMTPKey, cfg.SMTP.TLSCert, cfg.SMTP.TLSKey, stores, "SMTP")
imapTLS := newTLSCertLoader(explicitIMAPCert, explicitIMAPKey, cfg.IMAP.TLSCert, cfg.IMAP.TLSKey, stores, "IMAP")
pop3TLS := newTLSCertLoader(explicitPOP3Cert, explicitPOP3Key, cfg.POP3.TLSCert, cfg.POP3.TLSKey, stores, "POP3")
// 6. Outbound delivery manager (external mail queue + worker)
outboundMgr := outbound.NewManager(cfg.Outbound, cfg.SMTP.Domain, stores)
if outboundMgr.Enabled() {
outboundMgr.Start()
fmt.Println("外发邮件投递服务已启动")
} else {
fmt.Println("外发邮件投递未启用(outbound.max_per_day = 0")
}
// 6. 连接注册中心(后台「当前连接」页 + IMAP 新邮件推送)
connHub := connhub.New()
// 7. Start IMAP server(先于 SMTP 创建,SMTP 投递成功时通知其推送)
imapSrv := imap_server.NewIMAPServer(cfg.IMAP, stores, imapTLS, cfg.Ban, connHub, attStorage)
go func() {
if err := imapSrv.Start(); err != nil {
log.Printf("IMAP 服务启动失败: %v", err)
}
}()
// Start IMAPS if TLS is configured
if cfg.IMAP.TLSCert != "" && cfg.IMAP.TLSKey != "" {
go func() {
if err := imapSrv.StartTLS(); err != nil {
log.Printf("IMAPS 服务启动失败: %v", err)
}
}()
}
// 8. Start SMTP server(本地投递成功后触发 IMAP 新邮件推送)
smtpSrv := smtp_server.NewSMTPServer(cfg.SMTP, stores, attStorage, outboundMgr, smtpTLS, cfg.Ban, connHub, imapSrv)
go func() {
if err := smtpSrv.Start(); err != nil {
log.Printf("SMTP 服务启动失败: %v", err)
}
}()
// Start SMTPS and submission if TLS is configured
if cfg.SMTP.TLSCert != "" && cfg.SMTP.TLSKey != "" {
go func() {
if err := smtpSrv.StartTLS(); err != nil {
log.Printf("SMTPS 服务启动失败: %v", err)
}
}()
go func() {
if err := smtpSrv.StartSubmission(); err != nil {
log.Printf("SMTP Submission 服务启动失败: %v", err)
}
}()
}
// 9. Start POP3 server
pop3Srv := pop3_server.NewPOP3Server(cfg.POP3, stores, pop3TLS, cfg.Ban, connHub, imapSrv, attStorage)
go func() {
if err := pop3Srv.Start(); err != nil {
log.Printf("POP3 服务启动失败: %v", err)
}
}()
// Start POP3S if TLS is configured
if cfg.POP3.TLSCert != "" && cfg.POP3.TLSKey != "" {
go func() {
if err := pop3Srv.StartTLS(); err != nil {
log.Printf("POP3S 服务启动失败: %v", err)
}
}()
}
// 10. Start Web server(本地写信投递成功后同样触发 IMAP 新邮件推送)
webServer, err := web.NewWebServer(cfg.Web, stores, attStorage, cfg.Storage, cfg.Auth, cfg.Ban, cfg.Caddy, outboundMgr, connHub, imapSrv)
if err != nil {
log.Fatalf("Web 服务初始化失败: %v", err)
}
fmt.Printf("Web 服务启动在 %s\n", cfg.Web.Addr)
go func() {
if err := webServer.Start(); err != nil {
log.Fatalf("Web 服务启动失败: %v", err)
}
}()
// 11. 后台定期清理过期的协议调用日志(SMTP/IMAP/POP3
startProtocolLogCleaner(stores, cfg.Web.ProtocolLogKeepDays)
fmt.Println("MailGo 邮件系统启动完成")
select {} // Block main goroutine
}
// startProtocolLogCleaner 每 6 小时清理一次超出保留天数的协议调用日志。
// keepDays <= 0 表示不清理。
func startProtocolLogCleaner(stores *store.Stores, keepDays int) {
if keepDays <= 0 {
return
}
go func() {
for {
n, err := stores.ProtocolLogs.CleanupBefore(time.Now().AddDate(0, 0, -keepDays))
if err != nil {
log.Printf("清理协议日志失败: %v", err)
} else if n > 0 {
log.Printf("已清理 %d 条过期协议日志(保留 %d 天)", n, keepDays)
}
time.Sleep(6 * time.Hour)
}
}()
}
// ensureAdminUser checks if an admin user exists and creates one if not.
// It also ensures the default domain "example.com" exists.
func ensureAdminUser(stores *store.Stores, cfg *config.Config) {
// Check if admin user exists by trying to authenticate
_, err := stores.Users.GetByEmail("admin@example.com")
if err == nil {
fmt.Println("管理员账户已存在,跳过创建")
return
}
// Ensure the default domain exists
domain, err := stores.Domains.GetByName("example.com")
if err != nil {
// Domain doesn't exist, create it
domain = &db.Domain{
Name: "example.com",
SmtpPort: 25,
ImapPort: 143,
Pop3Port: 110,
TlsEnabled: false,
}
if createErr := stores.Domains.Create(domain); createErr != nil {
log.Printf("创建默认域名失败: %v", createErr)
return
}
fmt.Println("默认域名 example.com 创建成功")
}
// 初始密码:优先取环境变量 MAILGO_ADMIN_PASSWORD
// 否则生成随机密码并打印一次(只能在本机启动日志中看到)。
// 无论哪种方式都会标记首次登录必须改密,杜绝默认口令。
adminPassword := os.Getenv("MAILGO_ADMIN_PASSWORD")
generated := false
if adminPassword == "" {
adminPassword = randomPassword()
generated = true
}
// Hash the admin password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
if err != nil {
log.Printf("密码哈希失败: %v", err)
return
}
// Create the admin user
adminUser := &db.User{
Username: "admin",
PasswordHash: string(hashedPassword),
DomainID: domain.ID,
QuotaBytes: 5 * 1024 * 1024 * 1024, // 5GB
UsedBytes: 0,
IsActive: true,
IsAdmin: true,
MustChangePassword: true,
}
if createErr := stores.Users.Create(adminUser); createErr != nil {
log.Printf("创建管理员账户失败: %v", createErr)
return
}
if generated {
fmt.Printf("管理员账户 admin@example.com 创建成功,初始密码: %s\n", adminPassword)
} else {
fmt.Println("管理员账户 admin@example.com 创建成功(密码来自 MAILGO_ADMIN_PASSWORD")
}
fmt.Println("安全提示:该账户已被标记为“首次登录必须修改密码”,请登录后立即在 设置 页面修改。")
}
// randomPassword 生成 16 位随机密码(数字+大小写字母),用于初始管理员账户。
func randomPassword() string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
buf := make([]byte, 16)
if _, err := rand.Read(buf); err != nil {
// crypto/rand 失败极罕见;退化为时间种子以避免空密码
log.Printf("生成随机密码失败: %v,使用弱随机回退", err)
n := time.Now().UnixNano()
for i := range buf {
buf[i] = charset[(n>>(uint(i)*4))%int64(len(charset))]
}
return string(buf)
}
for i := range buf {
buf[i] = charset[int(buf[i])%len(charset)]
}
return string(buf)
}