diff --git a/config/config.go b/config/config.go index 8e78ade..71a7fc6 100644 --- a/config/config.go +++ b/config/config.go @@ -17,8 +17,8 @@ type DatabaseConfig struct { // StorageConfig holds file storage paths. type StorageConfig struct { - BaseDir string `toml:"base_dir"` - AttachDir string `toml:"attach_dir"` + BaseDir string `toml:"base_dir"` + AttachDir string `toml:"attach_dir"` } // WebConfig holds web server settings. @@ -28,12 +28,13 @@ type WebConfig struct { // SMTPConfig holds SMTP server settings. type SMTPConfig struct { - Addr string `toml:"addr"` - TLSAddr string `toml:"tls_addr"` - Domain string `toml:"domain"` - TLSCert string `toml:"tls_cert"` - TLSKey string `toml:"tls_key"` - MaxMessage int64 `toml:"max_message_bytes"` + Addr string `toml:"addr"` + TLSAddr string `toml:"tls_addr"` + SubmissionAddr string `toml:"submission_addr"` + Domain string `toml:"domain"` + TLSCert string `toml:"tls_cert"` + TLSKey string `toml:"tls_key"` + MaxMessage int64 `toml:"max_message_bytes"` } // IMAPConfig holds IMAP server settings. @@ -56,15 +57,15 @@ type POP3Config struct { type AuthConfig struct { // OAuth2 configuration OAuth2Enabled bool `toml:"oauth2_enabled"` - OAuth2Provider string `toml:"oauth2_provider"` // google, github, gitlab + 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 + 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) @@ -134,10 +135,11 @@ func defaultConfig() *Config { Addr: DefaultWebPort, }, SMTP: SMTPConfig{ - Addr: fmt.Sprintf(":%d", DefaultSMTPPort), - TLSAddr: fmt.Sprintf(":%d", DefaultSMTPTLSPort), - Domain: "localhost", - MaxMessage: 64 * 1024 * 1024, // 64MB + Addr: fmt.Sprintf(":%d", DefaultSMTPPort), + TLSAddr: fmt.Sprintf(":%d", DefaultSMTPTLSPort), + SubmissionAddr: fmt.Sprintf(":%d", DefaultSMTPSubmitPort), + Domain: "localhost", + MaxMessage: 64 * 1024 * 1024, // 64MB }, IMAP: IMAPConfig{ Addr: fmt.Sprintf(":%d", DefaultIMAPPort), @@ -186,6 +188,9 @@ func mergeDefaults(cfg *Config, defaults *Config) *Config { if cfg.SMTP.TLSAddr == "" { cfg.SMTP.TLSAddr = defaults.SMTP.TLSAddr } + if cfg.SMTP.SubmissionAddr == "" { + cfg.SMTP.SubmissionAddr = defaults.SMTP.SubmissionAddr + } if cfg.SMTP.Domain == "" { cfg.SMTP.Domain = defaults.SMTP.Domain } diff --git a/config/defaults.go b/config/defaults.go index af3d959..13850ec 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -2,8 +2,8 @@ package config // Linux path prefixes const ( - LinuxEtcDir = "/etc/mail_go/" - LinuxBaseDir = "/srv/mail_go/" + LinuxEtcDir = "/etc/mail_go/" + LinuxBaseDir = "/srv/mail_go/" ) // Windows path prefixes @@ -16,6 +16,7 @@ const ( const ( DefaultSMTPPort = 25 DefaultSMTPTLSPort = 465 + DefaultSMTPSubmitPort = 587 DefaultIMAPPort = 143 DefaultIMAPTLSPort = 993 DefaultPOP3Port = 110 diff --git a/internal/imap_server/backend.go b/internal/imap_server/backend.go index ec3e902..efbbe9c 100644 --- a/internal/imap_server/backend.go +++ b/internal/imap_server/backend.go @@ -1,6 +1,7 @@ package imap_server import ( + "bufio" "bytes" "fmt" "io" @@ -15,7 +16,9 @@ import ( "github.com/emersion/go-imap" "github.com/emersion/go-imap/backend" + "github.com/emersion/go-imap/backend/backendutil" asgomail "github.com/emersion/go-message/mail" + "github.com/emersion/go-message/textproto" ) // ---------- imapBackend ---------- @@ -67,7 +70,7 @@ func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) { attributes []string }{ {"INBOX", "/", nil}, - {"Sent", "/", nil}, + {"Sent", "/", []string{"\\Sent"}}, {"Drafts", "/", []string{"\\Drafts"}}, {"Trash", "/", []string{"\\Trash"}}, } @@ -87,25 +90,8 @@ func (u *imapUser) ListMailboxes(subscribed bool) ([]backend.Mailbox, error) { // GetMailbox returns a mailbox by name. func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) { - validNames := map[string]bool{ - "INBOX": true, - "Sent": true, - "Drafts": true, - "Trash": true, - } - - normalized := name - upper := strings.ToUpper(name) - if upper == "INBOX" { - normalized = "INBOX" - } else if validNames[upper] { - normalized = upper - } else { - // Try title case - normalized = strings.Title(strings.ToLower(name)) - } - - if !validNames[normalized] { + normalized, ok := canonicalMailboxName(name) + if !ok { return nil, backend.ErrNoSuchMailbox } @@ -117,6 +103,21 @@ func (u *imapUser) GetMailbox(name string) (backend.Mailbox, error) { }, nil } +func canonicalMailboxName(name string) (string, bool) { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "INBOX": + return "INBOX", true + case "SENT": + return "Sent", true + case "DRAFTS": + return "Drafts", true + case "TRASH": + return "Trash", true + default: + return "", false + } +} + // CreateMailbox creates a new mailbox (not supported in this version). func (u *imapUser) CreateMailbox(name string) error { return fmt.Errorf("mailbox creation not supported") @@ -170,11 +171,9 @@ func (m *imapMailbox) Info() (*imap.MailboxInfo, error) { // Status returns mailbox status information. func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, error) { - status := &imap.MailboxStatus{ - Name: m.name, - Flags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"}, - PermanentFlags: []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"}, - } + status := imap.NewMailboxStatus(m.name, items) + status.Flags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft"} + status.PermanentFlags = []string{"\\Answered", "\\Flagged", "\\Deleted", "\\Seen", "\\Draft", "\\*"} messages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) if err != nil { @@ -191,7 +190,11 @@ func (m *imapMailbox) Status(items []imap.StatusItem) (*imap.MailboxStatus, erro } status.Unseen = unseenCount status.Recent = 0 - status.UidNext = uint32(len(messages) + 1) + maxID, err := m.stores.Mails.MaxIDByUserAndFolder(m.user.id, m.name) + if err != nil { + return nil, err + } + status.UidNext = uint32(maxID + 1) status.UidValidity = 1 return status, nil @@ -258,87 +261,61 @@ func (m *imapMailbox) ListMessages(uid bool, seqset *imap.SeqSet, items []imap.F // buildIMAPMessage constructs an imap.Message from a db.Message with the requested items. func (m *imapMailbox) buildIMAPMessage(dbMsg *db.Message, seqNum uint32, items []imap.FetchItem) (*imap.Message, error) { - imapMsg := &imap.Message{ - SeqNum: seqNum, - Uid: uint32(dbMsg.ID), - Flags: m.getMessageFlags(dbMsg), - InternalDate: dbMsg.Date, - Body: make(map[*imap.BodySectionName]imap.Literal), - } + imapMsg := imap.NewMessage(seqNum, items) + imapMsg.Uid = uint32(dbMsg.ID) + imapMsg.Flags = m.getMessageFlags(dbMsg) + imapMsg.InternalDate = dbMsg.Date - rawMsg := buildRawMessage(dbMsg) + rawMsg := messageRawData(dbMsg) imapMsg.Size = uint32(len(rawMsg)) for _, item := range items { switch item { - case imap.FetchUid: - // UID is already set on the struct - case imap.FetchFlags: - // Flags are already set on the struct - case imap.FetchInternalDate: - // InternalDate is already set on the struct - case imap.FetchRFC822Size: - // Size is already set on the struct + case imap.FetchUid, imap.FetchFlags, imap.FetchInternalDate, imap.FetchRFC822Size: + continue case imap.FetchEnvelope: - imapMsg.Envelope = m.buildEnvelope(dbMsg) - case imap.FetchRFC822: - section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}} - imapMsg.Body[section] = bytes.NewReader(rawMsg) - case imap.FetchRFC822Header: - headerBytes := buildRawHeader(dbMsg) - section := &imap.BodySectionName{ - BodyPartName: imap.BodyPartName{ - Specifier: imap.HeaderSpecifier, - }, + hdr, _, err := headerAndBody(rawMsg) + if err == nil { + imapMsg.Envelope, _ = backendutil.FetchEnvelope(hdr) } - imapMsg.Body[section] = bytes.NewReader(headerBytes) - case imap.FetchRFC822Text: - var bodyText string - if dbMsg.TextBody != "" { - bodyText = dbMsg.TextBody - } else if dbMsg.HtmlBody != "" { - bodyText = dbMsg.HtmlBody + if imapMsg.Envelope == nil { + imapMsg.Envelope = m.buildEnvelope(dbMsg) } - section := &imap.BodySectionName{ - BodyPartName: imap.BodyPartName{ - Specifier: imap.TextSpecifier, - }, + case imap.FetchBody, imap.FetchBodyStructure: + hdr, body, err := headerAndBody(rawMsg) + if err == nil { + imapMsg.BodyStructure, _ = backendutil.FetchBodyStructure(hdr, body, item == imap.FetchBodyStructure) } - imapMsg.Body[section] = bytes.NewReader([]byte(bodyText)) default: - // Handle BODY[] and BODY.PEEK[] sections - itemStr := string(item) - if strings.HasPrefix(itemStr, "BODY[]") || strings.HasPrefix(itemStr, "BODY.PEEK[]") { - section := &imap.BodySectionName{BodyPartName: imap.BodyPartName{}} - imapMsg.Body[section] = bytes.NewReader(rawMsg) - } else if strings.HasPrefix(itemStr, "BODY[HEADER") || strings.HasPrefix(itemStr, "BODY.PEEK[HEADER") { - headerBytes := buildRawHeader(dbMsg) - section := &imap.BodySectionName{ - BodyPartName: imap.BodyPartName{ - Specifier: imap.HeaderSpecifier, - }, - } - imapMsg.Body[section] = bytes.NewReader(headerBytes) - } else if strings.HasPrefix(itemStr, "BODY[TEXT") || strings.HasPrefix(itemStr, "BODY.PEEK[TEXT") { - var bodyText string - if dbMsg.TextBody != "" { - bodyText = dbMsg.TextBody - } else if dbMsg.HtmlBody != "" { - bodyText = dbMsg.HtmlBody - } - section := &imap.BodySectionName{ - BodyPartName: imap.BodyPartName{ - Specifier: imap.TextSpecifier, - }, - } - imapMsg.Body[section] = bytes.NewReader([]byte(bodyText)) + section, err := imap.ParseBodySectionName(item) + if err != nil { + continue } + hdr, body, err := headerAndBody(rawMsg) + if err != nil { + return nil, err + } + literal, _ := backendutil.FetchBodySection(hdr, body, section) + imapMsg.Body[section] = literal } } return imapMsg, nil } +func messageRawData(msg *db.Message) []byte { + if msg.RawData != "" { + return []byte(msg.RawData) + } + return buildRawMessage(msg) +} + +func headerAndBody(raw []byte) (textproto.Header, io.Reader, error) { + body := bufio.NewReader(bytes.NewReader(raw)) + hdr, err := textproto.ReadHeader(body) + return hdr, body, err +} + // getMessageFlags returns IMAP flags for a database message. func (m *imapMailbox) getMessageFlags(dbMsg *db.Message) []string { flags := make([]string, 0) @@ -607,28 +584,40 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap continue } + flagSet := make(map[string]bool, len(flags)) for _, flag := range flags { + flagSet[flag] = true + } + + applyFlag := func(flag string, enabled bool) { switch flag { case "\\Seen": - if op == imap.AddFlags || op == imap.SetFlags { - _ = m.stores.Mails.MarkRead(dbMsg.ID) - } else if op == imap.RemoveFlags { - // Mark as unread — update directly - } + _ = m.stores.Mails.MarkReadState(dbMsg.ID, enabled) case "\\Flagged": - if op == imap.AddFlags || op == imap.SetFlags { - _ = m.stores.Mails.MarkFlagged(dbMsg.ID, true) - } else if op == imap.RemoveFlags { - _ = m.stores.Mails.MarkFlagged(dbMsg.ID, false) - } + _ = m.stores.Mails.MarkFlagged(dbMsg.ID, enabled) case "\\Deleted": - if op == imap.AddFlags || op == imap.SetFlags { + if enabled { m.deleted[dbMsg.ID] = true - } else if op == imap.RemoveFlags { + } else { delete(m.deleted, dbMsg.ID) } } } + + switch op { + case imap.SetFlags: + applyFlag("\\Seen", flagSet["\\Seen"]) + applyFlag("\\Flagged", flagSet["\\Flagged"]) + applyFlag("\\Deleted", flagSet["\\Deleted"]) + case imap.AddFlags: + for flag := range flagSet { + applyFlag(flag, true) + } + case imap.RemoveFlags: + for flag := range flagSet { + applyFlag(flag, false) + } + } } return nil @@ -636,6 +625,11 @@ func (m *imapMailbox) UpdateMessagesFlags(uid bool, seqset *imap.SeqSet, op imap // CopyMessages copies messages to another mailbox. func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) error { + dest, ok := canonicalMailboxName(dest) + if !ok { + return backend.ErrNoSuchMailbox + } + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) if err != nil { return err @@ -663,6 +657,7 @@ func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) e Subject: dbMsg.Subject, TextBody: dbMsg.TextBody, HtmlBody: dbMsg.HtmlBody, + RawData: dbMsg.RawData, IsRead: dbMsg.IsRead, IsFlagged: dbMsg.IsFlagged, Date: dbMsg.Date, @@ -675,6 +670,35 @@ func (m *imapMailbox) CopyMessages(uid bool, seqset *imap.SeqSet, dest string) e return nil } +// MoveMessages moves messages to another mailbox. +func (m *imapMailbox) MoveMessages(uid bool, seqset *imap.SeqSet, dest string) error { + dest, ok := canonicalMailboxName(dest) + if !ok { + return backend.ErrNoSuchMailbox + } + + dbMessages, err := m.stores.Mails.ListAllByUserAndFolder(m.user.id, m.name) + if err != nil { + return err + } + + for i, dbMsg := range dbMessages { + var match bool + if uid { + match = seqset.Contains(uint32(dbMsg.ID)) + } else { + match = seqset.Contains(uint32(i + 1)) + } + if !match { + continue + } + if err := m.stores.Mails.MoveToFolder(dbMsg.ID, dest); err != nil { + log.Printf("IMAP: failed to move message %d to %s: %v", dbMsg.ID, dest, err) + } + } + return nil +} + // Expunge permanently removes messages marked as \Deleted. func (m *imapMailbox) Expunge() error { if m.deleted == nil { diff --git a/internal/imap_server/server.go b/internal/imap_server/server.go index f0a8bec..6c78eae 100644 --- a/internal/imap_server/server.go +++ b/internal/imap_server/server.go @@ -26,37 +26,46 @@ func NewIMAPServer(cfg config.IMAPConfig, stores *store.Stores) *IMAPServer { } } +func (s *IMAPServer) tlsConfig() (*tls.Config, error) { + if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { + return nil, fmt.Errorf("IMAP 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 IMAP TLS certificate: %w", err) + } + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} + // newServer creates a configured imapserver.Server with the given address. -func (s *IMAPServer) newServer(addr string) *imapserver.Server { +func (s *IMAPServer) newServer(addr string, tlsConfig *tls.Config) *imapserver.Server { be := &imapBackend{stores: s.stores} srv := imapserver.New(be) srv.Addr = addr - srv.AllowInsecureAuth = true + srv.TLSConfig = tlsConfig + srv.AllowInsecureAuth = tlsConfig == nil return srv } // Start starts the IMAP server on the plain-text port. func (s *IMAPServer) Start() error { - srv := s.newServer(s.cfg.Addr) + tlsConfig, err := s.tlsConfig() + if err != nil { + log.Printf("IMAP STARTTLS 未启用: %v", err) + } + srv := s.newServer(s.cfg.Addr, tlsConfig) log.Printf("IMAP server listening on %s", s.cfg.Addr) return srv.ListenAndServe() } // StartTLS starts the IMAP server on the TLS port. func (s *IMAPServer) StartTLS() error { - if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { - return fmt.Errorf("IMAP TLS certificate or key not configured") - } - - cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + tlsConfig, err := s.tlsConfig() if err != nil { - return fmt.Errorf("failed to load IMAP TLS certificate: %w", err) + return err } - srv := s.newServer(s.cfg.TLSAddr) - srv.TLSConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, - } + srv := s.newServer(s.cfg.TLSAddr, tlsConfig) log.Printf("IMAPS server listening on %s", s.cfg.TLSAddr) return srv.ListenAndServeTLS() diff --git a/internal/pop3_server/server.go b/internal/pop3_server/server.go index f9d5fd1..79b9c15 100644 --- a/internal/pop3_server/server.go +++ b/internal/pop3_server/server.go @@ -26,10 +26,18 @@ type POP3Server struct { // NewPOP3Server creates a new POP3 server instance. func NewPOP3Server(cfg config.POP3Config, stores *store.Stores) *POP3Server { - return &POP3Server{ - stores: stores, - cfg: cfg, + return &POP3Server{stores: stores, cfg: cfg} +} + +func (s *POP3Server) tlsConfig() (*tls.Config, error) { + if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { + return nil, fmt.Errorf("POP3 TLS certificate or key not configured") } + cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + if err != nil { + return nil, fmt.Errorf("load POP3 TLS certificate failed: %w", err) + } + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil } // Start starts the POP3 server on the configured plain-text port. @@ -48,7 +56,6 @@ func (s *POP3Server) Start() error { for { conn, err := s.listener.Accept() if err != nil { - // Listener closed return } s.wg.Add(1) @@ -64,16 +71,12 @@ func (s *POP3Server) Start() error { // StartTLS starts the POP3 server on the configured TLS port. func (s *POP3Server) StartTLS() error { - if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { - return fmt.Errorf("POP3 TLS certificate or key not configured") - } - - cert, err := tls.LoadX509KeyPair(s.cfg.TLSCert, s.cfg.TLSKey) + tlsConfig, err := s.tlsConfig() if err != nil { - return fmt.Errorf("load POP3 TLS certificate failed: %w", err) + return err } - listener, err := tls.Listen("tcp", s.cfg.TLSAddr, &tls.Config{Certificates: []tls.Certificate{cert}}) + listener, err := tls.Listen("tcp", s.cfg.TLSAddr, tlsConfig) if err != nil { return fmt.Errorf("POP3 TLS listen failed: %w", err) } @@ -102,25 +105,20 @@ func (s *POP3Server) StartTLS() error { // handleConn handles a single POP3 client connection. func (s *POP3Server) handleConn(conn net.Conn) { defer conn.Close() - - // Set read/write deadlines conn.SetDeadline(time.Now().Add(10 * time.Minute)) reader := bufio.NewReader(conn) - - // Session state var user *db.User var messages []pop3Message var deleted map[int]bool + tlsActive := false - // Send greeting sendResponse(conn, "+OK MailGo POP3 server ready") - // Main command loop for { line, err := reader.ReadString('\n') if err != nil { - return // connection closed or error + return } line = strings.TrimSpace(line) @@ -128,7 +126,6 @@ func (s *POP3Server) handleConn(conn net.Conn) { continue } - // Parse command parts := strings.SplitN(line, " ", 2) cmd := strings.ToUpper(parts[0]) arg := "" @@ -136,6 +133,12 @@ func (s *POP3Server) handleConn(conn net.Conn) { arg = strings.TrimSpace(parts[1]) } + authenticated := user != nil && user.ID != 0 + if !authenticated && requiresAuth(cmd) { + sendResponse(conn, "-ERR authentication required") + continue + } + switch cmd { case "USER": user, messages, deleted = s.handleUSER(conn, arg, user) @@ -148,21 +151,40 @@ func (s *POP3Server) handleConn(conn net.Conn) { case "RETR": s.handleRETR(conn, arg, messages, deleted) case "DELE": - s.handleDELE(conn, arg, deleted) + s.handleDELE(conn, arg, messages, deleted) case "NOOP": sendResponse(conn, "+OK") case "RSET": deleted = make(map[int]bool) sendResponse(conn, "+OK") case "QUIT": - // In UPDATE state, actually delete marked messages s.expungeDeleted(messages, deleted, user) sendResponse(conn, "+OK MailGo POP3 server signing off") return case "CAPA": - sendResponse(conn, "+OK Capability list follows") - sendResponse(conn, "USER") - sendResponse(conn, ".") + s.handleCAPA(conn, tlsActive) + case "STLS": + if authenticated { + sendResponse(conn, "-ERR STLS not allowed after authentication") + continue + } + if tlsActive { + sendResponse(conn, "-ERR TLS already active") + continue + } + tlsConfig, err := s.tlsConfig() + if err != nil { + sendResponse(conn, "-ERR TLS not available") + continue + } + sendResponse(conn, "+OK Begin TLS negotiation") + tlsConn := tls.Server(conn, tlsConfig) + if err := tlsConn.Handshake(); err != nil { + return + } + conn = tlsConn + reader = bufio.NewReader(conn) + tlsActive = true case "TOP": s.handleTOP(conn, arg, messages, deleted) case "UIDL": @@ -173,6 +195,27 @@ func (s *POP3Server) handleConn(conn net.Conn) { } } +func requiresAuth(cmd string) bool { + switch cmd { + case "STAT", "LIST", "RETR", "DELE", "RSET", "TOP", "UIDL": + return true + default: + return false + } +} + +func (s *POP3Server) handleCAPA(conn net.Conn, tlsActive bool) { + sendResponse(conn, "+OK Capability list follows") + sendResponse(conn, "USER") + sendResponse(conn, "TOP") + sendResponse(conn, "UIDL") + sendResponse(conn, "RESP-CODES") + if !tlsActive && s.cfg.TLSCert != "" && s.cfg.TLSKey != "" { + sendResponse(conn, "STLS") + } + sendResponse(conn, ".") +} + // pop3Message holds a message and its computed size for POP3. type pop3Message struct { id uint @@ -198,7 +241,7 @@ func (s *POP3Server) loadMessages(user *db.User) []pop3Message { msgs = append(msgs, pop3Message{ id: dbMsgs[i].ID, raw: raw, - size: len(raw), + size: len(normalizePOP3Data(raw)), message: &dbMsgs[i], }) } @@ -212,12 +255,9 @@ func (s *POP3Server) handleUSER(conn net.Conn, username string, currentUser *db. return currentUser, nil, nil } - // Look up the user by email user, err := s.stores.Users.GetByEmail(username) if err != nil { - // Don't reveal whether user exists yet (standard POP3 behavior) sendResponse(conn, "+OK") - // Store the attempted username for PASS to use return &db.User{Username: username}, nil, nil } @@ -232,23 +272,14 @@ func (s *POP3Server) handlePASS(conn net.Conn, password string, user *db.User) ( return nil, nil, nil } - // Authenticate authUser, err := s.stores.Users.Authenticate(user.Username, password) if err != nil { - // If username was an email, try using it directly - if strings.Contains(user.Username, "@") { - authUser, err = s.stores.Users.Authenticate(user.Username, password) - } - if err != nil { - sendResponse(conn, "-ERR authentication failed") - return nil, nil, nil - } + sendResponse(conn, "-ERR authentication failed") + return nil, nil, nil } - // Load messages messages := s.loadMessages(authUser) deleted := make(map[int]bool) - sendResponse(conn, fmt.Sprintf("+OK authenticated, %d messages", len(messages))) return authUser, messages, deleted } @@ -269,7 +300,6 @@ func (s *POP3Server) handleSTAT(conn net.Conn, messages []pop3Message, deleted m // handleLIST processes the LIST command (with optional message number). func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { if arg == "" { - // List all messages sendResponse(conn, "+OK message list follows") for i, msg := range messages { if !deleted[i+1] { @@ -277,19 +307,19 @@ func (s *POP3Server) handleLIST(conn net.Conn, arg string, messages []pop3Messag } } sendResponse(conn, ".") - } else { - // List specific message - num, err := strconv.Atoi(arg) - if err != nil || num < 1 || num > len(messages) { - sendResponse(conn, "-ERR no such message") - return - } - if deleted[num] { - sendResponse(conn, "-ERR message deleted") - return - } - sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size)) + return } + + num, err := strconv.Atoi(arg) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].size)) } // handleRETR processes the RETR command. @@ -306,29 +336,17 @@ func (s *POP3Server) handleRETR(conn net.Conn, arg string, messages []pop3Messag msg := messages[num-1] sendResponse(conn, fmt.Sprintf("+OK %d octets", msg.size)) - - // Send the raw message, dot-stuffing any lines that start with "." - scanner := bufio.NewScanner(strings.NewReader(msg.raw)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, ".") { - conn.Write([]byte(".")) - } - conn.Write([]byte(line + "\r\n")) - } + writeDotStuffed(conn, msg.raw, nil) conn.Write([]byte(".\r\n")) } // handleDELE processes the DELE command. -func (s *POP3Server) handleDELE(conn net.Conn, arg string, deleted map[int]bool) { +func (s *POP3Server) handleDELE(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { num, err := strconv.Atoi(arg) - if err != nil || num < 1 { - sendResponse(conn, "-ERR invalid message number") + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") return } - if deleted == nil { - deleted = make(map[int]bool) - } if deleted[num] { sendResponse(conn, "-ERR message already deleted") return @@ -339,7 +357,6 @@ func (s *POP3Server) handleDELE(conn net.Conn, arg string, deleted map[int]bool) // handleTOP processes the TOP command (headers + first N lines of body). func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { - // Format: TOP msgNum [n] parts := strings.Fields(arg) if len(parts) < 1 { sendResponse(conn, "-ERR syntax error") @@ -351,7 +368,7 @@ func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message sendResponse(conn, "-ERR no such message") return } - if deleted != nil && deleted[num] { + if deleted[num] { sendResponse(conn, "-ERR message deleted") return } @@ -362,79 +379,42 @@ func (s *POP3Server) handleTOP(conn net.Conn, arg string, messages []pop3Message } msg := messages[num-1] + header, body := splitHeaderBody(msg.raw) sendResponse(conn, "+OK top of message follows") - - // Find blank line separating headers from body - headerEnd := strings.Index(msg.raw, "\r\n\r\n") - if headerEnd == -1 { - headerEnd = strings.Index(msg.raw, "\n\n") - if headerEnd == -1 { - // No body, just send everything - sendResponse(conn, msg.raw) - sendResponse(conn, ".") - return - } - } - - // Send headers - header := msg.raw[:headerEnd] - scanner := bufio.NewScanner(strings.NewReader(header)) - for scanner.Scan() { - line := scanner.Text() - if strings.HasPrefix(line, ".") { - conn.Write([]byte(".")) - } - conn.Write([]byte(line + "\r\n")) - } + writeDotStuffed(conn, header, nil) conn.Write([]byte("\r\n")) - - // Send up to N lines of body - if nLines > 0 { - body := msg.raw[headerEnd:] - scanner := bufio.NewScanner(strings.NewReader(body)) - lineCount := 0 - for scanner.Scan() && lineCount < nLines { - line := scanner.Text() - if strings.HasPrefix(line, ".") { - conn.Write([]byte(".")) - } - conn.Write([]byte(line + "\r\n")) - lineCount++ - } - } - + writeDotStuffed(conn, body, &nLines) conn.Write([]byte(".\r\n")) } // handleUIDL processes the UIDL command. func (s *POP3Server) handleUIDL(conn net.Conn, arg string, messages []pop3Message, deleted map[int]bool) { if arg == "" { - // List all UIDs sendResponse(conn, "+OK UIDL list follows") for i, msg := range messages { - if deleted == nil || !deleted[i+1] { + if !deleted[i+1] { sendResponse(conn, fmt.Sprintf("%d %d", i+1, msg.id)) } } sendResponse(conn, ".") - } else { - // Specific message UID - num, err := strconv.Atoi(arg) - if err != nil || num < 1 || num > len(messages) { - sendResponse(conn, "-ERR no such message") - return - } - if deleted != nil && deleted[num] { - sendResponse(conn, "-ERR message deleted") - return - } - sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id)) + return } + + num, err := strconv.Atoi(arg) + if err != nil || num < 1 || num > len(messages) { + sendResponse(conn, "-ERR no such message") + return + } + if deleted[num] { + sendResponse(conn, "-ERR message deleted") + return + } + sendResponse(conn, fmt.Sprintf("+OK %d %d", num, messages[num-1].id)) } // expungeDeleted actually deletes messages that were marked for deletion. func (s *POP3Server) expungeDeleted(messages []pop3Message, deleted map[int]bool, user *db.User) { - if deleted == nil || user == nil { + if deleted == nil || user == nil || user.ID == 0 { return } for seqNum, msgDeleted := range deleted { @@ -451,11 +431,53 @@ func sendResponse(conn net.Conn, line string) { conn.Write([]byte(line + "\r\n")) } -// buildRawMessage reconstructs a raw RFC822 message from a db.Message. -// This is a local copy to avoid importing from the imap_server package. -func buildRawMessage(msg *db.Message) []byte { - var buf strings.Builder +func normalizePOP3Data(raw string) string { + var b strings.Builder + writeDotStuffed(&b, raw, nil) + return b.String() +} +type stringWriter interface { + Write([]byte) (int, error) +} + +func writeDotStuffed(w stringWriter, raw string, maxLines *int) { + raw = strings.ReplaceAll(raw, "\r\n", "\n") + raw = strings.ReplaceAll(raw, "\r", "\n") + lines := strings.Split(raw, "\n") + written := 0 + for i, line := range lines { + if maxLines != nil && written >= *maxLines { + break + } + if i == len(lines)-1 && line == "" { + break + } + if strings.HasPrefix(line, ".") { + w.Write([]byte(".")) + } + w.Write([]byte(line + "\r\n")) + written++ + } +} + +func splitHeaderBody(raw string) (string, string) { + if idx := strings.Index(raw, "\r\n\r\n"); idx >= 0 { + return raw[:idx], raw[idx+4:] + } + if idx := strings.Index(raw, "\n\n"); idx >= 0 { + return raw[:idx], raw[idx+2:] + } + return raw, "" +} + +// buildRawMessage reconstructs a raw RFC822 message from a db.Message. +func buildRawMessage(msg *db.Message) []byte { + if msg.RawData != "" { + return []byte(msg.RawData) + } + + var buf strings.Builder buf.WriteString(fmt.Sprintf("From: %s\r\n", msg.FromAddr)) buf.WriteString(fmt.Sprintf("To: %s\r\n", msg.ToAddr)) if msg.CcAddr != "" { @@ -490,6 +512,5 @@ func buildRawMessage(msg *db.Message) []byte { } else { buf.WriteString("Content-Type: text/plain; charset=utf-8\r\n\r\n") } - return []byte(buf.String()) } diff --git a/internal/smtp_server/server.go b/internal/smtp_server/server.go index 09d14a2..30a3601 100644 --- a/internal/smtp_server/server.go +++ b/internal/smtp_server/server.go @@ -12,16 +12,24 @@ import ( "mail_go/config" "mail_go/internal/db" "mail_go/internal/mailutil" - "mail_go/internal/store" "mail_go/internal/storage" + "mail_go/internal/store" "github.com/emersion/go-message/mail" + "github.com/emersion/go-sasl" "github.com/emersion/go-smtp" ) -// SMTPServer wraps a go-smtp Server and provides mail receiving capability. +type smtpMode int + +const ( + smtpModeInbound smtpMode = iota + smtpModeSubmission + smtpModeImplicitTLS +) + +// SMTPServer wraps go-smtp servers and provides local mail delivery. type SMTPServer struct { - server *smtp.Server stores *store.Stores storage *storage.AttachmentStorage cfg config.SMTPConfig @@ -29,161 +37,228 @@ type SMTPServer struct { // NewSMTPServer creates a new SMTP server instance. func NewSMTPServer(cfg config.SMTPConfig, stores *store.Stores, attStorage *storage.AttachmentStorage) *SMTPServer { - s := &SMTPServer{ - stores: stores, - storage: attStorage, - cfg: cfg, - } - - be := &smtpBackend{server: s} - srv := smtp.NewServer(be) - srv.Addr = cfg.Addr - srv.Domain = cfg.Domain - srv.MaxMessageBytes = cfg.MaxMessage - srv.AllowInsecureAuth = true - srv.ReadTimeout = 60 * time.Second - srv.WriteTimeout = 60 * time.Second - - s.server = srv - return s + return &SMTPServer{stores: stores, storage: attStorage, cfg: cfg} } -// Start starts the SMTP server on the plain-text port. -func (s *SMTPServer) Start() error { - log.Printf("SMTP server listening on %s", s.cfg.Addr) - return s.server.ListenAndServe() -} - -// StartTLS starts the SMTP server on the TLS port. -func (s *SMTPServer) StartTLS() error { +func (s *SMTPServer) tlsConfig() (*tls.Config, error) { if s.cfg.TLSCert == "" || s.cfg.TLSKey == "" { - return fmt.Errorf("SMTP TLS certificate or key not configured") + 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 fmt.Errorf("failed to load SMTP TLS certificate: %w", err) + return nil, fmt.Errorf("failed to load SMTP TLS certificate: %w", err) } + return &tls.Config{Certificates: []tls.Certificate{cert}}, nil +} - // 创建一个新的 SMTP 服务器实例用于 TLS 端口 - be := &smtpBackend{server: s} +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 = s.cfg.TLSAddr + srv.Addr = addr srv.Domain = s.cfg.Domain srv.MaxMessageBytes = s.cfg.MaxMessage - srv.AllowInsecureAuth = false + srv.AllowInsecureAuth = tlsConfig == nil srv.ReadTimeout = 60 * time.Second srv.WriteTimeout = 60 * time.Second - srv.TLSConfig = &tls.Config{ - Certificates: []tls.Certificate{cert}, + 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 srv.ListenAndServeTLS() + 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, - rcpts: make([]string, 0), - attachments: make([]*db.Attachment, 0), + 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 - from string - rcpts []string + backend *smtpBackend + mode smtpMode + from string + rcpts []string authenticated bool - username string - attachments []*db.Attachment + userID uint + email string } -// AuthPlain authenticates the user with plain-text credentials. -func (s *smtpSession) AuthPlain(username, password string) error { - user, err := s.backend.server.stores.Users.Authenticate(username, password) - if err != nil { - return fmt.Errorf("authentication failed: %w", err) +// 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 } - s.authenticated = true - s.username = user.Username - return nil + 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] - s.attachments = s.attachments[:0] return nil } // Rcpt validates and records a recipient address (RCPT TO command). -// It verifies that the recipient domain exists in the system and the user exists. func (s *smtpSession) Rcpt(to string, opts *smtp.RcptOptions) error { - parts := strings.SplitN(to, "@", 2) - if len(parts) != 2 { - return fmt.Errorf("invalid recipient address: %s", to) - } - - userName := parts[0] - domainName := parts[1] - - // Check if domain is managed by this system - domain, err := s.backend.server.stores.Domains.GetByName(domainName) - if err != nil { - return fmt.Errorf("domain not found: %s", domainName) - } - - // Check if the user exists in this domain - _, err = s.backend.server.stores.Users.GetByUsername(userName, domain.ID) - if err != nil { - return fmt.Errorf("user not found: %s", to) + 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 } -// Data handles the message body (DATA command). It parses the MIME message, -// extracts fields and attachments, and stores the message for each recipient. +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 { - // Read all message data + 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) } - // Parse as MIME message + 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 fmt.Errorf("failed to parse MIME message: %w", err) + return nil, fmt.Errorf("failed to parse MIME message: %w", err) } - // Extract headers — 使用 AddressList 解码 RFC 2047 编码的地址 header := mr.Header - - fromAddr := mailutil.FormatAddressList(&header, "From") - toAddr := mailutil.FormatAddressList(&header, "To") - ccAddr := mailutil.FormatAddressList(&header, "Cc") - subject, _ := header.Subject() - messageID, _ := header.MessageID() - date, _ := header.Date() - if date.IsZero() { - date = time.Now() + 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() } - var textBody, htmlBody string - var attachments []*db.Attachment - - // Iterate through all MIME parts for { p, err := mr.NextPart() if err == io.EOF { @@ -202,16 +277,15 @@ func (s *smtpSession) Data(r io.Reader) error { 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") { - textBody = decoded + msg.textBody = decoded } else if strings.HasPrefix(contentType, "text/html") { - htmlBody = decoded + msg.htmlBody = decoded } case *mail.AttachmentHeader: @@ -225,83 +299,43 @@ func (s *smtpSession) Data(r io.Reader) error { log.Printf("SMTP: error reading attachment part: %v", readErr) continue } - - relPath, saveErr := s.backend.server.storage.Save(filename, buf) - if saveErr != nil { - log.Printf("SMTP: failed to save attachment %s: %v", filename, saveErr) - continue - } - - attachments = append(attachments, &db.Attachment{ + msg.attachments = append(msg.attachments, &db.Attachment{ FileName: filename, - FilePath: relPath, ContentType: contentType, FileSize: int64(len(buf)), }) } } - // Fallback: if no text body was extracted from MIME parts, use the raw data - if textBody == "" && htmlBody == "" { - textBody = string(data) + if msg.textBody == "" && msg.htmlBody == "" { + msg.textBody = string(data) } + return msg, nil +} - // Create a Message record for each verified recipient - for _, rcpt := range s.rcpts { - user, err := s.backend.server.stores.Users.GetByEmail(rcpt) - if err != nil { - log.Printf("SMTP: recipient not found %s, skipping", rcpt) - continue - } - - msg := &db.Message{ - UserID: user.ID, - MessageID: messageID, - Folder: "INBOX", - FromAddr: fromAddr, - ToAddr: toAddr, - CcAddr: ccAddr, - Subject: subject, - TextBody: textBody, - HtmlBody: htmlBody, - RawData: string(data), - IsRead: false, - IsFlagged: false, - Date: date, - } - - if createErr := s.backend.server.stores.Mails.Create(msg); createErr != nil { - log.Printf("SMTP: failed to create message for %s: %v", rcpt, createErr) - continue - } - - // Create Attachment records linked to the new message - for _, att := range attachments { - attCopy := db.Attachment{ - MessageID: msg.ID, - FileName: att.FileName, - FilePath: att.FilePath, - ContentType: att.ContentType, - FileSize: att.FileSize, - } - 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) +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 nil + 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] - s.attachments = s.attachments[:0] } // Logout is called when the SMTP connection is closed. diff --git a/internal/store/mail_store.go b/internal/store/mail_store.go index 2b5afcd..315fec1 100644 --- a/internal/store/mail_store.go +++ b/internal/store/mail_store.go @@ -24,7 +24,9 @@ type MailStore interface { ListByUserAndFolder(userID uint, folder string, page, size int) ([]db.Message, int64, error) ListAllByUserAndFolder(userID uint, folder string) ([]db.Message, error) CountByUserAndFolder(userID uint, folder string) (int64, error) + MaxIDByUserAndFolder(userID uint, folder string) (uint, error) MarkRead(id uint) error + MarkReadState(id uint, read bool) error MarkFlagged(id uint, flagged bool) error MoveToFolder(id uint, folder string) error Delete(id uint) error @@ -81,7 +83,12 @@ func (s *mailStoreGorm) ListByUserAndFolder(userID uint, folder string, page, si // MarkRead sets the IsRead flag to true for a message. func (s *mailStoreGorm) MarkRead(id uint) error { - return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_read", true).Error + return s.MarkReadState(id, true) +} + +// MarkReadState sets the IsRead flag for a message. +func (s *mailStoreGorm) MarkReadState(id uint, read bool) error { + return s.db.Model(&db.Message{}).Where("id = ?", id).Update("is_read", read).Error } // MarkFlagged sets the IsFlagged flag for a message. @@ -132,6 +139,18 @@ func (s *mailStoreGorm) CountByUserAndFolder(userID uint, folder string) (int64, return count, nil } +// MaxIDByUserAndFolder returns the highest message ID for a user folder. +func (s *mailStoreGorm) MaxIDByUserAndFolder(userID uint, folder string) (uint, error) { + var maxID uint + if err := s.db.Model(&db.Message{}). + Where("user_id = ? AND folder = ?", userID, folder). + Select("COALESCE(MAX(id), 0)"). + Scan(&maxID).Error; err != nil { + return 0, err + } + return maxID, nil +} + // CountByFolder returns the total count of messages in a given folder. func (s *mailStoreGorm) CountByFolder(folder string) (int64, error) { var count int64 diff --git a/internal/web/handlers/mail.go b/internal/web/handlers/mail.go index 0101da6..5e778b1 100644 --- a/internal/web/handlers/mail.go +++ b/internal/web/handlers/mail.go @@ -4,7 +4,6 @@ import ( "fmt" "io" "net/http" - "net/smtp" "path/filepath" "strconv" "strings" @@ -225,11 +224,61 @@ func (h *MailHandler) DoSend(c *gin.Context) { sb.WriteString(body) } - // Send via local SMTP - err := smtp.SendMail("localhost:25", nil, fromAddr, strings.Split(to, ","), []byte(sb.String())) - if err != nil { - // Log the error but still save to sent folder — SMTP may not be running yet - fmt.Printf("SMTP发送失败(邮件仍保存到发件箱): %v\n", err) + allRecipients := append(parseAddressInput(to), parseAddressInput(cc)...) + localUsers := make([]*db.User, 0, len(allRecipients)) + var unsupported []string + for _, rcpt := range allRecipients { + user, err := h.stores.Users.GetByEmail(rcpt) + if err != nil { + unsupported = append(unsupported, rcpt) + continue + } + localUsers = append(localUsers, user) + } + if len(unsupported) > 0 { + c.HTML(http.StatusBadRequest, "compose", gin.H{ + "currentUser": currentUser, + "activeFolder": "compose", + "error": fmt.Sprintf("暂不支持外部投递: %s", strings.Join(unsupported, ", ")), + "to": to, + "subject": subject, + "cc": cc, + "bodyContent": htmlBody, + "usedBytes": currentUser.UsedBytes, + "quotaBytes": currentUser.QuotaBytes, + }) + return + } + + for _, rcptUser := range localUsers { + inboxMsg := &db.Message{ + UserID: rcptUser.ID, + MessageID: messageID, + Folder: "INBOX", + FromAddr: fromAddr, + ToAddr: to, + CcAddr: cc, + Subject: subject, + TextBody: body, + HtmlBody: htmlBody, + RawData: sb.String(), + Date: now, + IsRead: false, + } + if createErr := h.stores.Mails.Create(inboxMsg); createErr != nil { + c.HTML(http.StatusInternalServerError, "compose", gin.H{ + "currentUser": currentUser, + "activeFolder": "compose", + "error": fmt.Sprintf("投递邮件失败: %v", createErr), + "to": to, + "subject": subject, + "cc": cc, + "bodyContent": htmlBody, + "usedBytes": currentUser.UsedBytes, + "quotaBytes": currentUser.QuotaBytes, + }) + return + } } // Save to Sent folder @@ -243,6 +292,7 @@ func (h *MailHandler) DoSend(c *gin.Context) { Subject: subject, TextBody: body, HtmlBody: htmlBody, + RawData: sb.String(), Date: now, IsRead: true, } @@ -306,6 +356,20 @@ func (h *MailHandler) DoSend(c *gin.Context) { c.Redirect(http.StatusFound, "/sent") } +func parseAddressInput(input string) []string { + parts := strings.FieldsFunc(input, func(r rune) bool { + return r == ',' || r == ';' || r == '\n' || r == '\r' || r == '\t' || r == ' ' + }) + addresses := make([]string, 0, len(parts)) + for _, part := range parts { + addr := strings.TrimSpace(part) + if addr != "" { + addresses = append(addresses, addr) + } + } + return addresses +} + // mimeTypes maps common file extensions to MIME types. var mimeTypes = map[string]string{ ".txt": "text/plain", diff --git a/main.go b/main.go index a03167e..22504c4 100644 --- a/main.go +++ b/main.go @@ -177,13 +177,18 @@ func main() { log.Printf("SMTP 服务启动失败: %v", err) } }() - // Start SMTPS if TLS is configured + // 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) + } + }() } // 7. Start IMAP server diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..51b220c --- /dev/null +++ b/todo.md @@ -0,0 +1,145 @@ +# 外部邮件投递功能 TODO + +## 目标 + +实现认证用户通过 Outlook/Web 向外部邮箱地址发送邮件,例如: + +- `someone@qq.com` +- `someone@gmail.com` +- `someone@outlook.com` + +当前系统只支持本地用户投递,外部收件人会被拒绝。外部投递需要实现 SMTP 出站发送能力,同时避免开放中继风险。 + +## 阶段 1:最小可用外部投递 + +### 安全策略 + +- [ ] 仅允许已认证用户外发。 +- [ ] `MAIL FROM` 必须等于登录用户邮箱。 +- [ ] Web 发信的 From 必须等于当前登录用户邮箱。 +- [ ] 限制单封邮件最大外部收件人数。 +- [ ] 限制单封邮件大小。 +- [ ] 明确拒绝未认证外部投递,防止开放中继。 + +### DNS / MX 查询 + +- [ ] 从外部收件人地址解析目标域名。 +- [ ] 使用 `net.LookupMX(domain)` 查询 MX 记录。 +- [ ] 按 MX 优先级排序。 +- [ ] 如果没有 MX,可按 RFC 规则尝试直接连接域名本身。 +- [ ] 记录 MX 查询失败原因。 + +### SMTP 出站发送 + +- [ ] 新增内部 outbound mailer 模块,例如 `internal/outbound/`。 +- [ ] 实现连接目标 MX 的 `:25` 端口。 +- [ ] 发送 `EHLO`。 +- [ ] 解析对方 SMTP 能力。 +- [ ] 如支持 `STARTTLS`,执行 STARTTLS。 +- [ ] TLS 成功后重新 `EHLO`。 +- [ ] 发送 `MAIL FROM`。 +- [ ] 发送 `RCPT TO`。 +- [ ] 发送 `DATA`。 +- [ ] 发送邮件原始内容。 +- [ ] 发送 `QUIT`。 +- [ ] 区分临时失败和永久失败。 + +### SMTP 客户端提交集成 + +- [ ] 修改 `internal/smtp_server/server.go`。 +- [ ] 对认证用户提交的外部收件人,不再直接拒绝。 +- [ ] 本地收件人仍走本地 INBOX 投递。 +- [ ] 外部收件人调用 outbound mailer。 +- [ ] 外部投递成功后保存 Sent 副本。 +- [ ] 外部投递失败时向 SMTP 客户端返回明确错误。 + +### Web 发信集成 + +- [ ] 修改 `internal/web/handlers/mail.go`。 +- [ ] Web 发信支持外部收件人。 +- [ ] 本地收件人走本地投递。 +- [ ] 外部收件人调用 outbound mailer。 +- [ ] 外部投递失败时页面显示错误。 +- [ ] 成功后保存 Sent 副本。 + +### 日志与错误 + +- [ ] 记录每次外部投递的目标域名、MX、收件人、结果。 +- [ ] 记录 SMTP 响应码和响应文本。 +- [ ] 临时失败返回可识别错误。 +- [ ] 永久失败返回可识别错误。 + +### 验证 + +- [ ] 使用 Outlook 向外部地址发送测试邮件。 +- [ ] 使用 Web 发信向外部地址发送测试邮件。 +- [ ] 测试 MX 查询失败。 +- [ ] 测试目标邮箱不存在。 +- [ ] 测试对方服务器临时拒收。 +- [ ] 测试未认证用户不能外部投递。 + +## 阶段 2:生产级出站队列 + +### 出站队列表 + +- [ ] 新增 outbound queue 数据表。 +- [ ] 保存发件人、收件人、RawData、状态、重试次数、下一次重试时间。 +- [ ] 保存最后一次 SMTP 响应。 +- [ ] 保存创建时间、更新时间、完成时间。 + +### 后台投递 worker + +- [ ] 实现后台 worker 扫描待投递队列。 +- [ ] 实现指数退避重试。 +- [ ] 临时失败进入重试。 +- [ ] 永久失败进入失败状态。 +- [ ] 超过最大重试周期后生成失败状态。 + +### 退信 + +- [ ] 为永久失败生成退信邮件。 +- [ ] 为超过重试周期的临时失败生成退信邮件。 +- [ ] 将退信投递到发件人 INBOX。 +- [ ] 退信中包含原始错误和目标收件人。 + +### DKIM 签名 + +- [ ] 使用域名 DKIM 私钥为外发邮件签名。 +- [ ] 添加 `DKIM-Signature` 头。 +- [ ] 支持当前域名的 selector。 +- [ ] 验证 DNS 中 DKIM TXT 记录匹配。 + +### 发送限制与防滥用 + +- [ ] 每用户每分钟发送限制。 +- [ ] 每用户每日发送限制。 +- [ ] 单封最大收件人数限制。 +- [ ] 单封最大大小限制。 +- [ ] 记录异常发送行为。 +- [ ] 管理员可禁用用户外发能力。 + +### 管理后台 + +- [ ] 增加外发队列页面。 +- [ ] 显示投递状态。 +- [ ] 显示失败原因。 +- [ ] 支持手动重试。 +- [ ] 支持取消队列任务。 + +## DNS 与服务器配置检查 + +外部投递不仅需要代码,还需要正确 DNS 和服务器信誉配置。 + +- [ ] SPF 记录,例如:`v=spf1 mx ip4:服务器IP -all` +- [ ] DKIM 记录,例如:`default._domainkey.example.com TXT ...` +- [ ] DMARC 记录,例如:`v=DMARC1; p=quarantine; rua=mailto:dmarc@example.com` +- [ ] PTR / rDNS 反向解析指向邮件主机名。 +- [ ] 邮件主机名 A/AAAA 记录指向服务器。 +- [ ] 服务器 25 端口出站未被云厂商封锁。 +- [ ] 主机名、HELO/EHLO 名称、证书域名尽量一致。 + +## 当前边界 + +- 当前已实现 Outlook 本地收发和同步兼容性修复。 +- 当前外部地址会被明确拒绝,避免开放中继。 +- 外部投递应优先实现阶段 1,再考虑阶段 2。 \ No newline at end of file