修复部分功能

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
+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",