345 lines
8.8 KiB
Go
345 lines
8.8 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/storage"
|
|
"mail_go/internal/store"
|
|
|
|
"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
|
|
cfg config.SMTPConfig
|
|
}
|
|
|
|
// NewSMTPServer creates a new SMTP server instance.
|
|
func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer {
|
|
return &SMTPServer{stores: stores, storage: attStorage, cfg: cfg}
|
|
}
|
|
|
|
func (s *SMTPServer) tlsConfig() (*tls.Config, error) {
|
|
if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" {
|
|
return nil, fmt.Errorf("SMTP TLS certificate or key not configured")
|
|
}
|
|
|
|
cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load SMTP TLS certificate: %w", err)
|
|
}
|
|
return &tls.Config{Certificates: []tls.Certificate{cert}}, 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
|
|
authenticated bool
|
|
userID uint
|
|
email string
|
|
}
|
|
|
|
// 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.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 {
|
|
if !s.authenticated {
|
|
return smtp.ErrAuthRequired
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(from), s.email) {
|
|
return fmt.Errorf("sender address must match authenticated user")
|
|
}
|
|
}
|
|
|
|
s.from = from
|
|
s.rcpts = s.rcpts[:0]
|
|
return nil
|
|
}
|
|
|
|
// Rcpt validates and records a recipient address (RCPT TO command).
|
|
func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error {
|
|
if _, err := s.localUserByEmail(to); err != nil {
|
|
if s.authenticated {
|
|
return fmt.Errorf("external relay is not supported yet: %s", to)
|
|
}
|
|
return fmt.Errorf("relay access denied: %s", to)
|
|
}
|
|
|
|
s.rcpts = append(s.rcpts, 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.
|
|
func (s *smtpSession) Data(r io.Reader) error {
|
|
if len(s.rcpts) == 0 {
|
|
return fmt.Errorf("no accepted local 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
|
|
}
|
|
|
|
for _, rcpt := range s.rcpts {
|
|
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)
|
|
}
|
|
|
|
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 []*db.Attachment
|
|
}
|
|
|
|
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, &db.Attachment{
|
|
FileName: filename,
|
|
ContentType: contentType,
|
|
FileSize: int64(len(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,
|
|
}
|
|
return s.backend.server.stores.Mails.Create(msg)
|
|
}
|
|
|
|
// Reset clears the session state for the next message on the same connection.
|
|
func (s *smtpSession) Reset() {
|
|
s.from = ""
|
|
s.rcpts = s.rcpts[:0]
|
|
}
|
|
|
|
// Logout is called when the SMTP connection is closed.
|
|
func (s *smtpSession) Logout() error {
|
|
return nil
|
|
}
|