- 域名编辑页新增“从 Caddy 获取证书”按钮:一键把本机 Caddy 已签发的 证书与私钥导入该域名的 TLS 目录并自动启用 TLS,支持通配符证书 (如 *.example.com 可匹配 mail.example.com),成功/失败均回显横幅 - 新增 internal/caddycert:搜索 Caddy 证书存储(同步镜像目录优先、 caddy.data_dir 与常见位置兜底),校验密钥对/有效期/SAN,并给出 可操作的中文错误提示(未找到/证书无效/权限不足) - install.sh 新增 setup-caddy-cert:安装 root 权限的 systemd path+timer 同步任务(mailgo-caddy-sync),把 Caddy 证书树镜像到 /srv/mail_go/tls/caddy(证书续期后自动更新、每日兜底),另授予 ACL 作为直接读取兜底;install 时自动检测并配置 - 新增 [caddy] data_dir 配置节,支持自定义 Caddy 数据目录 - 新增 internal/tlsutil:TLS 证书热加载器,每次握手按需重载证书 文件(mtime 检测),重载失败继续使用旧证书兜底并节流重试; 应用于 SMTPS 465/IMAPS 993/POP3S 995 与 STARTTLS,导入或上传 新证书后无需重启服务即生效 - 证书来源动态切换:协议显式配置优先,否则取首个启用 TLS 且有证书 的域名(10 秒缓存),新域名一键导入证书后自动切换 - 更新 README 与界面文案(去掉“重启服务生效”提示)
425 lines
12 KiB
Go
425 lines
12 KiB
Go
package smtp_server
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"strings"
|
|
"time"
|
|
|
|
"mail_go/config"
|
|
"mail_go/internal/db"
|
|
"mail_go/internal/mailutil"
|
|
"mail_go/internal/outbound"
|
|
"mail_go/internal/storage"
|
|
"mail_go/internal/store"
|
|
"mail_go/internal/tlsutil"
|
|
|
|
"github.com/emersion/go-message/mail"
|
|
"github.com/emersion/go-sasl"
|
|
"github.com/emersion/go-smtp"
|
|
)
|
|
|
|
type smtpMode int
|
|
|
|
const (
|
|
smtpModeInbound smtpMode = iota
|
|
smtpModeSubmission
|
|
smtpModeImplicitTLS
|
|
)
|
|
|
|
// SMTPServer wraps go-smtp servers and provides local mail delivery.
|
|
type SMTPServer struct {
|
|
stores *store.Stores
|
|
storage *storage.AttachmentStorage
|
|
outbound *outbound.Manager
|
|
cfg config.SMTPConfig
|
|
tlsLoader *tlsutil.Loader
|
|
}
|
|
|
|
// NewSMTPServer creates a new SMTP server instance. tlsLoader may be nil
|
|
// when TLS is not configured.
|
|
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage, ob *outbound.Manager, tlsLoader *tlsutil.Loader) *SMTPServer {
|
|
return &SMTPServer{stores: stores, storage: attStorage, outbound: ob, cfg: cfg, tlsLoader: tlsLoader}
|
|
}
|
|
|
|
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
|
if s.tlsLoader == nil {
|
|
return nil, fmt.Errorf("SMTP TLS certificate or key not configured")
|
|
}
|
|
// GetCertificate 每次握手按需重载证书,证书更新后无需重启服务
|
|
return &tls.Config{GetCertificate: s.tlsLoader.GetCertificate}, nil
|
|
}
|
|
|
|
func (s *SMTPServer) newServer(addr string, mode smtpMode, tlsConfig *tls.Config) *smtp.Server {
|
|
be := &smtpBackend{server: s, mode: mode}
|
|
srv := smtp.NewServer(be)
|
|
srv.Addr = addr
|
|
srv.Domain = s.cfg.Domain
|
|
srv.MaxMessageBytes = s.cfg.MaxMessage
|
|
srv.AllowInsecureAuth = tlsConfig == nil
|
|
srv.ReadTimeout = 60 * time.Second
|
|
srv.WriteTimeout = 60 * time.Second
|
|
srv.TLSConfig = tlsConfig
|
|
return srv
|
|
}
|
|
|
|
// Start starts the inbound SMTP server.
|
|
func (s *SMTPServer) Start() error {
|
|
tlsConfig, err := s.tlsConfig()
|
|
if err != nil {
|
|
log.Printf("SMTP STARTTLS 未启用: %v", err)
|
|
}
|
|
|
|
log.Printf("SMTP server listening on %s", s.cfg.Addr)
|
|
return s.newServer(s.cfg.Addr, smtpModeInbound, tlsConfig).ListenAndServe()
|
|
}
|
|
|
|
// StartTLS starts the implicit TLS SMTP submission server.
|
|
func (s *SMTPServer) StartTLS() error {
|
|
tlsConfig, err := s.tlsConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("SMTPS server listening on %s", s.cfg.TLSAddr)
|
|
return s.newServer(s.cfg.TLSAddr, smtpModeImplicitTLS, tlsConfig).ListenAndServeTLS()
|
|
}
|
|
|
|
// StartSubmission starts the SMTP submission server with STARTTLS support.
|
|
func (s *SMTPServer) StartSubmission() error {
|
|
tlsConfig, err := s.tlsConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
log.Printf("SMTP submission server listening on %s", s.cfg.SubmissionAddr)
|
|
return s.newServer(s.cfg.SubmissionAddr, smtpModeSubmission, tlsConfig).ListenAndServe()
|
|
}
|
|
|
|
// smtpBackend implements the smtp.Backend interface.
|
|
type smtpBackend struct {
|
|
server *SMTPServer
|
|
mode smtpMode
|
|
}
|
|
|
|
// NewSession creates a new SMTP session for the incoming connection.
|
|
func (be *smtpBackend) NewSession(c *smtp.Conn) (smtp.Session, error) {
|
|
return &smtpSession{
|
|
backend: be,
|
|
mode: be.mode,
|
|
rcpts: make([]string, 0),
|
|
}, nil
|
|
}
|
|
|
|
// smtpSession implements the smtp.Session interface for handling a single connection.
|
|
type smtpSession struct {
|
|
backend *smtpBackend
|
|
mode smtpMode
|
|
from string
|
|
rcpts []string
|
|
localRcpts []string
|
|
externalRcpts []string
|
|
authenticated bool
|
|
userID uint
|
|
email string
|
|
user *db.User
|
|
}
|
|
|
|
// AuthMechanisms returns supported SMTP AUTH mechanisms.
|
|
func (s *smtpSession) AuthMechanisms() []string {
|
|
return []string{sasl.Plain}
|
|
}
|
|
|
|
// Auth authenticates the user with SASL PLAIN credentials.
|
|
func (s *smtpSession) Auth(mech string) (sasl.Server, error) {
|
|
if mech != sasl.Plain {
|
|
return nil, smtp.ErrAuthUnknownMechanism
|
|
}
|
|
return sasl.NewPlainServer(func(identity, username, password string) error {
|
|
user, err := s.backend.server.stores.Users.Authenticate(username, password)
|
|
if err != nil {
|
|
return smtp.ErrAuthFailed
|
|
}
|
|
|
|
domainName := user.Domain.Name
|
|
if domainName == "" {
|
|
domain, err := s.backend.server.stores.Domains.GetByID(user.DomainID)
|
|
if err == nil {
|
|
domainName = domain.Name
|
|
}
|
|
}
|
|
if domainName == "" {
|
|
return smtp.ErrAuthFailed
|
|
}
|
|
|
|
s.authenticated = true
|
|
s.userID = user.ID
|
|
s.user = user
|
|
s.email = user.Username + "@" + domainName
|
|
return nil
|
|
}), nil
|
|
}
|
|
|
|
// Mail records the sender address (MAIL FROM command).
|
|
func (s *smtpSession) Mail(from string, opts *smtp.MailOptions) error {
|
|
if s.mode != smtpModeInbound && !s.authenticated {
|
|
return smtp.ErrAuthRequired
|
|
}
|
|
// Authenticated users may only send as themselves, preventing spoofing.
|
|
if s.authenticated && !strings.EqualFold(strings.TrimSpace(from), s.email) {
|
|
return fmt.Errorf("sender address must match authenticated user")
|
|
}
|
|
|
|
s.from = from
|
|
s.rcpts = s.rcpts[:0]
|
|
s.localRcpts = s.localRcpts[:0]
|
|
s.externalRcpts = s.externalRcpts[:0]
|
|
return nil
|
|
}
|
|
|
|
// Rcpt validates and records a recipient address (RCPT TO command).
|
|
// Local recipients are delivered to the mailbox; external recipients are
|
|
// allowed only for authenticated users and go to the outbound queue,
|
|
// which prevents open relay.
|
|
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
|
to = strings.TrimSpace(to)
|
|
if to == "" {
|
|
return fmt.Errorf("invalid recipient address: %s", to)
|
|
}
|
|
|
|
if _, err := s.localUserByEmail(to); err == nil {
|
|
s.rcpts = append(s.rcpts, to)
|
|
s.localRcpts = append(s.localRcpts, to)
|
|
return nil
|
|
}
|
|
|
|
// External recipient: only authenticated local users may relay.
|
|
if !s.authenticated {
|
|
return fmt.Errorf("relay access denied: %s", to)
|
|
}
|
|
|
|
// Sender verification must have been enforced in Mail() already.
|
|
ob := s.backend.server.outbound
|
|
if ob == nil || !ob.Enabled() {
|
|
return fmt.Errorf("external delivery is disabled: %s", to)
|
|
}
|
|
|
|
s.rcpts = append(s.rcpts, to)
|
|
s.externalRcpts = append(s.externalRcpts, to)
|
|
return nil
|
|
}
|
|
|
|
func (s *smtpSession) localUserByEmail(email string) (*db.User, error) {
|
|
return s.backend.server.stores.Users.GetByEmail(strings.TrimSpace(email))
|
|
}
|
|
|
|
// Data handles the message body and stores it for local recipients.
|
|
// External recipients (authenticated sessions only) are queued for
|
|
// outbound delivery.
|
|
func (s *smtpSession) Data(r io.Reader) error {
|
|
if len(s.rcpts) == 0 {
|
|
return fmt.Errorf("no accepted recipients")
|
|
}
|
|
|
|
data, err := io.ReadAll(r)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read message data: %w", err)
|
|
}
|
|
|
|
parsed, err := parseSMTPMessage(data)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Local recipients: deliver to INBOX.
|
|
for _, rcpt := range s.localRcpts {
|
|
user, err := s.localUserByEmail(rcpt)
|
|
if err != nil {
|
|
log.Printf("SMTP: recipient not found %s, skipping", rcpt)
|
|
continue
|
|
}
|
|
if err := s.saveMessage(user.ID, "INBOX", parsed, data, false); err != nil {
|
|
log.Printf("SMTP: failed to create message for %s: %v", rcpt, err)
|
|
continue
|
|
}
|
|
log.Printf("SMTP: message delivered to %s", rcpt)
|
|
}
|
|
|
|
// External recipients: queue for outbound delivery.
|
|
if len(s.externalRcpts) > 0 {
|
|
ob := s.backend.server.outbound
|
|
if ob == nil {
|
|
return fmt.Errorf("outbound delivery is unavailable")
|
|
}
|
|
maxRcpt := ob.MaxRecipients()
|
|
if maxRcpt > 0 && len(s.externalRcpts) > maxRcpt {
|
|
return fmt.Errorf("too many external recipients: %d (max %d)", len(s.externalRcpts), maxRcpt)
|
|
}
|
|
for _, rcpt := range s.externalRcpts {
|
|
if _, err := ob.Enqueue(s.user, s.email, rcpt, data); err != nil {
|
|
return fmt.Errorf("failed to queue external recipient %s: %v", rcpt, err)
|
|
}
|
|
log.Printf("SMTP: external message queued for %s", rcpt)
|
|
}
|
|
}
|
|
|
|
if s.authenticated && s.userID != 0 && s.mode != smtpModeInbound {
|
|
if err := s.saveMessage(s.userID, "Sent", parsed, data, true); err != nil {
|
|
log.Printf("SMTP: failed to save sent copy for %s: %v", s.email, err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type parsedSMTPMessage struct {
|
|
messageID string
|
|
fromAddr string
|
|
toAddr string
|
|
ccAddr string
|
|
subject string
|
|
textBody string
|
|
htmlBody string
|
|
date time.Time
|
|
attachments []*parsedAttachment
|
|
}
|
|
|
|
// parsedAttachment holds an extracted MIME attachment part.
|
|
type parsedAttachment struct {
|
|
fileName string
|
|
contentType string
|
|
data []byte
|
|
}
|
|
|
|
func parseSMTPMessage(data []byte) (*parsedSMTPMessage, error) {
|
|
mr, err := mail.CreateReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse MIME message: %w", err)
|
|
}
|
|
|
|
header := mr.Header
|
|
msg := &parsedSMTPMessage{}
|
|
msg.fromAddr = mailutil.FormatAddressList(&header, "From")
|
|
msg.toAddr = mailutil.FormatAddressList(&header, "To")
|
|
msg.ccAddr = mailutil.FormatAddressList(&header, "Cc")
|
|
msg.subject, _ = header.Subject()
|
|
msg.messageID, _ = header.MessageID()
|
|
msg.date, _ = header.Date()
|
|
if msg.date.IsZero() {
|
|
msg.date = time.Now()
|
|
}
|
|
|
|
for {
|
|
p, err := mr.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
log.Printf("SMTP: error reading MIME part: %v", err)
|
|
break
|
|
}
|
|
|
|
switch h := p.Header.(type) {
|
|
case *mail.InlineHeader:
|
|
contentType, params, _ := h.ContentType()
|
|
buf, readErr := io.ReadAll(p.Body)
|
|
if readErr != nil {
|
|
log.Printf("SMTP: error reading inline part: %v", readErr)
|
|
continue
|
|
}
|
|
charset := ""
|
|
if cs, ok := params["charset"]; ok {
|
|
charset = cs
|
|
}
|
|
decoded := mailutil.DecodeCharset(buf, charset)
|
|
if strings.HasPrefix(contentType, "text/plain") {
|
|
msg.textBody = decoded
|
|
} else if strings.HasPrefix(contentType, "text/html") {
|
|
msg.htmlBody = decoded
|
|
}
|
|
|
|
case *mail.AttachmentHeader:
|
|
filename, _ := h.Filename()
|
|
if filename == "" {
|
|
filename = "unnamed_attachment"
|
|
}
|
|
contentType, _, _ := h.ContentType()
|
|
buf, readErr := io.ReadAll(p.Body)
|
|
if readErr != nil {
|
|
log.Printf("SMTP: error reading attachment part: %v", readErr)
|
|
continue
|
|
}
|
|
msg.attachments = append(msg.attachments, &parsedAttachment{
|
|
fileName: filename,
|
|
contentType: contentType,
|
|
data: buf,
|
|
})
|
|
}
|
|
}
|
|
|
|
if msg.textBody == "" && msg.htmlBody == "" {
|
|
msg.textBody = string(data)
|
|
}
|
|
return msg, nil
|
|
}
|
|
|
|
func (s *smtpSession) saveMessage(userID uint, folder string, parsed *parsedSMTPMessage, data []byte, read bool) error {
|
|
msg := &db.Message{
|
|
UserID: userID,
|
|
MessageID: parsed.messageID,
|
|
Folder: folder,
|
|
FromAddr: parsed.fromAddr,
|
|
ToAddr: parsed.toAddr,
|
|
CcAddr: parsed.ccAddr,
|
|
Subject: parsed.subject,
|
|
TextBody: parsed.textBody,
|
|
HtmlBody: parsed.htmlBody,
|
|
RawData: string(data),
|
|
IsRead: read,
|
|
IsFlagged: false,
|
|
Date: parsed.date,
|
|
}
|
|
if err := s.backend.server.stores.Mails.Create(msg); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Persist attachments to disk and link them to the message so that the
|
|
// Web mail UI can list/download them and quota accounting stays correct.
|
|
for _, att := range parsed.attachments {
|
|
relPath, err := s.backend.server.storage.Save(att.fileName, att.data)
|
|
if err != nil {
|
|
log.Printf("SMTP: failed to save attachment %s: %v", att.fileName, err)
|
|
continue
|
|
}
|
|
rec := &db.Attachment{
|
|
MessageID: msg.ID,
|
|
FileName: att.fileName,
|
|
FilePath: relPath,
|
|
ContentType: att.contentType,
|
|
FileSize: int64(len(att.data)),
|
|
}
|
|
if err := s.backend.server.stores.Attachments.Create(rec); err != nil {
|
|
log.Printf("SMTP: failed to create attachment record: %v", err)
|
|
continue
|
|
}
|
|
_ = s.backend.server.stores.Users.UpdateUsedBytes(userID, rec.FileSize)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Reset clears the session state for the next message on the same connection.
|
|
func (s *smtpSession) Reset() {
|
|
s.from = ""
|
|
s.rcpts = s.rcpts[:0]
|
|
s.localRcpts = s.localRcpts[:0]
|
|
s.externalRcpts = s.externalRcpts[:0]
|
|
}
|
|
|
|
// Logout is called when the SMTP connection is closed.
|
|
func (s *smtpSession) Logout() error {
|
|
return nil
|
|
}
|