修复部分功能

This commit is contained in:
2026-06-02 20:33:08 +08:00
parent b35d468396
commit 59719586a1
10 changed files with 753 additions and 426 deletions
+21 -16
View File
@@ -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
}
+3 -2
View File
@@ -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
+126 -102
View File
@@ -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 {
+22 -13
View File
@@ -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()
+150 -129
View File
@@ -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())
}
+190 -156
View File
@@ -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.
+20 -1
View File
@@ -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
+70 -6
View File
@@ -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",
+6 -1
View File
@@ -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
+145
View File
@@ -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。